From f8aaa9ab72de1e075317f529e5feb29752c0be2c Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Tue, 8 Sep 2026 09:33:44 +0200 Subject: [PATCH 01/13] refactor: checkpoint quality purge before main integration Consolidate duplicate implementations, remove unused public bindings and dependencies, and simplify control flow. First-pass audit and targeted checks recorded; final validation follows main integration. Co-Authored-By: Claude Fable 5.1 --- .../api/auth/api-key-service.ts | 6 +- app/apps/shell-super-app/api/auth/config.ts | 36 +- .../api/auth/environment-file-provider.ts | 19 + .../api/auth/gateway-issuer-config.ts | 34 +- .../api/auth/identity-lifecycle.ts | 2 +- .../api/auth/impersonation-service.ts | 243 ++- app/apps/shell-super-app/api/auth/service.ts | 75 +- .../api/auth/stage-demo-bootstrap-contract.ts | 47 +- app/apps/shell-super-app/api/index.ts | 67 +- .../api/modules/deployment-allowlist.ts | 63 +- .../api/modules/installed-module-catalog.ts | 6 +- .../api/modules/shell-composition.ts | 96 +- .../api/modules/shell-resources.ts | 295 ++- .../shell-super-app/drizzle.auth.config.ts | 32 +- app/apps/shell-super-app/modern.config.ts | 116 +- app/apps/shell-super-app/package.json | 6 - app/apps/shell-super-app/shared/api.ts | 105 +- .../shared/ultramodern-build.ts | 29 +- .../shared/vertical-showcase.tsx | 26 + .../routes/[lang]/modules/[moduleId]/page.tsx | 34 +- .../[resourceType]/[resourceId]/page.tsx | 195 +- .../src/routes/[lang]/search/page.tsx | 176 +- .../src/routes/shell-content-layout.tsx | 57 + .../src/routes/shell-frame.tsx | 579 +++-- .../src/routes/ultramodern-jsonld.ts | 112 +- .../src/routes/ultramodern-route-head.tsx | 11 +- .../src/routes/ultramodern-route-metadata.ts | 12 - .../src/routes/use-shell-controls.ts | 2 +- .../src/routes/vertical-components.tsx | 57 +- .../src/routes/vertical-components.worker.tsx | 57 +- .../tests/integration/auth-runtime.test.ts | 95 +- .../generated-owner-isolation.test.ts | 55 +- .../support/impersonation-service-doubles.ts | 12 - .../tests/unit/impersonation-service.test.ts | 87 +- .../unit/installed-module-catalog.test.ts | 62 +- .../unit/installed-outbox-matcher.test.ts | 46 +- app/package.json | 12 +- app/packages/core-runtime/drizzle.config.ts | 32 +- app/packages/core-runtime/package.json | 1 - .../core-runtime/scripts/verify-db-schema.mts | 198 +- .../core-runtime/src/actions/collector.ts | 49 +- .../core-runtime/src/actions/definition.ts | 41 +- .../core-runtime/src/actions/repository.ts | 126 +- .../core-runtime/src/actions/runtime.ts | 22 +- .../src/auth/legal-entity-context.ts | 5 +- .../src/auth/principal-management.ts | 50 +- .../src/auth/principal-resolver.ts | 6 +- .../support-recovery-principal-context.ts | 24 +- .../src/auth/system-principal-context.ts | 17 +- .../src/authorization/rollout-decision.ts | 17 +- .../src/database/postgres-failure.ts | 42 +- app/packages/core-runtime/src/db/client.ts | 1 - app/packages/core-runtime/src/db/config.ts | 73 +- .../src/environment/dotenv-provider.ts | 36 + .../src/environment/drizzle-config.ts | 33 + app/packages/core-runtime/src/index.ts | 3 +- .../action-authorization-provisioning.ts | 80 +- .../src/install/context-bootstrap-shared.ts | 125 ++ .../src/install/stage-context-bootstrap.ts | 105 +- .../actions/bind-managed-api-key.action.ts | 5 +- .../actions/bind-self-api-key.action.ts | 5 +- .../actions/change-principal-status.action.ts | 7 +- .../change-tenant-module-state.action.ts | 9 +- .../create-non-human-principal.action.ts | 7 +- .../record-support-impersonation.action.ts | 7 +- ...t-managed-api-key-binding-status.action.ts | 7 +- .../set-self-api-key-binding-status.action.ts | 7 +- .../src/modules/application-composition.ts | 21 +- .../core-runtime/src/modules/catalog.ts | 77 +- .../core-runtime/src/modules/manifest.ts | 48 +- .../src/modules/module-state-gate.ts | 2 +- .../src/modules/runtime-registration.ts | 28 +- .../src/modules/shell-contribution.ts | 82 +- .../modules/tenant-module-state-service.ts | 2 +- .../core-runtime/src/operations/context.ts | 15 +- .../core-runtime/src/outbox/definition.ts | 91 +- .../core-runtime/src/outbox/errors.ts | 11 +- .../core-runtime/src/outbox/process.ts | 2 +- .../core-runtime/src/outbox/repository.ts | 30 +- .../core-runtime/src/outbox/runtime.ts | 69 +- .../core-runtime/src/permissions/config.ts | 58 +- .../src/permissions/context-access.ts | 14 +- .../core-runtime/src/permissions/service.ts | 4 +- .../core-runtime/src/reads/context.ts | 38 +- .../core-runtime/src/reads/definition.ts | 36 +- .../core-runtime/src/reads/runtime.ts | 52 +- .../core-runtime/src/search/persistence.ts | 2 +- .../core-runtime/src/search/projection.ts | 251 ++- .../src/testing/module-contract.ts | 59 + .../tests/integration/action-runtime.test.ts | 64 +- .../integration/search-persistence.test.ts | 6 +- .../integration/tenant-module-state.test.ts | 65 +- .../core-runtime/tests/support/database.ts | 15 +- .../tests/support/sql-connection.ts | 19 + .../tests/unit/action-definition.test.ts | 58 +- .../tests/unit/action-runtime.test.ts | 29 +- .../tests/unit/module-catalog.test.ts | 47 +- .../tests/unit/read-runtime.test.ts | 73 +- .../tests/unit/search-ingestion.test.ts | 4 +- .../tests/unit/search-projection.test.ts | 16 +- .../tests/unit/tenant-module-state.test.ts | 49 +- .../gateway-principal-verifier/package.json | 1 - app/packages/shared-contracts/package.json | 1 - .../shared-contracts/src/gateway-context.ts | 27 +- .../shared-contracts/src/problem-details.ts | 181 +- .../shared-contracts/src/ultramodern-build.ts | 30 + .../tests/unit/ultramodern-build.test.ts | 39 + .../shared-contracts/tooling/modern-config.ts | 120 ++ .../shared-design-tokens/package.json | 3 - app/pnpm-lock.yaml | 678 +----- app/quality-audit/knip-runtime-model.mts | 14 +- app/scripts/assert-mf-types.mts | 72 +- .../protected-entrypoint-inventory.mts | 2 +- .../authorization/rollout-contract.mts | 15 +- app/scripts/bootstrap-agent-skills.mts | 85 +- app/scripts/check-authorization-readiness.mts | 51 +- .../check-database-access-boundaries.mts | 5 +- .../check-module-entrypoint-boundaries.mts | 496 +++-- app/scripts/check-ontos-module-contracts.mts | 21 +- .../check-ultramodern-api-boundaries.mts | 86 +- app/scripts/database-trust-audit/report.mts | 257 ++- .../generate-node-backend-federation.mts | 77 +- .../generate-ontos-module-contract.mts | 94 +- .../generate-public-surface-assets.mts | 74 +- app/scripts/generate-tanstack-routes.mts | 103 +- app/scripts/generated-module-api-boundary.mts | 77 +- app/scripts/initialize-local-development.mts | 194 +- app/scripts/local-environment-values.mts | 3 +- app/scripts/materialize-outbox-worker.mjs | 93 +- app/scripts/materialize-zerops-runtime.mjs | 42 +- .../migrate-contacts-authorization.mts | 58 +- app/scripts/migrate-strict-effect.mts | 73 +- app/scripts/outbox-worker-delivery.mjs | 9 +- app/scripts/plan-deployment-impact.mts | 170 +- .../postgres/spicedb-database-config.d.mts | 5 - app/scripts/prepare-dev-module-contract.mts | 23 +- app/scripts/proof-cloudflare-version.mts | 72 +- app/scripts/proof-node-backend-federation.mts | 71 +- app/scripts/proof-workerd-ssr.mts | 62 +- ...provision-current-action-authorization.mts | 29 +- app/scripts/published-outbox-contracts.mts | 176 +- .../scaffolding/action-service/scaffold.mts | 2 +- app/scripts/scaffolding/action/scaffold.mts | 2 +- app/scripts/scaffolding/cli.mts | 62 +- .../external-http-adapter/scaffold.mts | 8 +- .../governed-contribution/scaffold.mts | 158 +- .../scaffold.mts | 14 +- .../microvertical-page/scaffold.mts | 146 +- .../scaffolding/module-contract/scaffold.mts | 8 +- .../scaffolding/outbox-message/scaffold.mts | 44 +- .../scaffolding/outbox-worker/scaffold.mts | 175 +- app/scripts/scaffolding/policy/scaffold.mts | 13 +- app/scripts/scaffolding/resource/scaffold.mts | 133 +- .../retire-contribution/scaffold.mts | 6 +- .../search-provider-access/scaffold.mts | 27 +- app/scripts/scaffolding/shared.mts | 249 ++- app/scripts/scaffolding/tailwind-prefix.mts | 7 +- .../scaffolding/tests/fixture-files.mts | 30 + .../tests/resource-generator.test.mts | 44 +- .../tests/retire-contribution.test.mts | 28 +- .../tests/scaffold-generators.test.mts | 16 +- app/scripts/shared/core-node-services.mts | 21 + app/scripts/shared/ultramodern-command.mts | 88 + app/scripts/shared/ultramodern-launch.mts | 36 + .../shared/ultramodern-wrapper-source.mts | 67 + app/scripts/tests/api-only-tooling.test.mts | 128 +- .../initialize-local-development.test.mts | 42 +- .../module-entrypoint-boundaries.test.mts | 71 +- ...sion-current-action-authorization.test.mts | 46 +- .../quality-audit-runtime-model.test.mts | 60 +- app/scripts/tests/quality-audit.test.mts | 5 + .../tests/ultramodern-command.test.mts | 115 + .../typescript-api-contract-boundary.mts | 577 +++-- .../ultramodern-api-boundary-rules.mts | 7 +- .../ultramodern-performance-readiness.mts | 71 +- app/scripts/ultramodern-typecheck.mts | 71 +- .../validate-ultramodern-workspace.mts | 1919 +++++++---------- app/scripts/verify-cloudflare-output.mts | 74 +- .../anti-slop/rules/no-module-mocking.ts | 28 +- .../anti-slop/rules/no-object-parameters.ts | 191 +- .../anti-slop/rules/no-unknown-parameters.ts | 28 +- .../anti-slop/rules/no-unknown-returns.ts | 48 +- .../anti-slop/rules/no-widen-then-assert.ts | 251 ++- .../anti-slop/shared/dictionary-types.ts | 917 ++++---- .../anti-slop/shared/function-parameters.ts | 26 + .../shared/lexical-type-parameters.ts | 98 +- .../anti-slop/shared/type-alias-reference.ts | 13 + .../rules/no-ad-hoc-argv-in-scripts.ts | 388 +--- .../effect-native/rules/no-ambient-date.ts | 435 ++-- .../rules/no-ambient-process-env.ts | 342 ++- .../rules/no-async-script-program.ts | 389 +--- .../effect-native/rules/no-bare-effect-run.ts | 453 ++-- .../rules/no-console-in-scripts.ts | 479 +--- .../rules/no-dependency-parameters.ts | 477 ++-- .../rules/no-direct-node-io-in-scripts.ts | 370 +--- .../effect-native/rules/no-dotenv-loading.ts | 208 +- .../rules/no-driver-failure-inspection.ts | 603 ++---- .../rules/no-duplicate-literal-vocabulary.ts | 400 ++-- .../rules/no-effect-provide-in-library.ts | 366 ++-- .../rules/no-effect-run-in-scripts.ts | 626 +++--- .../rules/no-effect-run-in-tests.ts | 109 +- .../rules/no-environment-record-type.ts | 236 +- .../no-failure-discarding-error-callback.ts | 531 ++--- .../no-hand-built-http-server-in-tests.ts | 267 ++- .../rules/no-hand-built-problem-details.ts | 460 ++-- .../rules/no-hand-parsed-environment-value.ts | 408 ++-- .../rules/no-hand-rolled-tagged-union.ts | 254 ++- .../rules/no-imperative-loop-in-effect-gen.ts | 497 ++--- .../rules/no-interface-first-codec.ts | 371 +--- .../no-json-schema-as-document-contract.ts | 406 ++-- .../effect-native/rules/no-layer-fresh.ts | 192 +- .../rules/no-layer-or-die-outside-root.ts | 361 ++-- .../rules/no-layer-provide-in-library.ts | 100 +- .../rules/no-literal-union-type-alias.ts | 226 +- .../rules/no-local-defect-seam.ts | 318 +-- .../no-manual-config-in-scaffold-templates.ts | 195 +- .../rules/no-manual-cookie-serialization.ts | 158 +- ...al-error-handling-in-scaffold-templates.ts | 68 +- .../rules/no-manual-identity-annotations.ts | 375 ++-- .../rules/no-manual-route-param-parsing.ts | 231 +- .../rules/no-manual-tag-comparison.ts | 535 +++-- .../rules/no-native-error-construction.ts | 173 +- .../rules/no-native-json-parse.ts | 199 +- .../rules/no-native-json-stringify.ts | 331 +-- .../effect-native/rules/no-native-timers.ts | 294 +-- .../rules/no-nested-effect-run.ts | 320 ++- .../rules/no-nullable-schema-field.ts | 437 ++-- .../rules/no-nullable-service-outcome.ts | 208 +- .../rules/no-per-operation-http-api-client.ts | 408 ++-- .../rules/no-per-request-key-material.ts | 492 ++--- .../no-process-exit-outside-script-entry.ts | 616 +----- .../no-promise-first-scaffold-templates.ts | 172 +- .../rules/no-promise-shaped-port.ts | 385 ++-- .../rules/no-raw-effect-adt-tag-check.ts | 196 +- .../rules/no-refinement-outside-schema.ts | 309 +-- .../rules/no-route-local-error-classifier.ts | 542 +++-- .../no-runtime-construction-outside-root.ts | 370 ++-- .../rules/no-scattered-browser-effect-run.ts | 247 ++- .../rules/no-sequential-independent-yields.ts | 473 +--- .../rules/no-string-timestamp-schema.ts | 653 +++--- .../rules/no-structural-document-walking.ts | 462 ++-- .../no-symbol-slotted-operation-record.ts | 230 +- .../rules/no-sync-schema-codec.ts | 238 +- .../no-threaded-correlation-parameter.ts | 336 ++- .../rules/no-throw-in-configuration-parser.ts | 628 +++--- .../rules/no-throw-in-effect-callback.ts | 372 +--- .../rules/no-throw-in-scripts.ts | 323 +-- .../rules/no-unbranded-identifier-schema.ts | 553 ++--- ...-unjustified-file-wide-lint-suppression.ts | 165 +- .../rules/no-unmanaged-mutable-state.ts | 297 ++- .../rules/no-unredacted-secret-field.ts | 481 ++--- .../rules/no-wide-factory-signature.ts | 240 +-- .../rules/prefer-effect-fn-for-operations.ts | 430 ++-- .../rules/prefer-match-over-tag-switch.ts | 203 +- .../rules/require-concurrency-option.ts | 372 +--- ...e-context-service-for-service-interface.ts | 416 ++-- ...re-observability-layers-at-runtime-root.ts | 587 +++-- .../require-timeout-on-external-effect.ts | 480 ++--- app/tools/oxlint/effect-native/shared/ast.ts | 291 +++ .../oxlint/effect-native/shared/bindings.ts | 83 + .../effect-native/shared/effect-identity.ts | 197 ++ .../effect-native/shared/effect-imports.ts | 39 +- .../oxlint/effect-native/shared/imports.ts | 198 ++ .../effect-native/shared/json-globals.ts | 28 + .../effect-native/shared/json-rule-scope.ts | 21 + .../oxlint/effect-native/shared/options.ts | 50 + .../oxlint/effect-native/shared/paths.ts | 101 +- .../oxlint/effect-native/shared/provenance.ts | 200 ++ .../shared/reference-positions.ts | 60 + .../oxlint/effect-native/shared/reporting.ts | 31 + .../effect-native/shared/rule-file-policy.ts | 22 + .../effect-native/shared/scaffold-text.ts | 66 + .../shared/schema-constructor.ts | 30 + .../effect-native/shared/schema-identity.ts | 117 + .../shared/schema-rule-support.ts | 43 + .../effect-native/shared/script-entry.ts | 65 + .../effect-native/shared/source-rule-scope.ts | 17 + .../effect-native/tests/fixtures.test.mts | 55 +- .../packages/static-property-key-controls.ts | 10 + .../valid/packages/template-property-key.ts | 4 + .../scripts/scaffolding/unicode-offsets.mts | 2 + .../src/computed-schema-identity.ts | 12 + .../src/computed-schema-identity.ts | 14 + .../packages/wrapped-mutation-boundary.ts | 6 + .../packages/wrapped-mutation-boundary.ts | 5 + .../tests/json-rule-scope.test.mts | 37 + .../oxlint/effect-native/tests/oxlint.mts | 57 +- .../tests/rule-file-policy.test.mts | 46 + .../tests/scaffold-text-unicode.test.mts | 23 + .../tests/scaffold-unicode.test.mts | 64 + .../tests/shared-helpers.test.mts | 288 +++ .../tests/source-rule-scope.test.mts | 52 + .../api/ares-lookup-read-server.ts | 87 +- .../api/auth/action-principal.ts | 33 +- .../api/counterparties-search-server.ts | 86 +- .../api/counterparty-read-read-server.ts | 94 +- .../counterparty-role-history-read-server.ts | 77 +- .../duplicate-candidate-detail-read-server.ts | 98 +- .../api/governed-detail-read-execution.ts | 139 ++ .../api/governed-read-handler.ts | 112 + ...nization-engagement-profile-read-server.ts | 104 +- .../api/parties-search-server.ts | 83 +- .../party-contact-point-detail-read-server.ts | 95 +- .../api/party-contact-points-read-server.ts | 94 +- .../api/party-correction-read-server.ts | 94 +- .../api/party-detail-read-server.ts | 94 +- .../api/party-match-decision-read-server.ts | 97 +- .../api/party-match-read-server.ts | 97 +- .../api/party-merge-readiness-read-server.ts | 97 +- ...-official-identifier-detail-read-server.ts | 102 +- ...official-identifier-history-read-server.ts | 104 +- .../party-relationship-detail-read-server.ts | 77 +- .../person-engagement-profile-read-server.ts | 104 +- .../party-registry/api/read-server-support.ts | 2 +- .../party-registry/drizzle.config.ts | 32 +- .../party-registry/drizzle.contacts.config.ts | 32 +- app/verticals/party-registry/modern.config.ts | 120 +- app/verticals/party-registry/package.json | 7 - .../scripts/verify-db-schema.mts | 28 +- .../scripts/verify-engagement-db-schema.mts | 17 +- .../shared/actions/archive-party.ts | 5 +- .../actions/confirm-duplicate-parties.ts | 8 +- .../shared/actions/correct-party-fact.ts | 8 +- .../shared/actions/counterparty-create.ts | 1 - .../shared/actions/counterparty-role-add.ts | 1 - .../shared/actions/counterparty-role-end.ts | 1 - .../shared/actions/create-party.ts | 5 +- .../actions/dismiss-duplicate-candidate.ts | 8 +- .../shared/actions/end-contact-point.ts | 4 +- ...mark-duplicate-candidate-needs-evidence.ts | 9 +- .../shared/actions/match-party.ts | 5 +- .../shared/actions/request-search-rebuild.ts | 1 - .../resolve-duplicate-candidate-create.ts | 9 +- .../resolve-duplicate-candidate-match.ts | 9 +- .../shared/actions/unarchive-party.ts | 3 +- .../shared/actions/update-contact-point.ts | 7 +- .../shared/domain/ares-application.ts | 270 ++- .../shared/domain/ares-evidence.ts | 4 +- .../shared/domain/canonical-utc-timestamp.ts | 17 + .../shared/domain/contact-point.ts | 92 +- .../shared/domain/correction-contracts.ts | 16 +- .../shared/domain/counterparty-contract.ts | 11 +- .../shared/domain/engagement-profile.ts | 25 +- .../shared/domain/identifier-contracts.ts | 31 +- .../shared/domain/identity-contracts.ts | 52 +- .../shared/domain/matching-contracts.ts | 120 +- .../merge-alias-resolution-errors/shared.ts | 6 +- .../shared/domain/merge-readiness.ts | 5 +- .../shared/domain/merge-selection.ts | 8 +- .../shared/domain/relationship-contract.ts | 14 +- .../domain/relationship-errors/index.ts | 1 - .../shared/domain/relationship-temporal.ts | 88 +- .../domain/search-projection-gateway.ts | 2 +- .../shared/domain/search-result.ts | 5 +- .../shared/domain/search-semantics.ts | 87 +- .../shared/resources/party-merge.ts | 25 +- .../resources/resource-ref-identifiers.ts | 4 +- .../shared/ultramodern-build.ts | 30 +- .../src/actions/add-contact-point.action.ts | 20 +- ...y-contact-point-added-v1.outbox-message.ts | 13 +- .../add-party-official-identifier.action.ts | 16 +- ...cial-identifier-added-v1.outbox-message.ts | 16 +- .../archive-organization-engagement.action.ts | 6 +- .../src/actions/archive-party.action.ts | 78 +- ...gistry-party-archived-v1.outbox-message.ts | 9 +- .../archive-person-engagement.action.ts | 6 +- .../attach-organization-engagement.action.ts | 6 +- .../attach-person-engagement.action.ts | 6 +- .../confirm-duplicate-parties.action.ts | 40 +- .../src/actions/correct-party-fact.action.ts | 15 - ...-party-fact-corrected-v1.outbox-message.ts | 9 +- .../src/actions/counterparty-create.action.ts | 15 +- ...-counterparty-created-v1.outbox-message.ts | 9 +- .../actions/counterparty-role-add.action.ts | 15 +- ...unterparty-role-added-v1.outbox-message.ts | 9 +- .../actions/counterparty-role-end.action.ts | 15 +- ...unterparty-role-ended-v1.outbox-message.ts | 9 +- .../create-party-relationship.action.ts | 12 - ...-relationship-created-v1.outbox-message.ts | 14 +- .../src/actions/create-party.action.ts | 16 +- ...egistry-party-created-v1.outbox-message.ts | 9 +- .../dismiss-duplicate-candidate.action.ts | 40 +- .../duplicate-case-resolution-handler.ts | 31 + .../src/actions/end-contact-point.action.ts | 20 +- ...y-contact-point-ended-v1.outbox-message.ts | 13 +- .../end-party-official-identifier.action.ts | 16 +- ...cial-identifier-ended-v1.outbox-message.ts | 16 +- .../actions/end-party-relationship.action.ts | 13 +- ...ry-relationship-ended-v1.outbox-message.ts | 14 +- ...plicate-candidate-needs-evidence.action.ts | 40 +- .../src/actions/match-party.action.ts | 6 +- .../actions/party-lifecycle-action-helpers.ts | 65 + .../actions/request-search-rebuild.action.ts | 19 +- ...rch-rebuild-requested-v1.outbox-message.ts | 11 +- ...solve-duplicate-candidate-create.action.ts | 9 +- ...esolve-duplicate-candidate-match.action.ts | 9 +- ...narchive-organization-engagement.action.ts | 6 +- .../src/actions/unarchive-party.action.ts | 47 +- ...stry-party-unarchived-v1.outbox-message.ts | 9 +- .../unarchive-person-engagement.action.ts | 6 +- .../actions/update-contact-point.action.ts | 22 +- ...contact-point-updated-v1.outbox-message.ts | 13 +- ...update-party-official-identifier.action.ts | 28 +- ...al-identifier-updated-v1.outbox-message.ts | 16 +- .../update-party-relationship.action.ts | 13 +- ...-relationship-updated-v1.outbox-message.ts | 19 +- .../src/actions/update-party.action.ts | 79 +- ...egistry-party-updated-v1.outbox-message.ts | 9 +- .../party-registry/src/api/action-gateway.ts | 662 +++--- .../src/api/counterparty-read.read.ts | 2 +- .../src/api/counterparty-role-history.read.ts | 2 +- .../api/duplicate-candidate-detail.read.ts | 2 +- .../src/api/engagement-profile-client.ts | 4 +- .../organization-engagement-profile.read.ts | 2 +- .../src/api/party-command-client.ts | 2 +- .../api/party-contact-point-detail.read.ts | 2 +- .../src/api/party-contact-points.read.ts | 2 +- .../src/api/party-correction.read.ts | 2 +- .../src/api/party-detail.read.ts | 2 +- .../src/api/party-match-decision.read.ts | 2 +- .../src/api/party-match.read.ts | 2 +- .../src/api/party-merge-readiness.read.ts | 2 +- .../party-official-identifier-detail.read.ts | 2 +- .../party-official-identifier-history.read.ts | 2 +- .../src/api/party-registry-http-client.ts | 2 +- .../src/api/party-relationship-detail.read.ts | 2 +- .../src/api/person-engagement-profile.read.ts | 2 +- .../src/db/engagement-schema.ts | 5 +- .../party-registry/src/db/engagement-types.ts | 2 +- app/verticals/party-registry/src/db/schema.ts | 105 +- .../src/federation/page-contacts.tsx | 2 +- .../integrations/ares/ares-subject.service.ts | 44 +- .../src/merge/canonical-survivor-selection.ts | 174 +- .../merge/party-alias-resolution.service.ts | 8 +- .../src/merge/party-alias-resolution.ts | 5 +- .../src/merge/reference-preservation-plan.ts | 82 +- ...-party-without-strong-identifier.policy.ts | 44 +- .../src/routes/[lang]/contacts/page.tsx | 2 +- .../src/routes/ultramodern-route-metadata.ts | 19 +- .../src/search/counterparties.provider.ts | 2 +- .../src/search/parties.provider.ts | 2 +- .../counterparty-persistence.service.ts | 51 +- ...engagement-reference-validation.service.ts | 60 +- ...party-contact-point-persistence.service.ts | 274 +-- .../src/services/party-correction.service.ts | 679 +++--- .../party-identifier-claim.service.ts | 5 - .../party-identity-persistence.service.ts | 144 +- .../party-matching-persistence.service.ts | 557 +++-- ...official-identifier-persistence.service.ts | 107 +- .../party-relationship-persistence.service.ts | 164 +- .../party-search-projection-source.service.ts | 30 +- .../party-search-projection.service.ts | 4 +- .../party-registry/src/worker-host/layer.ts | 17 +- .../tests/components/contacts-page.test.tsx | 2 +- .../tests/integration/ares-governed.test.ts | 18 +- .../integration/database-boundary.test.ts | 13 +- ...teway-assertion-redemption-runtime.test.ts | 15 +- .../identity-party-detail-history.test.ts | 13 +- .../tests/unit/matching-persistence.test.ts | 70 +- .../tests/unit/schema-contract.test.ts | 63 +- .../tests/unit/search-identifier-sync.test.ts | 4 +- .../tests/unit/search-projector.test.ts | 24 +- .../party-registry/vertical.manifest.ts | 2 +- 463 files changed, 22451 insertions(+), 28422 deletions(-) create mode 100644 app/apps/shell-super-app/api/auth/environment-file-provider.ts create mode 100644 app/apps/shell-super-app/shared/vertical-showcase.tsx create mode 100644 app/apps/shell-super-app/src/routes/shell-content-layout.tsx create mode 100644 app/packages/core-runtime/src/environment/dotenv-provider.ts create mode 100644 app/packages/core-runtime/src/environment/drizzle-config.ts create mode 100644 app/packages/core-runtime/src/install/context-bootstrap-shared.ts create mode 100644 app/packages/core-runtime/src/testing/module-contract.ts create mode 100644 app/packages/core-runtime/tests/support/sql-connection.ts create mode 100644 app/packages/shared-contracts/src/ultramodern-build.ts create mode 100644 app/packages/shared-contracts/tests/unit/ultramodern-build.test.ts create mode 100644 app/packages/shared-contracts/tooling/modern-config.ts delete mode 100644 app/scripts/postgres/spicedb-database-config.d.mts create mode 100644 app/scripts/scaffolding/tests/fixture-files.mts create mode 100644 app/scripts/shared/core-node-services.mts create mode 100644 app/scripts/shared/ultramodern-command.mts create mode 100644 app/scripts/shared/ultramodern-launch.mts create mode 100644 app/scripts/shared/ultramodern-wrapper-source.mts create mode 100644 app/scripts/tests/ultramodern-command.test.mts create mode 100644 app/tools/oxlint/anti-slop/shared/function-parameters.ts create mode 100644 app/tools/oxlint/anti-slop/shared/type-alias-reference.ts create mode 100644 app/tools/oxlint/effect-native/shared/ast.ts create mode 100644 app/tools/oxlint/effect-native/shared/bindings.ts create mode 100644 app/tools/oxlint/effect-native/shared/effect-identity.ts create mode 100644 app/tools/oxlint/effect-native/shared/imports.ts create mode 100644 app/tools/oxlint/effect-native/shared/json-globals.ts create mode 100644 app/tools/oxlint/effect-native/shared/json-rule-scope.ts create mode 100644 app/tools/oxlint/effect-native/shared/options.ts create mode 100644 app/tools/oxlint/effect-native/shared/provenance.ts create mode 100644 app/tools/oxlint/effect-native/shared/reference-positions.ts create mode 100644 app/tools/oxlint/effect-native/shared/reporting.ts create mode 100644 app/tools/oxlint/effect-native/shared/rule-file-policy.ts create mode 100644 app/tools/oxlint/effect-native/shared/scaffold-text.ts create mode 100644 app/tools/oxlint/effect-native/shared/schema-constructor.ts create mode 100644 app/tools/oxlint/effect-native/shared/schema-identity.ts create mode 100644 app/tools/oxlint/effect-native/shared/schema-rule-support.ts create mode 100644 app/tools/oxlint/effect-native/shared/script-entry.ts create mode 100644 app/tools/oxlint/effect-native/shared/source-rule-scope.ts create mode 100644 app/tools/oxlint/effect-native/tests/fixtures/no-hand-rolled-tagged-union/invalid/packages/static-property-key-controls.ts create mode 100644 app/tools/oxlint/effect-native/tests/fixtures/no-hand-rolled-tagged-union/valid/packages/template-property-key.ts create mode 100644 app/tools/oxlint/effect-native/tests/fixtures/no-manual-error-handling-in-scaffold-templates/invalid/scripts/scaffolding/unicode-offsets.mts create mode 100644 app/tools/oxlint/effect-native/tests/fixtures/no-sync-schema-codec/invalid/packages/core-runtime/src/computed-schema-identity.ts create mode 100644 app/tools/oxlint/effect-native/tests/fixtures/no-sync-schema-codec/valid/packages/core-runtime/src/computed-schema-identity.ts create mode 100644 app/tools/oxlint/effect-native/tests/fixtures/no-unmanaged-mutable-state/invalid/packages/wrapped-mutation-boundary.ts create mode 100644 app/tools/oxlint/effect-native/tests/fixtures/no-unmanaged-mutable-state/valid/packages/wrapped-mutation-boundary.ts create mode 100644 app/tools/oxlint/effect-native/tests/json-rule-scope.test.mts create mode 100644 app/tools/oxlint/effect-native/tests/rule-file-policy.test.mts create mode 100644 app/tools/oxlint/effect-native/tests/scaffold-text-unicode.test.mts create mode 100644 app/tools/oxlint/effect-native/tests/scaffold-unicode.test.mts create mode 100644 app/tools/oxlint/effect-native/tests/shared-helpers.test.mts create mode 100644 app/tools/oxlint/effect-native/tests/source-rule-scope.test.mts create mode 100644 app/verticals/party-registry/api/governed-detail-read-execution.ts create mode 100644 app/verticals/party-registry/api/governed-read-handler.ts create mode 100644 app/verticals/party-registry/shared/domain/canonical-utc-timestamp.ts create mode 100644 app/verticals/party-registry/src/actions/duplicate-case-resolution-handler.ts create mode 100644 app/verticals/party-registry/src/actions/party-lifecycle-action-helpers.ts diff --git a/app/apps/shell-super-app/api/auth/api-key-service.ts b/app/apps/shell-super-app/api/auth/api-key-service.ts index d98fc5a40..019620133 100644 --- a/app/apps/shell-super-app/api/auth/api-key-service.ts +++ b/app/apps/shell-super-app/api/auth/api-key-service.ts @@ -39,7 +39,7 @@ const ApiKeyCredentialInvalidErrorSchema = Schema.TaggedStruct( 'ApiKeyCredentialInvalidError', apiKeyCredentialInvalidFields, ); -export const ApiKeyCredentialInvalidError = Schema.TaggedError< +const ApiKeyCredentialInvalidError = Schema.TaggedError< Schema.Schema.Type >()('ApiKeyCredentialInvalidError', apiKeyCredentialInvalidFields); const apiKeyRateLimitedFields = { @@ -51,7 +51,7 @@ const ApiKeyRateLimitedErrorSchema = Schema.TaggedStruct( 'ApiKeyRateLimitedError', apiKeyRateLimitedFields, ); -export const ApiKeyRateLimitedError = Schema.TaggedError< +const ApiKeyRateLimitedError = Schema.TaggedError< Schema.Schema.Type >()('ApiKeyRateLimitedError', apiKeyRateLimitedFields); const apiKeyProviderUnavailableFields = { @@ -101,7 +101,7 @@ export interface IssuedApiKey extends ProviderApiKeyMetadata { export interface VerifiedApiKey { readonly providerKeyId: string; } -export interface PendingApiKeyCleanupBatch { +interface PendingApiKeyCleanupBatch { readonly hasMore: boolean; readonly providerKeyIds: readonly string[]; } diff --git a/app/apps/shell-super-app/api/auth/config.ts b/app/apps/shell-super-app/api/auth/config.ts index 6b58ed352..cee1fe9de 100644 --- a/app/apps/shell-super-app/api/auth/config.ts +++ b/app/apps/shell-super-app/api/auth/config.ts @@ -1,18 +1,8 @@ +import { loadEnvironmentFileProvider } from './environment-file-provider.ts'; import { APP_ENV_PATH } from '@app/core-runtime/workspace-environment'; -import { NodeFileSystem } from '@effect/platform-node'; -import { - Config, - ConfigProvider, - Context, - Effect, - FileSystem, - Layer, - Predicate, - Redacted, - Schema, -} from 'effect'; - -export const AuthConfigError = Schema.TaggedError()('AuthConfigError', { +import { Config, ConfigProvider, Context, Effect, Layer, Redacted, Schema } from 'effect'; + +const AuthConfigError = Schema.TaggedError()('AuthConfigError', { reason: Schema.String, }); type AuthConfigFailure = InstanceType; @@ -152,26 +142,10 @@ export interface LoadAuthConfigOptions { readonly envPath?: string; } -const loadFileProvider = ( - envPath: string, -): Effect.Effect => - Effect.scoped( - Layer.build(NodeFileSystem.layer).pipe( - Effect.map((services) => Context.get(services, FileSystem.FileSystem)), - Effect.flatMap((fileSystem) => fileSystem.readFileString(envPath)), - Effect.catchIf( - (error) => Predicate.isTagged(error.reason, 'NotFound'), - () => Effect.succeed(''), - ), - Effect.catchTag('PlatformError', () => Effect.fail(unableToLoadEnvironment())), - Effect.map((contents) => ConfigProvider.fromDotEnvContents(contents)), - ), - ); - export const loadAuthConfig = ( options: LoadAuthConfigOptions = {}, ): Effect.Effect => - loadFileProvider(options.envPath ?? ROOT_ENV_PATH).pipe( + loadEnvironmentFileProvider(options.envPath ?? ROOT_ENV_PATH, unableToLoadEnvironment).pipe( Effect.flatMap((fileProvider) => parseAuthConfigFromProvider( (options.environment === undefined diff --git a/app/apps/shell-super-app/api/auth/environment-file-provider.ts b/app/apps/shell-super-app/api/auth/environment-file-provider.ts new file mode 100644 index 000000000..e333c367d --- /dev/null +++ b/app/apps/shell-super-app/api/auth/environment-file-provider.ts @@ -0,0 +1,19 @@ +import { NodeFileSystem } from '@effect/platform-node'; +import { ConfigProvider, Context, Effect, FileSystem, Layer, Predicate } from 'effect'; + +export const loadEnvironmentFileProvider = ( + envPath: string, + unableToLoadEnvironment: () => Failure, +): Effect.Effect => + Effect.scoped( + Layer.build(NodeFileSystem.layer).pipe( + Effect.map((services) => Context.get(services, FileSystem.FileSystem)), + Effect.flatMap((fileSystem) => fileSystem.readFileString(envPath)), + Effect.catchIf( + (error) => Predicate.isTagged(error.reason, 'NotFound'), + () => Effect.succeed(''), + ), + Effect.catchTag('PlatformError', () => Effect.fail(unableToLoadEnvironment())), + Effect.map((contents) => ConfigProvider.fromDotEnvContents(contents)), + ), + ); diff --git a/app/apps/shell-super-app/api/auth/gateway-issuer-config.ts b/app/apps/shell-super-app/api/auth/gateway-issuer-config.ts index 4a2c301d5..8826f49be 100644 --- a/app/apps/shell-super-app/api/auth/gateway-issuer-config.ts +++ b/app/apps/shell-super-app/api/auth/gateway-issuer-config.ts @@ -1,15 +1,5 @@ -import { NodeFileSystem } from '@effect/platform-node'; -import { - Config, - ConfigProvider, - Context, - Effect, - FileSystem, - Layer, - Predicate, - Redacted, - Schema, -} from 'effect'; +import { loadEnvironmentFileProvider } from './environment-file-provider.ts'; +import { Config, ConfigProvider, Effect, Redacted, Schema } from 'effect'; import { ROOT_ENV_PATH } from './config.ts'; const withOptionalProperty = < @@ -34,7 +24,7 @@ const EnvironmentKeySchema = Schema.Literals(['ONTOS_GATEWAY_ISSUER', 'ONTOS_GAT type EnvironmentKey = typeof EnvironmentKeySchema.Type; type Environment = Readonly>>; -export interface Ed25519PrivateJwk { +interface Ed25519PrivateJwk { readonly alg: 'EdDSA'; readonly crv: 'Ed25519'; readonly d: string; @@ -133,26 +123,10 @@ export interface LoadGatewayIssuerConfigOptions { readonly envPath?: string; } -const loadFileProvider = ( - envPath: string, -): Effect.Effect => - Effect.scoped( - Layer.build(NodeFileSystem.layer).pipe( - Effect.map((services) => Context.get(services, FileSystem.FileSystem)), - Effect.flatMap((fileSystem) => fileSystem.readFileString(envPath)), - Effect.catchIf( - (error) => Predicate.isTagged(error.reason, 'NotFound'), - () => Effect.succeed(''), - ), - Effect.catchTag('PlatformError', () => Effect.fail(unableToLoadEnvironment())), - Effect.map((contents) => ConfigProvider.fromDotEnvContents(contents)), - ), - ); - export const loadGatewayIssuerConfig = ( options: LoadGatewayIssuerConfigOptions = {}, ): Effect.Effect => - loadFileProvider(options.envPath ?? ROOT_ENV_PATH).pipe( + loadEnvironmentFileProvider(options.envPath ?? ROOT_ENV_PATH, unableToLoadEnvironment).pipe( Effect.flatMap((fileProvider) => parseGatewayIssuerConfigFromProvider( (options.environment === undefined diff --git a/app/apps/shell-super-app/api/auth/identity-lifecycle.ts b/app/apps/shell-super-app/api/auth/identity-lifecycle.ts index 0949667dd..08f4aa3ed 100644 --- a/app/apps/shell-super-app/api/auth/identity-lifecycle.ts +++ b/app/apps/shell-super-app/api/auth/identity-lifecycle.ts @@ -48,7 +48,7 @@ const IdentityLifecycleOperationErrorSchema = Schema.TaggedStruct( 'IdentityLifecycleOperationError', identityLifecycleOperationErrorFields, ); -export const IdentityLifecycleOperationError = Schema.TaggedError< +const IdentityLifecycleOperationError = Schema.TaggedError< Schema.Schema.Type >()('IdentityLifecycleOperationError', identityLifecycleOperationErrorFields); export type IdentityLifecycleError = diff --git a/app/apps/shell-super-app/api/auth/impersonation-service.ts b/app/apps/shell-super-app/api/auth/impersonation-service.ts index d3951a7c7..afcc07fba 100644 --- a/app/apps/shell-super-app/api/auth/impersonation-service.ts +++ b/app/apps/shell-super-app/api/auth/impersonation-service.ts @@ -58,13 +58,13 @@ export type { SupportRecoveryRecord, } from './support-impersonation-store-service.ts'; -export const SupportImpersonationDeniedErrorSchema = Schema.TaggedStruct( +const SupportImpersonationDeniedErrorSchema = Schema.TaggedStruct( 'SupportImpersonationDeniedError', { code: Schema.Literal('support_impersonation_denied'), reason: Schema.String }, ); -export type SupportImpersonationDeniedError = typeof SupportImpersonationDeniedErrorSchema.Type; +type SupportImpersonationDeniedError = typeof SupportImpersonationDeniedErrorSchema.Type; const SupportImpersonationDeniedFailure = Data.TaggedError('SupportImpersonationDeniedError'); -export const SupportImpersonationUnavailableErrorSchema = Schema.TaggedStruct( +const SupportImpersonationUnavailableErrorSchema = Schema.TaggedStruct( 'SupportImpersonationUnavailableError', { code: Schema.Literal('support_impersonation_unavailable'), reason: Schema.String }, ); @@ -122,7 +122,7 @@ const providerOperation = (operation: () => PromiseLike) => const databasePolicy = (operation: Effect.Effect) => operation.pipe(Effect.mapError(unavailable), impersonationTimeout); -export interface SupportProviderSession { +interface SupportProviderSession { readonly activeTenantId?: null | string | undefined; readonly id: string; readonly impersonatedBy?: null | string | undefined; @@ -364,6 +364,39 @@ const encodeSignedCookie = Effect.fn('encodeSignedCookie')(function* encodeSigne return `${value}.${signature}`; }); +const recoveryFromSession = Effect.fn('recoveryFromSession')(function* recoveryFromSessionEffect( + currentSession: SupportProviderSession, +) { + const tenantId = currentSession.activeTenantId; + const actionId = currentSession.impersonationActionId; + const originalAuthBindingId = currentSession.impersonationOriginalAuthBindingId; + const originalPrincipalId = currentSession.impersonationOriginalPrincipalId; + const originalSessionId = currentSession.impersonationOriginalSessionId; + const reason = currentSession.impersonationReason; + const targetPrincipalId = currentSession.impersonationTargetPrincipalId; + if ( + !Predicate.isString(tenantId) || + !Predicate.isString(actionId) || + !Predicate.isString(originalAuthBindingId) || + !Predicate.isString(originalPrincipalId) || + !Predicate.isString(originalSessionId) || + !Predicate.isString(reason) || + !Predicate.isString(targetPrincipalId) + ) { + return yield* Effect.fail(unavailable()); + } + return { + actionId, + impersonationSessionId: currentSession.id, + originalAuthBindingId, + originalPrincipalId, + originalSessionId, + reason, + targetPrincipalId, + tenantId, + } satisfies SupportRecoveryRecord; +}); + interface SupportCheckpointInput { readonly idempotencyKey: string; readonly payload: unknown; @@ -438,6 +471,43 @@ export const makeSupportImpersonationService = ( } return yield* store.loadExpiredRecovery(Redacted.make(sessionToken.value)); }); + const restoredSessionCookies = Effect.fn( + 'makeSupportImpersonationService.restoredSessionCookies', + )(function* restoredSessionCookiesEffect( + originalSessionToken: Redacted.Redacted, + dontRememberFlag: string | undefined, + expiresAtEpochMillis: number, + nowEpochMillis: number, + ) { + const sessionCookie = yield* encodeSignedCookie(Redacted.value(originalSessionToken), secret); + const maxAge = Math.max(0, Math.floor((expiresAtEpochMillis - nowEpochMillis) / 1000)); + const remembered = dontRememberFlag === undefined || dontRememberFlag.length === 0; + const restoredCookie = serializeAuthCookie( + configuration, + 'session_token', + sessionCookie, + remembered ? { maxAge: `${maxAge} seconds` } : {}, + ); + const dontRememberCookie = remembered + ? Option.none() + : Option.some( + serializeAuthCookie( + configuration, + 'dont_remember', + yield* encodeSignedCookie('true', secret), + ), + ); + return [ + restoredCookie, + ...(Option.isSome(dontRememberCookie) ? [dontRememberCookie.value] : []), + ...clearAuthCookies(configuration).filter( + (header) => + !header.startsWith(`${authCookieName(configuration, 'session_token')}=`) && + (Option.isNone(dontRememberCookie) || + !header.startsWith(`${authCookieName(configuration, 'dont_remember')}=`)), + ), + ]; + }); const recoverOriginalSession = Effect.fn( 'makeSupportImpersonationService.recoverOriginalSession', )(function* recoverOriginalSessionEffect(requestHeaders: Headers) { @@ -466,39 +536,14 @@ export const makeSupportImpersonationService = ( state: 'expired' as const, }; } - const sessionCookie = yield* encodeSignedCookie(originalSessionToken, secret); - const maxAge = Math.max( - 0, - Math.floor((original.value.expiresAt.getTime() - nowEpochMillis) / 1000), - ); - const remembered = dontRememberFlag === undefined || dontRememberFlag.length === 0; - const restoredCookie = serializeAuthCookie( - configuration, - 'session_token', - sessionCookie, - remembered ? { maxAge: `${maxAge} seconds` } : {}, - ); - const dontRememberCookie = remembered - ? Option.none() - : Option.some( - serializeAuthCookie( - configuration, - 'dont_remember', - yield* encodeSignedCookie('true', secret), - ), - ); return { originalSessionId: original.value.id, - setCookieHeaders: [ - restoredCookie, - ...(Option.isSome(dontRememberCookie) ? [dontRememberCookie.value] : []), - ...clearAuthCookies(configuration).filter( - (header) => - !header.startsWith(`${authCookieName(configuration, 'session_token')}=`) && - (Option.isNone(dontRememberCookie) || - !header.startsWith(`${authCookieName(configuration, 'dont_remember')}=`)), - ), - ], + setCookieHeaders: yield* restoredSessionCookies( + Redacted.make(originalSessionToken), + dontRememberFlag, + original.value.expiresAt.getTime(), + nowEpochMillis, + ), state: 'restored' as const, }; }); @@ -564,6 +609,54 @@ export const makeSupportImpersonationService = ( }; }, ); + const stopAbsentSession = Effect.fn('makeSupportImpersonationService.stopAbsentSession')( + function* stopAbsentSessionEffect(requestHeaders: Headers, headers: Headers) { + const [expiredRecovery, recovered] = yield* Effect.all( + [loadExpiredImpersonationRecovery(requestHeaders), recoverOriginalSession(requestHeaders)], + { concurrency: 2 }, + ); + if (Option.isSome(expiredRecovery)) { + yield* store.insertRecovery(expiredRecovery.value); + yield* terminateImpersonationSession(expiredRecovery.value.impersonationSessionId); + const restoredMatches = + (recovered.state === 'restored' || recovered.state === 'expired') && + recovered.originalSessionId === expiredRecovery.value.originalSessionId; + return yield* completeRecovery({ + recovery: expiredRecovery.value, + restoredSessionId: expiredRecovery.value.originalSessionId, + sessionTerminated: true, + setCookieHeaders: restoredMatches + ? recovered.setCookieHeaders + : clearAuthCookies(configuration), + }); + } + if (recovered.state === 'restored' || recovered.state === 'expired') { + const recoveries = yield* store.loadRecoveries(recovered.originalSessionId); + const outcomes = yield* Effect.forEach( + recoveries, + (recovery) => + completeRecovery({ + recovery, + restoredSessionId: recovered.originalSessionId, + sessionTerminated: false, + setCookieHeaders: recovered.setCookieHeaders, + }), + { concurrency: 1 }, + ); + return { + active: false as const, + checkpointPending: outcomes.some((outcome) => outcome.checkpointPending), + setCookieHeaders: recovered.setCookieHeaders, + }; + } + return { + active: false as const, + checkpointPending: recovered.state === 'invalid', + setCookieHeaders: + recovered.state === 'invalid' ? clearAuthCookies(configuration) : cookieHeaders(headers), + }; + }, + ); return Object.freeze({ start: Effect.fn('makeSupportImpersonationService.start')( function* startSupportImpersonationEffect(input: StartSupportImpersonationInput) { @@ -689,55 +782,7 @@ export const makeSupportImpersonationService = ( }), ); if (current.response === null) { - const [expiredRecovery, recovered] = yield* Effect.all( - [ - loadExpiredImpersonationRecovery(input.requestHeaders), - recoverOriginalSession(input.requestHeaders), - ], - { concurrency: 2 }, - ); - if (Option.isSome(expiredRecovery)) { - yield* store.insertRecovery(expiredRecovery.value); - yield* terminateImpersonationSession(expiredRecovery.value.impersonationSessionId); - const restoredMatches = - (recovered.state === 'restored' || recovered.state === 'expired') && - recovered.originalSessionId === expiredRecovery.value.originalSessionId; - return yield* completeRecovery({ - recovery: expiredRecovery.value, - restoredSessionId: expiredRecovery.value.originalSessionId, - sessionTerminated: true, - setCookieHeaders: restoredMatches - ? recovered.setCookieHeaders - : clearAuthCookies(configuration), - }); - } - if (recovered.state === 'restored' || recovered.state === 'expired') { - const recoveries = yield* store.loadRecoveries(recovered.originalSessionId); - const outcomes = yield* Effect.forEach( - recoveries, - (recovery) => - completeRecovery({ - recovery, - restoredSessionId: recovered.originalSessionId, - sessionTerminated: false, - setCookieHeaders: recovered.setCookieHeaders, - }), - { concurrency: 1 }, - ); - return { - active: false as const, - checkpointPending: outcomes.some((outcome) => outcome.checkpointPending), - setCookieHeaders: recovered.setCookieHeaders, - }; - } - return { - active: false as const, - checkpointPending: recovered.state === 'invalid', - setCookieHeaders: - recovered.state === 'invalid' - ? clearAuthCookies(configuration) - : cookieHeaders(current.headers), - }; + return yield* stopAbsentSession(input.requestHeaders, current.headers); } const currentSessionId = current.response.session.id; if (!Predicate.isString(current.response.session.impersonatedBy)) { @@ -766,34 +811,8 @@ export const makeSupportImpersonationService = ( setCookieHeaders: cookieHeaders(current.headers), }; } - const tenantId = current.response.session.activeTenantId; - const actionId = current.response.session.impersonationActionId; - const originalAuthBindingId = current.response.session.impersonationOriginalAuthBindingId; - const originalPrincipalId = current.response.session.impersonationOriginalPrincipalId; - const originalSessionId = current.response.session.impersonationOriginalSessionId; - const reason = current.response.session.impersonationReason; - const targetPrincipalId = current.response.session.impersonationTargetPrincipalId; - if ( - !Predicate.isString(tenantId) || - !Predicate.isString(actionId) || - !Predicate.isString(originalAuthBindingId) || - !Predicate.isString(originalPrincipalId) || - !Predicate.isString(originalSessionId) || - !Predicate.isString(reason) || - !Predicate.isString(targetPrincipalId) - ) { - return yield* Effect.fail(unavailable()); - } - const recovery = { - actionId, - impersonationSessionId: currentSessionId, - originalAuthBindingId, - originalPrincipalId, - originalSessionId, - reason, - targetPrincipalId, - tenantId, - } satisfies SupportRecoveryRecord; + const recovery = yield* recoveryFromSession(current.response.session); + const { originalSessionId } = recovery; yield* store.insertRecovery(recovery); const stoppedExit = yield* Effect.exit( providerOperation( diff --git a/app/apps/shell-super-app/api/auth/service.ts b/app/apps/shell-super-app/api/auth/service.ts index c4ae21ce6..9b4f3ab43 100644 --- a/app/apps/shell-super-app/api/auth/service.ts +++ b/app/apps/shell-super-app/api/auth/service.ts @@ -75,7 +75,7 @@ type FixtureUserProvision = ( const revealAuthenticationSecret = (input: AuthenticationSecretInput): string => Redacted.isRedacted(input) ? Redacted.value(input) : input; -export interface SafeTenantIdentity { +interface SafeTenantIdentity { readonly displayName: string; readonly email: string; readonly impersonating?: true; @@ -83,17 +83,17 @@ export interface SafeTenantIdentity { readonly tenantId: string; } -export interface SafeAuthenticatedIdentity extends SafeTenantIdentity { +interface SafeAuthenticatedIdentity extends SafeTenantIdentity { readonly legalEntityId: string; readonly legalName: string; } -export interface AuthenticationResult { +interface AuthenticationResult { readonly identity: SafeTenantIdentity; readonly setCookieHeaders: readonly string[]; } -export type TenantContextResult = +type TenantContextResult = | { readonly setCookieHeaders: readonly string[]; readonly state: 'anonymous'; @@ -105,21 +105,21 @@ export type TenantContextResult = readonly state: 'authenticated'; }; -export interface CurrentSessionResult { +interface CurrentSessionResult { readonly identity: SafeTenantIdentity | null; readonly setCookieHeaders: readonly string[]; } -export interface SignOutResult { +interface SignOutResult { readonly setCookieHeaders: readonly string[]; } -export interface AvailableTenantsResult { +interface AvailableTenantsResult { readonly setCookieHeaders: readonly string[]; readonly tenants: readonly AvailableTenant[]; } -export interface SwitchTenantResult { +interface SwitchTenantResult { readonly selectedTenantId: string; readonly setCookieHeaders: readonly string[]; } @@ -312,23 +312,44 @@ const toSafeIdentity = ( tenantId: principal.tenantId, }); -const mapKnownRuntimeError = (error: Failure): AuthenticationRuntimeError | undefined => { - if (isAPIError(error)) { - const code = - Predicate.isObjectKeyword(error.body) && error.body !== null && 'code' in error.body - ? error.body.code - : undefined; +const isInvalidImpersonationLifecycle = ( + target: ResolvedPrincipalIdentity, + original: ResolvedPrincipalIdentity, + lifecycle: SupportImpersonationLifecycle, +): boolean => + target.principalId === original.principalId || + target.principalId !== lifecycle.targetPrincipalId || + original.principalId !== lifecycle.originalPrincipalId || + original.authBindingId !== lifecycle.originalAuthBindingId || + lifecycle.originalSessionId.length === 0 || + lifecycle.reason.trim().length === 0 || + lifecycle.reason.length > 500; - if (code === FORBIDDEN_IDENTITY_CODE) { - return new OntosIdentityForbiddenError(); - } +const mapApiError = (error: APIError): AuthenticationRuntimeError | undefined => { + const code = + Predicate.isObjectKeyword(error.body) && error.body !== null && 'code' in error.body + ? error.body.code + : undefined; - if (code === IDENTITY_UNAVAILABLE_CODE || error.statusCode === 503) { - return new AuthenticationUnavailableError(); - } + if (code === FORBIDDEN_IDENTITY_CODE) { + return new OntosIdentityForbiddenError(); + } - if (error.statusCode === 400 || error.statusCode === 401 || error.statusCode === 403) { - return new InvalidCredentialsError(); + if (code === IDENTITY_UNAVAILABLE_CODE || error.statusCode === 503) { + return new AuthenticationUnavailableError(); + } + + if (error.statusCode === 400 || error.statusCode === 401 || error.statusCode === 403) { + return new InvalidCredentialsError(); + } + return undefined; +}; + +const mapKnownRuntimeError = (error: Failure): AuthenticationRuntimeError | undefined => { + if (isAPIError(error)) { + const known = mapApiError(error); + if (known !== undefined) { + return known; } } @@ -590,15 +611,7 @@ const assembleAuthenticationService = ( const original = yield* resolver .resolveBetterAuthUserForTenant(originalBetterAuthUserId, target.tenantId) .pipe(Effect.mapError(mapResolverError)); - if ( - target.principalId === original.principalId || - target.principalId !== lifecycle.targetPrincipalId || - original.principalId !== lifecycle.originalPrincipalId || - original.authBindingId !== lifecycle.originalAuthBindingId || - lifecycle.originalSessionId.length === 0 || - lifecycle.reason.trim().length === 0 || - lifecycle.reason.length > 500 - ) { + if (isInvalidImpersonationLifecycle(target, original, lifecycle)) { return yield* new OntosIdentityForbiddenError(); } const { verifySupportImpersonationStarted } = resolver; diff --git a/app/apps/shell-super-app/api/auth/stage-demo-bootstrap-contract.ts b/app/apps/shell-super-app/api/auth/stage-demo-bootstrap-contract.ts index b99f4b948..197b4879d 100644 --- a/app/apps/shell-super-app/api/auth/stage-demo-bootstrap-contract.ts +++ b/app/apps/shell-super-app/api/auth/stage-demo-bootstrap-contract.ts @@ -100,46 +100,29 @@ const stageDemoBootstrapSource = Config.all({ siamparkPassword: Config.schema(StageDemoPasswordSchema, 'STAGE_SIAMPARK_PASSWORD'), }); +const configurationRequirements = [ + ['STAGE_DEMO_PASSWORD', 'must contain at least 8 characters'], + ['STAGE_SIAMPARK_PASSWORD', 'must contain at least 8 characters'], + ['BETTER_AUTH_SECRET', 'must contain at least 32 characters'], + ['BETTER_AUTH_URL', 'must be an HTTP origin'], + ['DATABASE_ADMIN_URL', 'must use PostgreSQL'], +] as const; + const configurationFailureFromConfigError = ( error: Config.ConfigError, ): StageDemoBootstrapError => { const { message } = error; - const missing = message.includes('Expected string'); if (message.includes('ULTRAMODERN_DEPLOYMENT_ENVIRONMENT')) { return configurationFailure('The demo bootstrap can run only in the stage environment'); } - if (message.includes('STAGE_DEMO_PASSWORD')) { - return configurationFailure( - missing - ? 'STAGE_DEMO_PASSWORD is required' - : 'STAGE_DEMO_PASSWORD must contain at least 8 characters', - ); - } - if (message.includes('STAGE_SIAMPARK_PASSWORD')) { - return configurationFailure( - missing - ? 'STAGE_SIAMPARK_PASSWORD is required' - : 'STAGE_SIAMPARK_PASSWORD must contain at least 8 characters', - ); - } - if (message.includes('BETTER_AUTH_SECRET')) { - return configurationFailure( - missing - ? 'BETTER_AUTH_SECRET is required' - : 'BETTER_AUTH_SECRET must contain at least 32 characters', - ); - } - if (message.includes('BETTER_AUTH_URL')) { - return configurationFailure( - missing ? 'BETTER_AUTH_URL is required' : 'BETTER_AUTH_URL must be an HTTP origin', - ); - } - if (message.includes('DATABASE_ADMIN_URL')) { - return configurationFailure( - missing ? 'DATABASE_ADMIN_URL is required' : 'DATABASE_ADMIN_URL must use PostgreSQL', - ); + const requirement = configurationRequirements.find(([key]) => message.includes(key)); + if (requirement === undefined) { + return configurationFailure('The stage demo configuration is invalid'); } - return configurationFailure('The stage demo configuration is invalid'); + const [key, invalidReason] = requirement; + return configurationFailure( + `${key} ${message.includes('Expected string') ? 'is required' : invalidReason}`, + ); }; const environmentProvider = (environment: StageDemoEnvironment): ConfigProvider.ConfigProvider => diff --git a/app/apps/shell-super-app/api/index.ts b/app/apps/shell-super-app/api/index.ts index 72e32ebff..b2980b412 100644 --- a/app/apps/shell-super-app/api/index.ts +++ b/app/apps/shell-super-app/api/index.ts @@ -1077,6 +1077,25 @@ const tenantGroupLive = HttpApiBuilder.group(ShellAuthenticationApi, 'tenants', ), ); +const requireAuthenticatedShellContext = Effect.fn('ShellApi.requireAuthenticatedShellContext')( + function* requireAuthenticatedShellContext(headers: RequestHeaders) { + const authentication = yield* AuthenticationService; + const session = yield* authentication + .resolveShellContext(requestHeaders(headers)) + .pipe( + Effect.catch((error) => pipe(error, shellProblemFromAuthenticationError, failShellProblem)), + ); + yield* forwardSetCookieHeaders(session.setCookieHeaders); + if (session.state === 'anonymous') { + return yield* failShellProblem(shellAuthenticationRequiredProblem()); + } + if (session.state !== 'authenticated') { + return yield* failShellProblem(shellSelectionRequiredProblem()); + } + return session; + }, +); + const compositionGroupLive = HttpApiBuilder.group( ShellAuthenticationApi, 'composition', @@ -1130,21 +1149,7 @@ const compositionGroupLive = HttpApiBuilder.group( ) .handle('resolveModuleTarget', ({ payload, request }) => Effect.gen(function* resolveModuleTargetHandler() { - const authentication = yield* AuthenticationService; - const session = yield* authentication - .resolveShellContext(requestHeaders(request.headers)) - .pipe( - Effect.catch((error) => - pipe(error, shellProblemFromAuthenticationError, failShellProblem), - ), - ); - yield* forwardSetCookieHeaders(session.setCookieHeaders); - if (session.state === 'anonymous') { - return yield* failShellProblem(shellAuthenticationRequiredProblem()); - } - if (session.state !== 'authenticated') { - return yield* failShellProblem(shellSelectionRequiredProblem()); - } + const session = yield* requireAuthenticatedShellContext(request.headers); const governedReads = yield* ShellGovernedReads; const correlationId = correlationFromRequest(request); const response = yield* governedReads @@ -1220,21 +1225,7 @@ const resourcesGroupLive = HttpApiBuilder.group(ShellAuthenticationApi, 'resourc ) .handle('resourceDetail', ({ payload, request }) => Effect.gen(function* resourceDetailHandler() { - const authentication = yield* AuthenticationService; - const session = yield* authentication - .resolveShellContext(requestHeaders(request.headers)) - .pipe( - Effect.catch((error) => - pipe(error, shellProblemFromAuthenticationError, failShellProblem), - ), - ); - yield* forwardSetCookieHeaders(session.setCookieHeaders); - if (session.state === 'anonymous') { - return yield* failShellProblem(shellAuthenticationRequiredProblem()); - } - if (session.state !== 'authenticated') { - return yield* failShellProblem(shellSelectionRequiredProblem()); - } + const session = yield* requireAuthenticatedShellContext(request.headers); const governedReads = yield* ShellGovernedReads; const response = yield* governedReads .resourceDetail({ @@ -1252,21 +1243,7 @@ const resourcesGroupLive = HttpApiBuilder.group(ShellAuthenticationApi, 'resourc ) .handle('attachMedia', ({ payload, request }) => Effect.gen(function* attachMediaHandler() { - const authentication = yield* AuthenticationService; - const session = yield* authentication - .resolveShellContext(requestHeaders(request.headers)) - .pipe( - Effect.catch((error) => - pipe(error, shellProblemFromAuthenticationError, failShellProblem), - ), - ); - yield* forwardSetCookieHeaders(session.setCookieHeaders); - if (session.state === 'anonymous') { - return yield* failShellProblem(shellAuthenticationRequiredProblem()); - } - if (session.state !== 'authenticated') { - return yield* failShellProblem(shellSelectionRequiredProblem()); - } + const session = yield* requireAuthenticatedShellContext(request.headers); const resolution = yield* attachShellMedia( { ...session.principal, diff --git a/app/apps/shell-super-app/api/modules/deployment-allowlist.ts b/app/apps/shell-super-app/api/modules/deployment-allowlist.ts index 7d2689338..61b19e69d 100644 --- a/app/apps/shell-super-app/api/modules/deployment-allowlist.ts +++ b/app/apps/shell-super-app/api/modules/deployment-allowlist.ts @@ -27,7 +27,7 @@ const OntosDeploymentAppIdSchema = StringSchema.check(isPattern(deploymentIdPatt ); type OntosDeploymentAppId = typeof OntosDeploymentAppIdSchema.Type; -export class DeploymentAllowlistConfigurationError extends TaggedError()( +class DeploymentAllowlistConfigurationError extends TaggedError()( 'DeploymentAllowlistConfigurationError', { cause: StringSchema, @@ -36,7 +36,7 @@ export class DeploymentAllowlistConfigurationError extends TaggedError hostname === '[::1]' || hostname.endsWith('.localhost'); +const isContractDocumentUrl = (url: URL): boolean => + url.username === '' && + url.password === '' && + url.hash === '' && + url.search === '' && + url.pathname === ONTOS_MODULE_CONTRACT_PATH; + const normalizedContractUrl = (value: string, environment: string): string | undefined => { const url = URL.parse(value); - if ( - url === null || - url.username !== '' || - url.password !== '' || - url.hash !== '' || - url.search !== '' || - url.pathname !== ONTOS_MODULE_CONTRACT_PATH - ) { + if (url === null || !isContractDocumentUrl(url)) { return undefined; } const developmentLoopback = @@ -99,6 +99,31 @@ const normalizedContractUrl = (value: string, environment: string): string | und return url.protocol === 'https:' || developmentLoopback ? url.href : undefined; }; +const contractUrlIssues = ( + overlay: DeploymentAllowlistOverlay, + environment: string, +): FilterIssue[] => { + const issues: FilterIssue[] = []; + const normalizedUrls = new Set(); + for (const [appId, contractUrl] of Object.entries(overlay.ontosModuleManifests)) { + const normalized = normalizedContractUrl(contractUrl, environment); + if (normalized === undefined) { + issues.push({ + issue: 'contract URL is invalid for this deployment environment', + path: ['overlay', 'ontosModuleManifests', appId], + }); + } else if (normalizedUrls.has(normalized)) { + issues.push({ + issue: 'allowlist contains duplicate normalized URLs', + path: ['overlay', 'ontosModuleManifests', appId], + }); + } else { + normalizedUrls.add(normalized); + } + } + return issues; +}; + const DeploymentAllowlistInputSchema = Struct({ environment: NonEmptyString, overlay: DeploymentAllowlistOverlaySchema, @@ -139,23 +164,7 @@ const DeploymentAllowlistInputSchema = Struct({ } } - const normalizedUrls = new Set(); - for (const [appId, contractUrl] of Object.entries(input.overlay.ontosModuleManifests)) { - const normalized = normalizedContractUrl(contractUrl, input.environment); - if (normalized === undefined) { - issues.push({ - issue: 'contract URL is invalid for this deployment environment', - path: ['overlay', 'ontosModuleManifests', appId], - }); - } else if (normalizedUrls.has(normalized)) { - issues.push({ - issue: 'allowlist contains duplicate normalized URLs', - path: ['overlay', 'ontosModuleManifests', appId], - }); - } else { - normalizedUrls.add(normalized); - } - } + issues.push(...contractUrlIssues(input.overlay, input.environment)); return issues; }), ); diff --git a/app/apps/shell-super-app/api/modules/installed-module-catalog.ts b/app/apps/shell-super-app/api/modules/installed-module-catalog.ts index 68af80ca8..d148e2455 100644 --- a/app/apps/shell-super-app/api/modules/installed-module-catalog.ts +++ b/app/apps/shell-super-app/api/modules/installed-module-catalog.ts @@ -39,7 +39,7 @@ const InstalledModuleCatalogUnavailableErrorSchema = Schema.TaggedStruct( ); type InstalledModuleCatalogUnavailableFailure = typeof InstalledModuleCatalogUnavailableErrorSchema.Type; -export const InstalledModuleCatalogUnavailableError = +const InstalledModuleCatalogUnavailableError = Schema.TaggedError()( 'InstalledModuleCatalogUnavailableError', unavailableErrorFields, @@ -55,7 +55,7 @@ const InstalledModuleCatalogInvalidErrorSchema = Schema.TaggedStruct( invalidErrorFields, ); type InstalledModuleCatalogInvalidFailure = typeof InstalledModuleCatalogInvalidErrorSchema.Type; -export const InstalledModuleCatalogInvalidError = +const InstalledModuleCatalogInvalidError = Schema.TaggedError()( 'InstalledModuleCatalogInvalidError', invalidErrorFields, @@ -67,7 +67,7 @@ export type InstalledModuleCatalogError = export type ModuleContractFetch = typeof globalThis.fetch; -export interface InstalledModuleCatalogLoaderOptions { +interface InstalledModuleCatalogLoaderOptions { readonly maxBytes?: number; readonly timeoutMs?: number; } diff --git a/app/apps/shell-super-app/api/modules/shell-composition.ts b/app/apps/shell-super-app/api/modules/shell-composition.ts index 3dcd6eecf..dce9fb292 100644 --- a/app/apps/shell-super-app/api/modules/shell-composition.ts +++ b/app/apps/shell-super-app/api/modules/shell-composition.ts @@ -8,7 +8,6 @@ import type { import { decideModuleStateAccess } from '@app/core-runtime'; import { Context, Effect, Layer, Schema } from 'effect'; import { ShellCompositionSchema, ShellNavigationItemSchema } from '../../shared/api.ts'; -import type { ShellComposition } from '../../shared/api.ts'; import type { InstalledModuleCatalogError } from './installed-module-catalog.ts'; const withOptionalProperty = < @@ -47,21 +46,6 @@ export interface ShellCompositionContext { readonly tenantId: string; } -export type ShellCompositionModel = Exclude; - -export type ShellTargetResolution = - | { readonly outcome: 'selection_required' } - | { readonly outcome: 'not_found' } - | { readonly outcome: 'forbidden' } - | { readonly outcome: 'unavailable' } - | { - readonly appId: string; - readonly moduleId: string; - readonly outcome: 'resolved'; - readonly page: ShellPageContribution; - readonly writable: boolean; - }; - export interface ShellCompositionSources { readonly catalog: Effect.Effect; readonly contextAccess: Pick; @@ -97,6 +81,50 @@ const pageByContributionKey = ( ), ); +const resolveContributionPage = ( + contributions: OntosShellContributions, + input: { readonly entrypointKey?: string; readonly moduleId: string }, +): ShellPageContribution | undefined => { + const { navigation, pages } = contributions; + if (input.entrypointKey !== undefined) { + return pages.find( + ({ entrypoint }) => + entrypoint.entrypointKey === input.entrypointKey && entrypoint.moduleKey === input.moduleId, + ); + } + const [landing] = navigation; + return landing === undefined + ? undefined + : pages.find(({ contributionKey }) => String(contributionKey) === String(landing.pageKey)); +}; + +const resolveTargetPermission = Effect.fn('ShellComposition.resolveTargetPermission')( + function* resolveTargetPermission( + sources: ShellCompositionSources, + context: ShellCompositionContext & { + readonly legalEntityId: string; + readonly moduleId: string; + }, + ) { + const [permission, ...unexpected] = yield* sources.contextAccess.modules({ + legalEntityId: context.legalEntityId, + moduleIds: [context.moduleId], + principalId: context.principalId, + tenantId: context.tenantId, + }); + if (unexpected.length > 0 || permission === undefined || permission.key !== context.moduleId) { + return { outcome: 'unavailable' } as const; + } + if (permission.decision === 'unavailable') { + return { outcome: 'unavailable' } as const; + } + if (permission.decision === 'denied') { + return { outcome: 'forbidden' } as const; + } + return null; + }, +); + export const makeShellComposition = (sources: ShellCompositionSources) => { const compose = Effect.fn('makeShellComposition.compose')(function* composeShellEffect( context: ShellCompositionContext, @@ -204,24 +232,10 @@ export const makeShellComposition = (sources: ShellCompositionSources) => { if (contract === undefined) { return { outcome: 'not_found' } as const; } - const { pages } = contract.manifest.publicSurface.shellContributions; - const { navigation } = contract.manifest.publicSurface.shellContributions; - const [landing] = navigation; - const exactPage = - input.entrypointKey === undefined - ? undefined - : pages.find( - ({ entrypoint }) => - entrypoint.entrypointKey === input.entrypointKey && - entrypoint.moduleKey === input.moduleId, - ); - const landingPage = - landing === undefined - ? undefined - : pages.find( - ({ contributionKey }) => String(contributionKey) === String(landing.pageKey), - ); - const page = input.entrypointKey === undefined ? landingPage : exactPage; + const page = resolveContributionPage( + contract.manifest.publicSurface.shellContributions, + input, + ); if (page === undefined) { return { outcome: 'not_found' } as const; } @@ -235,20 +249,14 @@ export const makeShellComposition = (sources: ShellCompositionSources) => { if (decideModuleStateAccess(state, access) === 'deny') { return { outcome: 'not_found' } as const; } - const [permission, ...unexpected] = yield* sources.contextAccess.modules({ + const permission = yield* resolveTargetPermission(sources, { legalEntityId: context.legalEntityId, - moduleIds: [input.moduleId], + moduleId: input.moduleId, principalId: context.principalId, tenantId: context.tenantId, }); - if (unexpected.length > 0 || permission === undefined || permission.key !== input.moduleId) { - return { outcome: 'unavailable' } as const; - } - if (permission.decision === 'unavailable') { - return { outcome: 'unavailable' } as const; - } - if (permission.decision === 'denied') { - return { outcome: 'forbidden' } as const; + if (permission !== null) { + return permission; } return { appId: contract.deployment.appId, diff --git a/app/apps/shell-super-app/api/modules/shell-resources.ts b/app/apps/shell-super-app/api/modules/shell-resources.ts index f47bf179f..90f651dd4 100644 --- a/app/apps/shell-super-app/api/modules/shell-resources.ts +++ b/app/apps/shell-super-app/api/modules/shell-resources.ts @@ -12,6 +12,7 @@ import { ResourceRefSchema as SharedResourceRefSchema, ShellTimelineEntrySchema as SharedShellTimelineEntrySchema, } from '../../shared/api.ts'; +import type { ShellSearchResult as SharedShellSearchResult } from '../../shared/api.ts'; import type { InstalledModuleCatalogError } from './installed-module-catalog.ts'; const stableKey = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(300)); @@ -74,43 +75,7 @@ const RawShellSearchResultSchema = Schema.Union([ PartyShellSearchResultSchema, LegacyShellSearchResultSchema, ]); -export const ShellSearchResultSchema = Schema.Union([ - Schema.Struct({ - kind: Schema.Literal('resource'), - ref: ResourceRefSchema, - title: stableKey, - }), - Schema.Struct({ - archived: Schema.Boolean, - kind: Schema.Literal('party'), - matchedViaAlias: Schema.Boolean, - ref: ResourceRefSchema, - title: stableKey, - }), - Schema.Struct({ - collision: Schema.optionalKey( - Schema.Struct({ - counterpartyRefs: Schema.Array(ResourceRefSchema), - kind: Schema.Literal('CANONICAL_PARTY_COUNTERPARTY_COLLISION'), - }), - ), - currentRoles: Schema.Array(PartyRoleSchema), - kind: Schema.Literal('counterparty'), - legalEntity: Schema.Struct({ - legalEntityId: LegalEntityIdSchema, - tenantId: TenantIdSchema, - }), - party: Schema.Struct({ - archived: Schema.Boolean, - matchedViaAlias: Schema.Boolean, - ref: ResourceRefSchema, - title: stableKey, - }), - ref: ResourceRefSchema, - title: stableKey, - }), -]); -export type ShellSearchResult = Schema.Schema.Type; +export type ShellSearchResult = SharedShellSearchResult; export interface ShellSearchRequest { readonly includeArchived?: boolean; @@ -119,9 +84,8 @@ export interface ShellSearchRequest { } export const ShellTimelineEntrySchema = SharedShellTimelineEntrySchema; -export type ShellTimelineEntry = Schema.Schema.Type; -export const ShellResourceDetailSchema = Schema.Struct({ +const ShellResourceDetailSchema = Schema.Struct({ fields: Schema.Array( Schema.Struct({ label: stableKey, @@ -130,7 +94,6 @@ export const ShellResourceDetailSchema = Schema.Struct({ ), title: stableKey, }); -export type ShellResourceDetail = Schema.Schema.Type; const ProviderFailureCauseSchema = Schema.Defect(); @@ -139,7 +102,7 @@ export class ShellProviderUnavailableError extends Schema.TaggedError Effect.Effect; } -export interface ShellSearchProviderRequest { +interface ShellSearchProviderRequest { readonly appId: string; readonly authorization: Authorization; readonly correlationId: string; @@ -162,13 +125,13 @@ export interface ShellSearchProviderRequest Effect.Effect; } -export interface ShellResourceProviderRequest< +interface ShellResourceProviderRequest< ApiKey extends string = string, Authorization extends string = string, > { @@ -179,7 +142,7 @@ export interface ShellResourceProviderRequest< readonly ref: ResourceRef; } -export interface ShellResourceProviderHandler { +interface ShellResourceProviderHandler { readonly detail: ( input: ShellResourceProviderRequest, ) => Effect.Effect; @@ -273,12 +236,36 @@ const resourceDecision = (...[dependencies, context, ref]: ResourceDecisionArgum tenantId: context.tenantId, }); +interface SearchProviderIdentity { + readonly descriptor: { readonly resourceType: string }; + readonly moduleId: string; +} + +const collisionBelongsToProvider = ( + context: ShellResourceContext, + provider: SearchProviderIdentity, + result: Extract, +): boolean => + result.collision?.counterpartyRefs.every( + (ref) => + ref.tenantId === context.tenantId && + ref.moduleId === provider.moduleId && + ref.resourceType === provider.descriptor.resourceType, + ) ?? true; + +const counterpartyBelongsToContext = ( + context: ShellResourceContext, + result: Extract, +): boolean => + context.legalEntityId !== undefined && + result.ref.tenantId === context.tenantId && + result.party.ref.tenantId === context.tenantId && + result.legalEntity.tenantId === context.tenantId && + result.legalEntity.legalEntityId === context.legalEntityId; + const resultBelongsToProvider = ( context: ShellResourceContext, - provider: { - readonly descriptor: { readonly resourceType: string }; - readonly moduleId: string; - }, + provider: SearchProviderIdentity, result: ShellSearchResult, ): boolean => { if ( @@ -295,18 +282,8 @@ const resultBelongsToProvider = ( return true; } return ( - context.legalEntityId !== undefined && - result.ref.tenantId === context.tenantId && - result.party.ref.tenantId === context.tenantId && - result.legalEntity.tenantId === context.tenantId && - result.legalEntity.legalEntityId === context.legalEntityId && - (result.collision?.counterpartyRefs.every( - (ref) => - ref.tenantId === context.tenantId && - ref.moduleId === provider.moduleId && - ref.resourceType === provider.descriptor.resourceType, - ) ?? - true) + counterpartyBelongsToContext(context, result) && + collisionBelongsToProvider(context, provider, result) ); }; @@ -339,6 +316,79 @@ const decodeProviderResults = ( Effect.mapError(unavailable), ); +const searchProviders = (catalog: InstalledModuleCatalog) => + catalog.contracts.flatMap((contract) => + contract.manifest.publicSurface.shellContributions.search.flatMap((contribution) => { + const descriptor = contract.manifest.publicSurface.search.find( + ({ key }) => key === contribution.searchKey, + ); + return descriptor === undefined + ? [] + : [ + { + appId: contract.deployment.appId, + contribution, + descriptor, + moduleId: contract.manifest.module.id, + }, + ]; + }), + ); + +interface SearchCandidate { + readonly provider: ReturnType[number]; + readonly value: ShellSearchResult; +} + +const authorizeSearchCandidates = Effect.fn('ShellSearch.authorizeCandidates')( + function* authorizeSearchCandidates( + ...[dependencies, context, uniqueCandidates]: readonly [ + ShellResourceDependencies, + ShellResourceContext, + readonly SearchCandidate[], + ] + ) { + const resourceCandidates = uniqueCandidates.filter( + ({ provider }) => provider.descriptor.accessFiltering === 'resource_permission', + ); + const candidateResourceRefs = resourceCandidates.flatMap(({ value }) => [ + value.ref, + ...(value.kind === 'counterparty' ? (value.collision?.counterpartyRefs ?? []) : []), + ]); + const resourcesToAuthorize = [ + ...new Map(candidateResourceRefs.map((ref) => [resourceKey(ref), ref])).values(), + ]; + let resourcePermissions: readonly ContextAccessResult[] = []; + if (resourceCandidates.length > 0) { + const { legalEntityId } = context; + if (legalEntityId === undefined) { + return yield* unavailable(); + } + resourcePermissions = yield* dependencies.contextAccess.resources({ + legalEntityId, + principalId: context.principalId, + resources: resourcesToAuthorize, + tenantId: context.tenantId, + }); + } + if ( + resourcePermissions.length !== resourcesToAuthorize.length || + resourcePermissions.some(({ decision, key }, index) => { + const candidate = resourcesToAuthorize[index]; + return ( + candidate === undefined || key !== resourceKey(candidate) || decision === 'unavailable' + ); + }) + ) { + return yield* unavailable(); + } + const allowedKeys = new Set( + resourcePermissions.flatMap(({ decision, key }) => (decision === 'allowed' ? [key] : [])), + ); + return allowedKeys; + }, +); + type ShellSearchArguments = readonly [ dependencies: ShellResourceDependencies, gateway: ShellSearchProviderHandler, @@ -355,23 +405,7 @@ export const makeShellSearch = (...[dependencies, gateway]: ShellSearchArguments return { partial: false, results: [] } as const; } const catalog = yield* dependencies.catalog.pipe(Effect.mapError(unavailable)); - const providers = catalog.contracts.flatMap((contract) => - contract.manifest.publicSurface.shellContributions.search.flatMap((contribution) => { - const descriptor = contract.manifest.publicSurface.search.find( - ({ key }) => key === contribution.searchKey, - ); - return descriptor === undefined - ? [] - : [ - { - appId: contract.deployment.appId, - contribution, - descriptor, - moduleId: contract.manifest.module.id, - }, - ]; - }), - ); + const providers = searchProviders(catalog); const moduleIds = [...new Set(providers.map(({ moduleId }) => moduleId))].toSorted(); if (moduleIds.length === 0) { return { partial: false, results: [] } as const; @@ -501,43 +535,7 @@ export const makeShellSearch = (...[dependencies, gateway]: ShellSearchArguments if (uniqueCandidates.length === 0) { return { partial: succeeded.length !== attempts.length, results: [] } as const; } - const resourceCandidates = uniqueCandidates.filter( - ({ provider }) => provider.descriptor.accessFiltering === 'resource_permission', - ); - const candidateResourceRefs = resourceCandidates.flatMap(({ value }) => [ - value.ref, - ...(value.kind === 'counterparty' ? (value.collision?.counterpartyRefs ?? []) : []), - ]); - const resourcesToAuthorize = [ - ...new Map(candidateResourceRefs.map((ref) => [resourceKey(ref), ref])).values(), - ]; - let resourcePermissions: readonly ContextAccessResult[] = []; - if (resourceCandidates.length > 0) { - const { legalEntityId } = context; - if (legalEntityId === undefined) { - return yield* unavailable(); - } - resourcePermissions = yield* dependencies.contextAccess.resources({ - legalEntityId, - principalId: context.principalId, - resources: resourcesToAuthorize, - tenantId: context.tenantId, - }); - } - if ( - resourcePermissions.length !== resourcesToAuthorize.length || - resourcePermissions.some(({ decision, key }, index) => { - const candidate = resourcesToAuthorize[index]; - return ( - candidate === undefined || key !== resourceKey(candidate) || decision === 'unavailable' - ); - }) - ) { - return yield* unavailable(); - } - const allowedKeys = new Set( - resourcePermissions.flatMap(({ decision, key }) => (decision === 'allowed' ? [key] : [])), - ); + const allowedKeys = yield* authorizeSearchCandidates(dependencies, context, uniqueCandidates); const results = uniqueCandidates .flatMap(({ provider, value }) => { if ( @@ -594,15 +592,32 @@ const mediaAffordance = (state: TenantModuleState, attachable: boolean): MediaAf const hasMediaBinding = (attachable: boolean, binding: Binding | undefined): boolean => attachable && binding !== undefined; -export type ShellResourceResolution = - | { readonly outcome: 'forbidden' | 'not_found' | 'unavailable' } - | { - readonly detail: ShellResourceDetail; - readonly media: MediaAffordance; - readonly outcome: 'resolved'; - readonly projectionLagging: boolean; - readonly timeline: readonly ShellTimelineEntry[]; - }; +const accessOutcome = (decisions: readonly ContextAccessResult[], expectedKey: string) => { + const [decision, ...unexpected] = decisions; + if ( + unexpected.length > 0 || + decision?.key !== expectedKey || + decision.decision === 'unavailable' + ) { + return { outcome: 'unavailable' } as const; + } + return decision.decision === 'denied' + ? ({ outcome: 'forbidden' } as const) + : ({ outcome: 'allowed' } as const); +}; + +const resourceAccessOutcome = Effect.fn('ShellResourceDetail.accessOutcome')( + function* resourceAccessOutcome(...[dependencies, context, ref]: ResourceDecisionArguments) { + const moduleAccess = accessOutcome( + yield* moduleDecision(dependencies, context, ref.moduleId), + ref.moduleId, + ); + if (moduleAccess.outcome !== 'allowed') { + return moduleAccess; + } + return accessOutcome(yield* resourceDecision(dependencies, context, ref), resourceKey(ref)); + }, +); type ShellResourceDetailArguments = readonly [ dependencies: ShellResourceDependencies, @@ -644,35 +659,9 @@ export const makeShellResourceDetail = ( ) { return { outcome: 'not_found' } as const; } - const [moduleAccess, ...unexpectedModules] = yield* moduleDecision( - dependencies, - context, - ref.moduleId, - ); - if ( - unexpectedModules.length > 0 || - moduleAccess?.key !== ref.moduleId || - moduleAccess.decision === 'unavailable' - ) { - return { outcome: 'unavailable' } as const; - } - if (moduleAccess.decision === 'denied') { - return { outcome: 'forbidden' } as const; - } - const [resourceAccess, ...unexpectedResources] = yield* resourceDecision( - dependencies, - context, - ref, - ); - if ( - unexpectedResources.length > 0 || - resourceAccess?.key !== resourceKey(ref) || - resourceAccess.decision === 'unavailable' - ) { - return { outcome: 'unavailable' } as const; - } - if (resourceAccess.decision === 'denied') { - return { outcome: 'forbidden' } as const; + const access = yield* resourceAccessOutcome(dependencies, context, ref); + if (access.outcome !== 'allowed') { + return access; } const mediaBinding = contributions.mediaAttachments.find( ({ resourceType: key }) => key === ref.resourceType, diff --git a/app/apps/shell-super-app/drizzle.auth.config.ts b/app/apps/shell-super-app/drizzle.auth.config.ts index 203e4493c..71d156e9d 100644 --- a/app/apps/shell-super-app/drizzle.auth.config.ts +++ b/app/apps/shell-super-app/drizzle.auth.config.ts @@ -1,33 +1,7 @@ -import { APP_ENV_PATH } from '@app/core-runtime/workspace-environment'; -import { defineConfig } from 'drizzle-kit'; -import { Redacted, Result, Schema } from 'effect'; +import { defineWorkspaceDrizzleConfig } from '../../packages/core-runtime/src/environment/drizzle-config.ts'; -const nodeFileSystem = process.getBuiltinModule('node:fs'); -const nodeProcess = process.getBuiltinModule('node:process'); -const nodeUtilities = process.getBuiltinModule('node:util'); -const fileConfig = nodeFileSystem.existsSync(APP_ENV_PATH) - ? Result.getOrThrow( - Result.try(() => nodeUtilities.parseEnv(nodeFileSystem.readFileSync(APP_ENV_PATH, 'utf-8'))), - ) - : {}; -const configValues = { ...fileConfig, ...nodeProcess.env }; -const databaseUrl = Redacted.value( - Result.getOrThrow( - Schema.decodeUnknownResult( - Schema.RedactedFromValue(Schema.Trim.pipe(Schema.check(Schema.isMinLength(1)))), - )(configValues.DATABASE_ADMIN_URL), - ), -); - -export default defineConfig({ - dbCredentials: { - url: databaseUrl, - }, - dialect: 'postgresql', - migrations: { - schema: 'drizzle', - table: '__drizzle_migrations_auth', - }, +export default defineWorkspaceDrizzleConfig({ out: './drizzle-auth', schema: './api/auth/db/schema.ts', + table: '__drizzle_migrations_auth', }); diff --git a/app/apps/shell-super-app/modern.config.ts b/app/apps/shell-super-app/modern.config.ts index 73948a504..c69a408be 100644 --- a/app/apps/shell-super-app/modern.config.ts +++ b/app/apps/shell-super-app/modern.config.ts @@ -1,6 +1,11 @@ import { readFileSync } from 'node:fs'; -import { builtinModules, createRequire } from 'node:module'; -import path from 'node:path'; +import { + createCloudflareWorkerSecurity, + createWorkerSsrPlugins, + createZephyrRspackPlugin, + resolveCloudflareExternal, +} from '../../packages/shared-contracts/tooling/modern-config.ts'; +import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { appTools, defineConfig, presetUltramodern } from '@modern-js/app-tools'; import type { AppTools, AppToolsUserConfig, CliPlugin } from '@modern-js/app-tools'; @@ -112,51 +117,20 @@ const cloudflareWorkerRemoteStubPath = fileURLToPath( new URL('src/api/cloudflare-worker-remote-stub.ts', import.meta.url), ); const effectApiSourceDirectory = fileURLToPath(new URL('api/', import.meta.url)); -const nodeBuiltinRequests = new Set(builtinModules.flatMap((name) => [name, `node:${name}`])); /* oxlint-disable promise/prefer-await-to-callbacks -- Rspack externals use a callback API. expires: 2026-12-31. */ const cloudflareRuntimeExternal = ( - { dependencyType, request }: { dependencyType?: string; request?: string }, + request: { dependencyType?: string; request?: string }, callback: (error?: Error, result?: string | string[], type?: 'module-import') => void, ) => { - const nativeModuleImport = (specifier: string) => - dependencyType?.startsWith('commonjs') === true ? [specifier, 'default'] : specifier; - if (request === 'cloudflare:sockets') { - callback(undefined, nativeModuleImport(request), 'module-import'); - return; - } - if (request !== undefined && nodeBuiltinRequests.has(request)) { - callback( - undefined, - nativeModuleImport(request.startsWith('node:') ? request : `node:${request}`), - 'module-import', - ); - return; - } - callback(); + callback(...resolveCloudflareExternal(request)); }; /* oxlint-enable promise/prefer-await-to-callbacks */ -const zephyrRspackPlugin = (): CliPlugin => ({ - name: 'ultramodern-zephyr-rspack-plugin', - pre: ['@modern-js/plugin-module-federation-config'], - setup(api) { - // Zephyr uploads federated build artifacts to Zephyr Cloud (the fast - // rollback path). Uploading REQUIRES a Zephyr Cloud account and, in CI, a - // deploy-scoped ZE_CI_TOKEN; without it Zephyr fatally fails to load its - // application configuration. Zephyr therefore engages ONLY for such an - // authoritative deploy — a plain build never contacts Zephyr Cloud, needs - // no account, and is never blocked. This is the framework's "works with or - // without Zephyr" contract. The plugin stays registered unconditionally - // (this gate keys on Zephyr's native deploy token, not any UltraModern - // opt-out). When deploying, ZE_FAIL_BUILD=true makes an upload failure a - // hard build failure. - const zephyrCiDeploy = getOptionalBuildConfig('ZE_CI_TOKEN') !== undefined; - if (!zephyrCiDeploy) { - return; - } - api.modifyRspackConfig(withBuildConfigEnvironment('ZE_FAIL_BUILD', 'true', withZephyrRspack())); - }, -}); +const zephyrRspackPlugin = (): CliPlugin => + createZephyrRspackPlugin({ + configure: () => withBuildConfigEnvironment('ZE_FAIL_BUILD', 'true', withZephyrRspack()), + readToken: () => getOptionalBuildConfig('ZE_CI_TOKEN'), + }); const appId = 'shell-super-app'; const moduleFederationConfigPath = fileURLToPath( @@ -272,45 +246,7 @@ export default defineConfig( worker: { compatibilityDate: '2026-06-02', name: cloudflareWorkerName, - security: { - contentSecurityPolicy: { - directives: { - 'base-uri': ["'self'"], - 'connect-src': ["'self'", 'https:', 'http:', 'wss:', 'ws:'], - 'default-src': ["'self'"], - 'font-src': ["'self'", 'data:', 'https:', 'http:'], - 'form-action': ["'self'"], - 'frame-ancestors': ["'self'"], - 'img-src': ["'self'", 'data:', 'blob:', 'https:', 'http:'], - 'manifest-src': ["'self'", 'https:', 'http:'], - 'object-src': ["'none'"], - 'script-src': [ - "'self'", - "'unsafe-inline'", - "'unsafe-eval'", - 'https:', - 'http:', - 'blob:', - ], - 'style-src': ["'self'", "'unsafe-inline'", 'https:', 'http:'], - 'worker-src': ["'self'", 'blob:'], - }, - mode: 'report-only', - reason: - 'Report-only by default so Cloudflare Module Federation SSR can prove remote script, style, and connect compatibility before enforcement.', - }, - enabled: true, - headers: { - contentTypeOptions: 'nosniff', - permissionsPolicy: 'camera=(), geolocation=(), microphone=(), payment=(), usb=()', - referrerPolicy: 'strict-origin-when-cross-origin', - }, - noindex: { - localhost: true, - previewHostnames: [], - workersDev: true, - }, - }, + security: createCloudflareWorkerSecurity(), services: [ { binding: @@ -457,27 +393,7 @@ export default defineConfig( __filename: false, }); config.plugins.push( - new rspack.DefinePlugin({ - 'globalThis.FinalizationRegistry': 'undefined', - }), - new rspack.NormalModuleReplacementPlugin(/[?&]loaderId=/u, (resource) => { - resource.request = resource.request.replace( - /(?[?&])retain=[^&]*/u, - '$retain=true', - ); - }), - new rspack.NormalModuleReplacementPlugin(/^\.\.?[/\\]/u, (resource) => { - const [requestPath] = resource.request.split('?', 1); - if ( - requestPath !== undefined && - path - .resolve(resource.context, requestPath) - .startsWith(effectApiSourceDirectory) && - !resource.request.includes('modern-bff-runtime-source') - ) { - resource.request = `${resource.request}?modern-bff-runtime-source`; - } - }), + ...createWorkerSsrPlugins(rspack, effectApiSourceDirectory), new rspack.NormalModuleReplacementPlugin( /^partyRegistry\//u, cloudflareWorkerRemoteStubPath, diff --git a/app/apps/shell-super-app/package.json b/app/apps/shell-super-app/package.json index 991286351..886d6949e 100644 --- a/app/apps/shell-super-app/package.json +++ b/app/apps/shell-super-app/package.json @@ -38,18 +38,14 @@ "@modern-js/plugin-i18n": "npm:@bleedingdev/modern-js-plugin-i18n@3.8.2-ultramodern.12", "@modern-js/plugin-tanstack": "npm:@bleedingdev/modern-js-plugin-tanstack@3.8.2-ultramodern.12", "@modern-js/runtime": "npm:@bleedingdev/modern-js-runtime@3.8.2-ultramodern.12", - "@module-federation/bridge-react": "2.8.0", "@module-federation/modern-js-v3": "2.8.0", - "@module-federation/runtime": "2.8.0", "@tanstack/react-router": "1.170.25", "@techsio/ui-kit": "0.25.1", "better-auth": "1.7.2", - "dotenv": "17.4.2", "drizzle-orm": "1.0.0-rc.5-ab785fc", "effect": "4.0.0-beta.107", "i18next": "26.3.6", "jose": "6.2.5", - "node-fetch": "^3.3.2", "pg": "8.22.0", "react": "19.2.8", "react-dom": "19.2.8", @@ -57,7 +53,6 @@ }, "devDependencies": { "@cloudflare/workers-types": "5.20260810.1", - "@effect/tsgo": "0.19.0", "@modern-js/adapter-rstest": "npm:@bleedingdev/modern-js-adapter-rstest@3.8.2-ultramodern.12", "@modern-js/app-tools": "npm:@bleedingdev/modern-js-app-tools@3.8.2-ultramodern.12", "@playwright/test": "1.61.0", @@ -71,7 +66,6 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "bun-types": "1.4.0", - "auth": "1.7.2", "drizzle-kit": "1.0.0-rc.5-ab785fc", "happy-dom": "20.8.3", "tailwindcss": "^4.3.2", diff --git a/app/apps/shell-super-app/shared/api.ts b/app/apps/shell-super-app/shared/api.ts index 971936170..57dbc3a77 100644 --- a/app/apps/shell-super-app/shared/api.ts +++ b/app/apps/shell-super-app/shared/api.ts @@ -883,33 +883,86 @@ export const ShellAuthenticationApi = HttpApi.make('shellAuthenticationApi') ) .add(GatewayContextApiGroup); +const authenticationEndpointPath = (endpoint: { readonly path: string }) => + `/shell-super-app-api${endpoint.path}` as const; + export const shellAuthenticationApiContract = { apiPrefix: '/shell-super-app-api', - availableLegalEntitiesPath: '/shell-super-app-api/auth/legal-entities', - availableTenantsPath: '/shell-super-app-api/auth/tenants', - changePrincipalStatusPath: '/shell-super-app-api/auth/identity/principal-status', - compositionPath: '/shell-super-app-api/shell/composition', - createNonHumanPrincipalPath: '/shell-super-app-api/auth/identity/principals', - currentSessionPath: '/shell-super-app-api/auth/session', - issueApiKeyGatewayContextPath: '/shell-super-app-api/auth/api-key/gateway-context', - issueGatewayContextPath: '/shell-super-app-api/auth/gateway-context', - issueManagedApiKeyPath: '/shell-super-app-api/auth/identity/api-keys/managed', - issueSelfApiKeyPath: '/shell-super-app-api/auth/identity/api-keys/self', - listManagedApiKeysPath: '/shell-super-app-api/auth/identity/api-keys/managed/list', - listSelfApiKeysPath: '/shell-super-app-api/auth/identity/api-keys/self/list', - mediaAttachmentPath: '/shell-super-app-api/shell/resource/media-attachment', + availableLegalEntitiesPath: authenticationEndpointPath( + ShellAuthenticationApi.groups.legalEntities.endpoints.availableLegalEntities, + ), + availableTenantsPath: authenticationEndpointPath( + ShellAuthenticationApi.groups.tenants.endpoints.availableTenants, + ), + changePrincipalStatusPath: authenticationEndpointPath( + ShellAuthenticationApi.groups.identity.endpoints.changePrincipalStatus, + ), + compositionPath: authenticationEndpointPath( + ShellAuthenticationApi.groups.composition.endpoints.shellComposition, + ), + createNonHumanPrincipalPath: authenticationEndpointPath( + ShellAuthenticationApi.groups.identity.endpoints.createNonHumanPrincipal, + ), + currentSessionPath: authenticationEndpointPath( + ShellAuthenticationApi.groups.authentication.endpoints.currentSession, + ), + issueApiKeyGatewayContextPath: authenticationEndpointPath( + ShellAuthenticationApi.groups.gatewayContext.endpoints.issueApiKeyGatewayContext, + ), + issueGatewayContextPath: authenticationEndpointPath( + ShellAuthenticationApi.groups.gatewayContext.endpoints.issueGatewayContext, + ), + issueManagedApiKeyPath: authenticationEndpointPath( + ShellAuthenticationApi.groups.identity.endpoints.issueManagedApiKey, + ), + issueSelfApiKeyPath: authenticationEndpointPath( + ShellAuthenticationApi.groups.identity.endpoints.issueSelfApiKey, + ), + listManagedApiKeysPath: authenticationEndpointPath( + ShellAuthenticationApi.groups.identity.endpoints.listManagedApiKeys, + ), + listSelfApiKeysPath: authenticationEndpointPath( + ShellAuthenticationApi.groups.identity.endpoints.listSelfApiKeys, + ), + mediaAttachmentPath: authenticationEndpointPath( + ShellAuthenticationApi.groups.resources.endpoints.attachMedia, + ), ownerId: 'shell-super-app', - resolveModuleTargetPath: '/shell-super-app-api/shell/module-target', - resourceDetailPath: '/shell-super-app-api/shell/resource', - rotateManagedApiKeyPath: '/shell-super-app-api/auth/identity/api-keys/managed/rotate', - rotateSelfApiKeyPath: '/shell-super-app-api/auth/identity/api-keys/self/rotate', - searchPath: '/shell-super-app-api/shell/search', - setManagedApiKeyStatusPath: '/shell-super-app-api/auth/identity/api-keys/managed/status', - setSelfApiKeyStatusPath: '/shell-super-app-api/auth/identity/api-keys/self/status', - signInPath: '/shell-super-app-api/auth/sign-in', - signOutPath: '/shell-super-app-api/auth/sign-out', - startSupportImpersonationPath: '/shell-super-app-api/auth/identity/impersonation/start', - stopSupportImpersonationPath: '/shell-super-app-api/auth/identity/impersonation/stop', - switchLegalEntityPath: '/shell-super-app-api/auth/legal-entity/switch', - switchTenantPath: '/shell-super-app-api/auth/tenant/switch', + resolveModuleTargetPath: authenticationEndpointPath( + ShellAuthenticationApi.groups.composition.endpoints.resolveModuleTarget, + ), + resourceDetailPath: authenticationEndpointPath( + ShellAuthenticationApi.groups.resources.endpoints.resourceDetail, + ), + rotateManagedApiKeyPath: authenticationEndpointPath( + ShellAuthenticationApi.groups.identity.endpoints.rotateManagedApiKey, + ), + rotateSelfApiKeyPath: authenticationEndpointPath( + ShellAuthenticationApi.groups.identity.endpoints.rotateSelfApiKey, + ), + searchPath: authenticationEndpointPath(ShellAuthenticationApi.groups.resources.endpoints.search), + setManagedApiKeyStatusPath: authenticationEndpointPath( + ShellAuthenticationApi.groups.identity.endpoints.setManagedApiKeyStatus, + ), + setSelfApiKeyStatusPath: authenticationEndpointPath( + ShellAuthenticationApi.groups.identity.endpoints.setSelfApiKeyStatus, + ), + signInPath: authenticationEndpointPath( + ShellAuthenticationApi.groups.authentication.endpoints.signIn, + ), + signOutPath: authenticationEndpointPath( + ShellAuthenticationApi.groups.authentication.endpoints.signOut, + ), + startSupportImpersonationPath: authenticationEndpointPath( + ShellAuthenticationApi.groups.identity.endpoints.startSupportImpersonation, + ), + stopSupportImpersonationPath: authenticationEndpointPath( + ShellAuthenticationApi.groups.identity.endpoints.stopSupportImpersonation, + ), + switchLegalEntityPath: authenticationEndpointPath( + ShellAuthenticationApi.groups.legalEntities.endpoints.switchLegalEntity, + ), + switchTenantPath: authenticationEndpointPath( + ShellAuthenticationApi.groups.tenants.endpoints.switchTenant, + ), } as const; diff --git a/app/apps/shell-super-app/shared/ultramodern-build.ts b/app/apps/shell-super-app/shared/ultramodern-build.ts index 82b8cd52c..c3bada928 100644 --- a/app/apps/shell-super-app/shared/ultramodern-build.ts +++ b/app/apps/shell-super-app/shared/ultramodern-build.ts @@ -1,3 +1,4 @@ +import { withUltramodernBuildIdentity } from '../../../packages/shared-contracts/src/ultramodern-build.ts'; import { Predicate } from 'effect'; declare const ULTRAMODERN_BUILD_MARKER: string; @@ -53,29 +54,11 @@ const ultramodernBuildMarker = Predicate.isString(ULTRAMODERN_BUILD_MARKER) const ultramodernSourceRevision = Predicate.isString(ULTRAMODERN_SOURCE_REVISION) ? ULTRAMODERN_SOURCE_REVISION : ultramodernGeneratedBuildArtifact.deliveryUnit.sourceRevision; -const ultramodernBuildArtifact = { - ...ultramodernGeneratedBuildArtifact, - deliveryUnit: { - ...ultramodernGeneratedBuildArtifact.deliveryUnit, - build: ultramodernBuildMarker, - buildMarker: ultramodernBuildMarker, - sourceRevision: ultramodernSourceRevision, - }, - surfaces: { - api: { - ...ultramodernGeneratedBuildArtifact.surfaces.api, - build: ultramodernBuildMarker, - buildMarker: ultramodernBuildMarker, - sourceRevision: ultramodernSourceRevision, - }, - ui: { - ...ultramodernGeneratedBuildArtifact.surfaces.ui, - build: ultramodernBuildMarker, - buildMarker: ultramodernBuildMarker, - sourceRevision: ultramodernSourceRevision, - }, - }, -} as const; +const ultramodernBuildArtifact = withUltramodernBuildIdentity( + ultramodernGeneratedBuildArtifact, + ultramodernBuildMarker, + ultramodernSourceRevision, +); export { ultramodernBuildArtifact }; diff --git a/app/apps/shell-super-app/shared/vertical-showcase.tsx b/app/apps/shell-super-app/shared/vertical-showcase.tsx new file mode 100644 index 000000000..6c5fe2801 --- /dev/null +++ b/app/apps/shell-super-app/shared/vertical-showcase.tsx @@ -0,0 +1,26 @@ +import { useModernI18n } from '@modern-js/plugin-i18n/runtime'; + +const widgetCount = Number('0'); + +export const VerticalShowcase = () => { + const { t } = useModernI18n(); + + if (widgetCount === 0) { + return ( +
+

+ {t('shell.hero.empty')} +

+
+ ); + } + + return ( +
+
+
+ ); +}; diff --git a/app/apps/shell-super-app/src/routes/[lang]/modules/[moduleId]/page.tsx b/app/apps/shell-super-app/src/routes/[lang]/modules/[moduleId]/page.tsx index 110742c06..f3ac9e1b6 100644 --- a/app/apps/shell-super-app/src/routes/[lang]/modules/[moduleId]/page.tsx +++ b/app/apps/shell-super-app/src/routes/[lang]/modules/[moduleId]/page.tsx @@ -10,7 +10,7 @@ import { resolveThenLoadModuleTarget, settleModuleEntrypointLoad, } from '../../../module-entrypoint-loader.ts'; -import { AuthenticatedDashboardLayout } from '../../../shell-frame.tsx'; +import { ShellContentLayout } from '../../../shell-content-layout.tsx'; import { useShellControls } from '../../../use-shell-controls.ts'; import type { ModuleTargetPageModel } from './page.data.ts'; @@ -114,37 +114,13 @@ export const ModuleTargetView = ({ initialModel }: ModuleTargetViewProps) => { ); return ( - - {controls.logoutFailed ? ( - - {t('shell.auth.logout.failed')} - - ) : null} {content} - + ); }; diff --git a/app/apps/shell-super-app/src/routes/[lang]/resources/[moduleId]/[resourceType]/[resourceId]/page.tsx b/app/apps/shell-super-app/src/routes/[lang]/resources/[moduleId]/[resourceType]/[resourceId]/page.tsx index 09ea27047..5bf476b21 100644 --- a/app/apps/shell-super-app/src/routes/[lang]/resources/[moduleId]/[resourceType]/[resourceId]/page.tsx +++ b/app/apps/shell-super-app/src/routes/[lang]/resources/[moduleId]/[resourceType]/[resourceId]/page.tsx @@ -3,19 +3,106 @@ import { useModernI18n } from '@modern-js/plugin-i18n/runtime'; import { useLoaderData } from '@modern-js/plugin-tanstack/runtime'; import { Button } from '@techsio/ui-kit/atoms/button'; import { StatusText } from '@techsio/ui-kit/atoms/status-text'; -import { DateTime, Effect } from 'effect'; +import { DateTime, Effect, Schema } from 'effect'; import { useState } from 'react'; import { attachResourceMedia } from '../../../../../../api/auth-client.ts'; import { runBrowserEffect } from '../../../../../../runtime/browser-effect-runtime.ts'; -import { AuthenticatedDashboardLayout } from '../../../../../shell-frame.tsx'; +import { ShellContentLayout } from '../../../../../shell-content-layout.tsx'; +import type { ResourcePageModel } from './page.data.ts'; import { useShellControls } from '../../../../../use-shell-controls.ts'; +const MediaStateSchema = Schema.Literals(['failed', 'idle', 'pending', 'success']); +type MediaState = typeof MediaStateSchema.Type; + +const ResourceDetails = ({ + mediaState, + model, + onAttach, +}: { + readonly mediaState: MediaState; + readonly model: Extract; + readonly onAttach: () => Promise; +}) => { + const { t } = useModernI18n(); + return ( +
+
+

+ {model.resource.detail.title} +

+
+ {model.resource.detail.fields.map((field) => ( +
+
{field.label}
+
{field.value}
+
+ ))} +
+
+
+

+ {t('shell.resource.media.title')} +

+ + {model.resource.media.enabled ? null : ( + + {t(`shell.resource.media.${model.resource.media.reason}`)} + + )} + {mediaState === 'success' || mediaState === 'failed' ? ( + + {t(`shell.resource.media.${mediaState}`)} + + ) : null} +
+
+

+ {t('shell.resource.timeline.title')} +

+ {model.resource.projectionLagging ? ( + + {t('shell.resource.timeline.lagging')} + + ) : null} + {model.resource.timeline.length === 0 ? ( + {t('shell.resource.timeline.empty')} + ) : ( +
    + {model.resource.timeline.map((entry) => { + const occurredAt = DateTime.formatIso(entry.occurredAt); + return ( +
  1. + — {entry.summary} +
  2. + ); + })} +
+ )} +
+
+ ); +}; + const ResourcePage = () => { const { t } = useModernI18n(); const model = useLoaderData({ from: '/$lang/resources/$moduleId/$resourceType/$resourceId', }); - const [mediaState, setMediaState] = useState<'failed' | 'idle' | 'pending' | 'success'>('idle'); + const [mediaState, setMediaState] = useState('idle'); const controls = useShellControls( model.shell.state === 'authenticated' ? model.shell : undefined, ); @@ -56,109 +143,17 @@ const ResourcePage = () => { {t(`shell.resource.${model.state}`)} ) : ( -
-
-

- {model.resource.detail.title} -

-
- {model.resource.detail.fields.map((field) => ( -
-
{field.label}
-
{field.value}
-
- ))} -
-
-
-

- {t('shell.resource.media.title')} -

- - {model.resource.media.enabled ? null : ( - - {t(`shell.resource.media.${model.resource.media.reason}`)} - - )} - {mediaState === 'success' || mediaState === 'failed' ? ( - - {t(`shell.resource.media.${mediaState}`)} - - ) : null} -
-
-

- {t('shell.resource.timeline.title')} -

- {model.resource.projectionLagging ? ( - - {t('shell.resource.timeline.lagging')} - - ) : null} - {model.resource.timeline.length === 0 ? ( - {t('shell.resource.timeline.empty')} - ) : ( -
    - {model.resource.timeline.map((entry) => { - const occurredAt = DateTime.formatIso(entry.occurredAt); - return ( -
  1. - — {entry.summary} -
  2. - ); - })} -
- )} -
-
+ ); return ( - - {controls.logoutFailed ? ( - - {t('shell.auth.logout.failed')} - - ) : null} {content} - + ); }; diff --git a/app/apps/shell-super-app/src/routes/[lang]/search/page.tsx b/app/apps/shell-super-app/src/routes/[lang]/search/page.tsx index 15aa36a64..00d2ccfda 100644 --- a/app/apps/shell-super-app/src/routes/[lang]/search/page.tsx +++ b/app/apps/shell-super-app/src/routes/[lang]/search/page.tsx @@ -4,12 +4,88 @@ import { useLoaderData } from '@modern-js/plugin-tanstack/runtime'; import { Badge } from '@techsio/ui-kit/atoms/badge'; import { LinkButton } from '@techsio/ui-kit/atoms/link-button'; import { StatusText } from '@techsio/ui-kit/atoms/status-text'; +import { Match } from 'effect'; import type { ShellSearchResult } from '../../../../shared/api.ts'; -import { AuthenticatedDashboardLayout } from '../../shell-frame.tsx'; +import { ShellContentLayout } from '../../shell-content-layout.tsx'; import { useShellControls } from '../../use-shell-controls.ts'; -const SearchPage = () => { +const SearchResultItem = ({ + currentTenantId, + result, +}: { + readonly currentTenantId: string; + readonly result: ShellSearchResult; +}) => { const { language, t } = useModernI18n(); + const party = Match.value(result).pipe( + Match.when({ kind: 'resource' }, () => null), + Match.when({ kind: 'party' }, (partyResult) => partyResult), + Match.when({ kind: 'counterparty' }, (counterparty) => counterparty.party), + Match.exhaustive, + ); + const resultKindLabel = (): string => { + if (result.kind === 'resource') { + return result.ref.resourceType; + } + return result.kind === 'party' ? t('shell.search.party') : t('shell.search.counterparty'); + }; + return ( +
  • + + {result.title} + +
    + + {resultKindLabel()} + + {party?.archived === true ? ( + + {t('shell.search.archived')} + + ) : null} + {party?.matchedViaAlias === true ? ( + + {t('shell.search.alias_match')} + + ) : null} + {result.kind === 'counterparty' + ? result.currentRoles.map((role) => ( + + {t(`shell.search.roles.${role}`)} + + )) + : null} + {result.kind === 'counterparty' && result.collision !== undefined ? ( + + {t('shell.search.reconciliation_required')} + + ) : null} +
    +

    + {`${result.ref.tenantId ?? currentTenantId}:${result.ref.moduleId}:${result.ref.resourceType}:${result.ref.resourceId}`} +

    + {result.kind === 'counterparty' ? ( + <> +

    + {`${t('shell.search.party')}: ${result.party.ref.tenantId}:${result.party.ref.moduleId}:${result.party.ref.resourceType}:${result.party.ref.resourceId}`} +

    +

    + {`${t('shell.search.legal_entity')}: ${result.legalEntity.legalEntityId}`} +

    + + ) : null} +
  • + ); +}; + +const SearchPage = () => { + const { t } = useModernI18n(); const model = useLoaderData({ from: '/$lang/search' }); const controls = useShellControls( model.shell.state === 'authenticated' ? model.shell : undefined, @@ -28,12 +104,6 @@ const SearchPage = () => { ); } const currentTenantId = model.shell.identity.tenantId; - const resultKindLabel = (result: ShellSearchResult): string => { - if (result.kind === 'resource') { - return result.ref.resourceType; - } - return result.kind === 'party' ? t('shell.search.party') : t('shell.search.counterparty'); - }; const content = model.state !== 'ready' ? ( @@ -57,98 +127,20 @@ const SearchPage = () => { ) : (
      {model.response.results.map((result) => ( -
    • - - {result.title} - -
      - - {resultKindLabel(result)} - - {result.kind !== 'resource' && - (result.kind === 'party' ? result.archived : result.party.archived) ? ( - - {t('shell.search.archived')} - - ) : null} - {result.kind !== 'resource' && - (result.kind === 'party' - ? result.matchedViaAlias - : result.party.matchedViaAlias) ? ( - - {t('shell.search.alias_match')} - - ) : null} - {result.kind === 'counterparty' - ? result.currentRoles.map((role) => ( - - {t(`shell.search.roles.${role}`)} - - )) - : null} - {result.kind === 'counterparty' && result.collision !== undefined ? ( - - {t('shell.search.reconciliation_required')} - - ) : null} -
      -

      - {`${result.ref.tenantId ?? currentTenantId}:${result.ref.moduleId}:${result.ref.resourceType}:${result.ref.resourceId}`} -

      - {result.kind === 'counterparty' ? ( - <> -

      - {`${t('shell.search.party')}: ${result.party.ref.tenantId}:${result.party.ref.moduleId}:${result.party.ref.resourceType}:${result.party.ref.resourceId}`} -

      -

      - {`${t('shell.search.legal_entity')}: ${result.legalEntity.legalEntityId}`} -

      - - ) : null} -
    • + result={result} + /> ))}
    )} ); return ( - - {controls.logoutFailed ? ( - - {t('shell.auth.logout.failed')} - - ) : null} + {content} - + ); }; diff --git a/app/apps/shell-super-app/src/routes/shell-content-layout.tsx b/app/apps/shell-super-app/src/routes/shell-content-layout.tsx new file mode 100644 index 000000000..8b263e04c --- /dev/null +++ b/app/apps/shell-super-app/src/routes/shell-content-layout.tsx @@ -0,0 +1,57 @@ +import { useModernI18n } from '@modern-js/plugin-i18n/runtime'; +import { StatusText } from '@techsio/ui-kit/atoms/status-text'; +import type { ComponentProps } from 'react'; +import { AuthenticatedDashboardLayout } from './shell-frame.tsx'; +import type { useShellControls } from './use-shell-controls.ts'; + +type Controls = ReturnType; +type Shell = NonNullable[0]>; +type PageProps = Pick< + ComponentProps, + 'children' | 'currentModuleId' | 'title' +>; + +export const ShellContentLayout = ({ + children, + controls, + shell, + ...pageProps +}: PageProps & { + readonly controls: Controls; + readonly shell: Shell; +}) => { + const { t } = useModernI18n(); + return ( + + {controls.logoutFailed ? ( + + {t('shell.auth.logout.failed')} + + ) : null} + {children} + + ); +}; diff --git a/app/apps/shell-super-app/src/routes/shell-frame.tsx b/app/apps/shell-super-app/src/routes/shell-frame.tsx index 21f650c47..bd5d31818 100644 --- a/app/apps/shell-super-app/src/routes/shell-frame.tsx +++ b/app/apps/shell-super-app/src/routes/shell-frame.tsx @@ -59,6 +59,53 @@ export interface AuthenticatedDashboardLayoutProps { readonly unavailableDeployments: readonly ShellUnavailableDeployment[]; } +interface DashboardTenantSelectorProps { + readonly currentTenantId: string; + readonly onTenantChange: (tenantId: string) => void; + readonly tenantChoices: readonly DashboardTenantItem[]; + readonly tenantState: AuthenticatedDashboardLayoutProps['tenantState']; + readonly tenantSwitchFailed: boolean; + readonly tenantSwitchPending: boolean; +} + +interface DashboardLegalEntitySelectorProps { + readonly currentLegalEntityId: string | undefined; + readonly legalEntityChoices: readonly DashboardLegalEntityItem[]; + readonly legalEntityState: AuthenticatedDashboardLayoutProps['legalEntityState']; + readonly legalEntitySwitchFailed: boolean; + readonly legalEntitySwitchPending: boolean; + readonly onLegalEntityChange: (legalEntityId: string) => void; +} + +interface DashboardSearchProps { + readonly onSearch: (query: string) => void; + readonly onValueChange: (value: string) => void; + readonly value: string; +} + +interface DashboardModuleNavigationItemProps { + readonly currentModuleId: string | undefined; + readonly module: DashboardNavigationItem; +} + +interface DashboardDeploymentNavigationItemProps { + readonly deployment: ShellUnavailableDeployment; +} + +interface DashboardNavigationProps { + readonly currentModuleId: string | undefined; + readonly homeCurrent: boolean | undefined; + readonly navigation: readonly DashboardNavigationItem[]; + readonly unavailableDeployments: readonly ShellUnavailableDeployment[]; +} + +interface DashboardHeaderProps { + readonly identity: DashboardAccount; + readonly logoutPending: boolean; + readonly onLogout: () => void; + readonly title: string | undefined; +} + const selectorStatus = (failed: boolean, unavailable: boolean): 'default' | 'error' | 'warning' => { if (failed) { return 'error'; @@ -95,50 +142,20 @@ const tenantSelectorDisabled = ( tenantSwitchPending || !tenantItems.some((item) => item.value !== currentTenantId); -export const AuthenticatedDashboardLayout = ({ - children, - currentLegalEntityId, - currentModuleId, +const DashboardTenantSelector = ({ currentTenantId, - homeCurrent = true, - identity, - legalEntityChoices, - legalEntityState, - legalEntitySwitchFailed, - legalEntitySwitchPending, - logoutPending, - navigation, - onLegalEntityChange, - onLogout, - onSearch, onTenantChange, tenantChoices, tenantState, tenantSwitchFailed, tenantSwitchPending, - title, - unavailableDeployments, -}: AuthenticatedDashboardLayoutProps) => { +}: DashboardTenantSelectorProps) => { const { t } = useModernI18n(); - const [searchValue, setSearchValue] = useState(''); - const accountItems: MenuItem[] = [ - { - disabled: logoutPending, - label: t(logoutPending ? 'shell.auth.logout.pending' : 'shell.auth.logout.action'), - type: 'action', - value: 'logout', - }, - ]; const tenantItems = tenantChoices.map(({ name, tenantId }) => ({ displayValue: name, label: name, value: tenantId, })); - const legalEntityItems = legalEntityChoices.map(({ legalEntityId, legalName }) => ({ - displayValue: legalName, - label: legalName, - value: legalEntityId, - })); const tenantUnavailable = tenantState === 'unavailable'; const tenantStatus = selectorStatus(tenantSwitchFailed, tenantUnavailable); const tenantStatusText = selectorStatusText( @@ -157,6 +174,68 @@ export const AuthenticatedDashboardLayout = ({ tenantItems, currentTenantId, ); + + return ( + + ); +}; + +const DashboardLegalEntitySelector = ({ + currentLegalEntityId, + legalEntityChoices, + legalEntityState, + legalEntitySwitchFailed, + legalEntitySwitchPending, + onLegalEntityChange, +}: DashboardLegalEntitySelectorProps) => { + const { t } = useModernI18n(); + const legalEntityItems = legalEntityChoices.map(({ legalEntityId, legalName }) => ({ + displayValue: legalName, + label: legalName, + value: legalEntityId, + })); const legalEntityUnavailable = legalEntityState === 'unavailable'; const legalEntityStatus = selectorStatus(legalEntitySwitchFailed, legalEntityUnavailable); const legalEntityStatusText = selectorStatusText( @@ -170,6 +249,218 @@ export const AuthenticatedDashboardLayout = ({ }, ); + return ( + + ); +}; + +const DashboardSearch = ({ onSearch, onValueChange, value }: DashboardSearchProps) => { + const { t } = useModernI18n(); + + return ( + { + event.preventDefault(); + const query = value.trim(); + if (query.length > 0) { + onSearch(query); + } + }} + onValueChange={onValueChange} + value={value} + > + {t('shell.search.label')} + + + + {t('shell.search.submit')} + + + ); +}; + +const DashboardModuleNavigationItem = ({ + currentModuleId, + module, +}: DashboardModuleNavigationItemProps) => { + const { t } = useModernI18n(); + + return ( +
  • + {module.enabled && module.href !== undefined ? ( + + {module.label} + + ) : ( + {module.label} + )} + {module.state === 'read_only' ? ( + + {t('shell.modules.state.readOnly')} + + ) : null} + {module.state === 'deprecated' ? ( + + {t('shell.modules.state.deprecated')} + + ) : null} + {module.unavailable ? ( + + {t('shell.modules.unavailable')} + + ) : null} +
  • + ); +}; + +const DashboardDeploymentNavigationItem = ({ + deployment, +}: DashboardDeploymentNavigationItemProps) => { + const { t } = useModernI18n(); + + return ( +
  • + {deployment.appId} + + {t( + `shell.modules.discovery.${ + deployment.status === 'unavailable' ? deployment.reason : deployment.status + }`, + )} + +
  • + ); +}; + +const DashboardNavigation = ({ + currentModuleId, + homeCurrent = true, + navigation, + unavailableDeployments, +}: DashboardNavigationProps) => { + const { t } = useModernI18n(); + + return ( + + ); +}; + +const DashboardHeader = ({ identity, logoutPending, onLogout, title }: DashboardHeaderProps) => { + const { t } = useModernI18n(); + const accountItems: MenuItem[] = [ + { + disabled: logoutPending, + label: t(logoutPending ? 'shell.auth.logout.pending' : 'shell.auth.logout.action'), + type: 'action', + value: 'logout', + }, + ]; + + return ( +
    + {title === undefined ? null : ( + +

    {title}

    +
    + )} + + + + { + if (value === 'logout') { + onLogout(); + } + }} + triggerText={identity.displayName} + /> + + + +
    + ); +}; + +export const AuthenticatedDashboardLayout = (props: AuthenticatedDashboardLayoutProps) => { + const { t } = useModernI18n(); + const [searchValue, setSearchValue] = useState(''); + const { tenantSwitchFailed } = props; + useEffect(() => { if (tenantSwitchFailed) { document.querySelector('#tenant-switch-status')?.scrollIntoView({ block: 'nearest' }); @@ -183,202 +474,42 @@ export const AuthenticatedDashboardLayout = ({ className="shell:flex shell:w-full shell:shrink-0 shell:flex-col shell:gap-6 shell:bg-(--color-surface) shell:p-4 shell:md:w-64" >

    {t('shell.dashboard.brand')}

    - - - { - event.preventDefault(); - const query = searchValue.trim(); - if (query.length > 0) { - onSearch(query); - } - }} + + + - {t('shell.search.label')} - - - - {t('shell.search.submit')} - - - + /> +
    -
    - {title === undefined ? null : ( - -

    {title}

    -
    - )} - - - - { - if (value === 'logout') { - onLogout(); - } - }} - triggerText={identity.displayName} - /> - - - -
    -
    {children}
    + +
    {props.children}
    ); diff --git a/app/apps/shell-super-app/src/routes/ultramodern-jsonld.ts b/app/apps/shell-super-app/src/routes/ultramodern-jsonld.ts index 0fd5b5234..678f530d7 100644 --- a/app/apps/shell-super-app/src/routes/ultramodern-jsonld.ts +++ b/app/apps/shell-super-app/src/routes/ultramodern-jsonld.ts @@ -1,113 +1,7 @@ -export type JsonLdPrimitive = string | number | boolean | null; -export type JsonLdValue = +type JsonLdPrimitive = string | number | boolean | null; +type JsonLdValue = | JsonLdPrimitive | readonly JsonLdValue[] | { readonly [key: string]: JsonLdValue }; -export type JsonLdObject = Readonly>; +type JsonLdObject = Readonly>; export type RouteJsonLd = JsonLdObject | readonly JsonLdObject[]; - -const schemaContext = 'https://schema.org' as const; - -type SchemaObject = JsonLdObject & { - readonly '@context': typeof schemaContext; - readonly '@type': TType; -}; - -type ThingReference = - | string - | { - readonly '@id'?: string; - readonly '@type'?: string; - readonly name?: string; - readonly url?: string; - }; - -const withSchemaContext = ( - type: TType, - input: TInput, -): SchemaObject & TInput => ({ - '@context': schemaContext, - '@type': type, - ...input, -}); - -export const defineRouteJsonLd = (jsonLd: TJsonLd): TJsonLd => jsonLd; - -export interface WebPageJsonLdInput { - readonly description?: string; - readonly inLanguage?: string | readonly string[]; - readonly isPartOf?: ThingReference; - readonly name: string; - readonly url: string; -} - -export const webPageJsonLd = (input: WebPageJsonLdInput) => withSchemaContext('WebPage', input); - -export interface WebApplicationJsonLdInput { - readonly applicationCategory?: string; - readonly browserRequirements?: string; - readonly description?: string; - readonly name: string; - readonly operatingSystem?: string; - readonly url: string; -} - -export const webApplicationJsonLd = (input: WebApplicationJsonLdInput) => - withSchemaContext('WebApplication', input); - -export interface SoftwareApplicationJsonLdInput { - readonly applicationCategory?: string; - readonly applicationSubCategory?: string; - readonly description?: string; - readonly name: string; - readonly offers?: ThingReference; - readonly operatingSystem?: string; - readonly url: string; -} - -export const softwareApplicationJsonLd = (input: SoftwareApplicationJsonLdInput) => - withSchemaContext('SoftwareApplication', input); - -export interface OrganizationJsonLdInput { - readonly logo?: string; - readonly name: string; - readonly sameAs?: readonly string[]; - readonly url?: string; -} - -export const organizationJsonLd = (input: OrganizationJsonLdInput) => - withSchemaContext('Organization', input); - -export interface BreadcrumbListItemInput { - readonly item: string; - readonly name: string; -} - -export const breadcrumbListJsonLd = (items: readonly BreadcrumbListItemInput[]) => - withSchemaContext('BreadcrumbList', { - itemListElement: items.map((entry, index) => ({ - '@type': 'ListItem', - item: entry.item, - name: entry.name, - position: index + 1, - })), - }); - -export interface FAQPageQuestionInput { - readonly acceptedAnswer: { - readonly text: string; - }; - readonly name: string; -} - -export const faqPageJsonLd = (questions: readonly FAQPageQuestionInput[]) => - withSchemaContext('FAQPage', { - mainEntity: questions.map((question) => ({ - '@type': 'Question', - acceptedAnswer: { - '@type': 'Answer', - text: question.acceptedAnswer.text, - }, - name: question.name, - })), - }); diff --git a/app/apps/shell-super-app/src/routes/ultramodern-route-head.tsx b/app/apps/shell-super-app/src/routes/ultramodern-route-head.tsx index 38c95d494..753274fb8 100644 --- a/app/apps/shell-super-app/src/routes/ultramodern-route-head.tsx +++ b/app/apps/shell-super-app/src/routes/ultramodern-route-head.tsx @@ -98,6 +98,7 @@ const sanitiseJsonLd = (value: RouteJsonLd) => export const UltramodernRouteHead = () => { const { language, t } = useModernI18n(); const { alternates, canonical } = useLocalizedLocation(); + const resolvedLanguage = language ?? fallbackLanguage; const route = resolveRouteMetadata(canonical); const title = route === undefined ? appName : t(route.titleKey); const description = route === undefined ? appName : t(route.descriptionKey); @@ -106,7 +107,7 @@ export const UltramodernRouteHead = () => { const jsonLd = route?.jsonLd; return ( - + {title} @@ -121,16 +122,12 @@ export const UltramodernRouteHead = () => { rel="alternate" /> ))} - + - + diff --git a/app/apps/shell-super-app/src/routes/ultramodern-route-metadata.ts b/app/apps/shell-super-app/src/routes/ultramodern-route-metadata.ts index ff0b706c8..835a449a1 100644 --- a/app/apps/shell-super-app/src/routes/ultramodern-route-metadata.ts +++ b/app/apps/shell-super-app/src/routes/ultramodern-route-metadata.ts @@ -194,15 +194,3 @@ export const ultramodernLocalisedUrls = { en: '/resources/:moduleId/:resourceType/:resourceId', }, } as const; - -export const ultramodernPublicRoutes = [] as const; - -export const ultramodernRouteConfig = { - authoring: 'colocated-route-meta', - generatedManifest: true, - localisedUrls: ultramodernLocalisedUrls, - namespace: ultramodernRouteNamespace, - publicRoutes: ultramodernPublicRoutes, - routes: ultramodernRouteMetadata, - source: 'route-owned', -} as const; diff --git a/app/apps/shell-super-app/src/routes/use-shell-controls.ts b/app/apps/shell-super-app/src/routes/use-shell-controls.ts index e7977e73d..03195f038 100644 --- a/app/apps/shell-super-app/src/routes/use-shell-controls.ts +++ b/app/apps/shell-super-app/src/routes/use-shell-controls.ts @@ -8,7 +8,7 @@ import { SwitchLegalEntityPayloadSchema, SwitchTenantPayloadSchema } from '../.. import { runBrowserEffect } from '../runtime/browser-effect-runtime.ts'; import type { AuthenticatedHomePageModel } from './[lang]/page.data.ts'; -export const SwitchFailureStateSchema = Schema.Literals(['authentication-required', 'failed']); +const SwitchFailureStateSchema = Schema.Literals(['authentication-required', 'failed']); export type SwitchFailureState = typeof SwitchFailureStateSchema.Type; const tenantSwitchFailureState = (error: SwitchTenantClientError): SwitchFailureState => diff --git a/app/apps/shell-super-app/src/routes/vertical-components.tsx b/app/apps/shell-super-app/src/routes/vertical-components.tsx index e0d236934..52c1dd43a 100644 --- a/app/apps/shell-super-app/src/routes/vertical-components.tsx +++ b/app/apps/shell-super-app/src/routes/vertical-components.tsx @@ -1,55 +1,2 @@ -import { Link, useModernI18n } from '@modern-js/plugin-i18n/runtime'; - -const widgetCount = Number('0'); - -export const Header = () => { - const { t } = useModernI18n(); - - return ( -
    - - {t('shell.title')} - -
    - ); -}; - -export const StatusBadge = () => { - const { t } = useModernI18n(); - - return ( - - {widgetCount} {t('shell.hero.cardOneKicker')} - - ); -}; - -export const VerticalShowcase = () => { - const { t } = useModernI18n(); - - if (widgetCount === 0) { - return ( -
    -

    - {t('shell.hero.empty')} -

    -
    - ); - } - - return ( -
    -
    -
    - ); -}; +// Generated composition entry retained for workspace tooling. +export { VerticalShowcase } from '../../shared/vertical-showcase'; diff --git a/app/apps/shell-super-app/src/routes/vertical-components.worker.tsx b/app/apps/shell-super-app/src/routes/vertical-components.worker.tsx index e0d236934..52c1dd43a 100644 --- a/app/apps/shell-super-app/src/routes/vertical-components.worker.tsx +++ b/app/apps/shell-super-app/src/routes/vertical-components.worker.tsx @@ -1,55 +1,2 @@ -import { Link, useModernI18n } from '@modern-js/plugin-i18n/runtime'; - -const widgetCount = Number('0'); - -export const Header = () => { - const { t } = useModernI18n(); - - return ( -
    - - {t('shell.title')} - -
    - ); -}; - -export const StatusBadge = () => { - const { t } = useModernI18n(); - - return ( - - {widgetCount} {t('shell.hero.cardOneKicker')} - - ); -}; - -export const VerticalShowcase = () => { - const { t } = useModernI18n(); - - if (widgetCount === 0) { - return ( -
    -

    - {t('shell.hero.empty')} -

    -
    - ); - } - - return ( -
    -
    -
    - ); -}; +// Generated composition entry retained for workspace tooling. +export { VerticalShowcase } from '../../shared/vertical-showcase'; diff --git a/app/apps/shell-super-app/tests/integration/auth-runtime.test.ts b/app/apps/shell-super-app/tests/integration/auth-runtime.test.ts index 905b5c776..fea9924f7 100644 --- a/app/apps/shell-super-app/tests/integration/auth-runtime.test.ts +++ b/app/apps/shell-super-app/tests/integration/auth-runtime.test.ts @@ -103,6 +103,16 @@ const authenticationContextLayer = Layer.mergeAll( const cookieHeader = (setCookieHeaders: readonly string[]) => setCookieHeaders.map((header) => header.split(';')[0]).join('; '); +const headerValue = (headers: Headers, name: string): string => headers.get(name) ?? ''; +const optionalText = (value: string | undefined): string => value ?? ''; +const assertOptionalField = ( + value: Value | null | undefined, + key: Key, + expected: string | null, +): void => { + assert.equal(value?.[key], expected); +}; + const installedCatalog = (moduleIds: readonly string[]): InstalledModuleCatalog => Object.freeze({ contracts: Object.freeze([]), @@ -370,7 +380,7 @@ test('creates, resolves, persists, revokes, and signs out a Better Auth session' const invalid = await runEffectTestPromise( Effect.flip(authentication.signIn(email, 'wrong-password', requestHeaders)), ); - assert.equal(invalid._tag, 'InvalidCredentialsError'); + assert.ok(Schema.is(Schema.TaggedStruct('InvalidCredentialsError', {}))(invalid)); const anonymousRuntime = makeShellAuthenticationApiRuntime( authenticationLayer, @@ -395,7 +405,7 @@ test('creates, resolves, persists, revokes, and signs out a Better Auth session' }), ); assert.equal(anonymousGatewayResponse.status, 401); - assert.match(anonymousGatewayResponse.headers.get('www-authenticate') ?? '', /^Bearer/u); + assert.match(headerValue(anonymousGatewayResponse.headers, 'www-authenticate'), /^Bearer/u); const anonymousModulesResponse = await unavailableHandler.handler( new Request(`${configuration.baseUrl}/shell/composition`, { @@ -403,7 +413,7 @@ test('creates, resolves, persists, revokes, and signs out a Better Auth session' }), ); assert.equal(anonymousModulesResponse.status, 401); - assert.match(anonymousModulesResponse.headers.get('www-authenticate') ?? '', /^Bearer/u); + assert.match(headerValue(anonymousModulesResponse.headers, 'www-authenticate'), /^Bearer/u); const anonymousPageResponse = await unavailableHandler.handler( new Request(`${configuration.baseUrl}/shell/module-target`, { body: JSON.stringify({ @@ -415,7 +425,7 @@ test('creates, resolves, persists, revokes, and signs out a Better Auth session' }), ); assert.equal(anonymousPageResponse.status, 401); - assert.match(anonymousPageResponse.headers.get('www-authenticate') ?? '', /^Bearer/u); + assert.match(headerValue(anonymousPageResponse.headers, 'www-authenticate'), /^Bearer/u); const signInResponse = await unavailableHandler.handler( new Request(`${configuration.baseUrl}/auth/sign-in`, { @@ -440,7 +450,7 @@ test('creates, resolves, persists, revokes, and signs out a Better Auth session' body: JSON.stringify({ entrypointKey, moduleId: 'testing.pages' }), headers: new Headers({ 'content-type': 'application/json', - cookie: authenticatedHeaders.get('cookie') ?? '', + cookie: headerValue(authenticatedHeaders, 'cookie'), origin: configuration.baseUrl, }), method: 'POST', @@ -450,7 +460,7 @@ test('creates, resolves, persists, revokes, and signs out a Better Auth session' .currentSession(authenticatedHeaders) .pipe(Effect.provide(authenticationContextLayer)), ); - assert.equal(current.identity?.tenantId, tenantId); + assertOptionalField(current.identity, 'tenantId', tenantId); assert.notEqual(current.identity, undefined); const pageRuntime = makeShellAuthenticationApiRuntime( @@ -570,7 +580,7 @@ test('creates, resolves, persists, revokes, and signs out a Better Auth session' body: JSON.stringify({ name: 'must-not-be-created' }), headers: { 'content-type': 'application/json', - cookie: authenticatedHeaders.get('cookie') ?? '', + cookie: headerValue(authenticatedHeaders, 'cookie'), origin: configuration.baseUrl, }, method: 'POST', @@ -621,7 +631,7 @@ test('creates, resolves, persists, revokes, and signs out a Better Auth session' body: JSON.stringify({ displayName: 'Denied managed identity', kind: 'service' }), headers: { 'content-type': 'application/json', - cookie: authenticatedHeaders.get('cookie') ?? '', + cookie: headerValue(authenticatedHeaders, 'cookie'), 'idempotency-key': 'denied-managed-identity', origin: configuration.baseUrl, }, @@ -639,7 +649,7 @@ test('creates, resolves, persists, revokes, and signs out a Better Auth session' .where(eq(actionInvocations.idempotencyKey, 'denied-managed-identity')) .limit(1), ); - assert.equal(deniedIdentityInvocation?.status, 'rejected'); + assertOptionalField(deniedIdentityInvocation, 'status', 'rejected'); if (deniedIdentityInvocation === undefined) { throw new Error('The denied identity Action did not persist its invocation'); } @@ -823,7 +833,7 @@ test('creates, resolves, persists, revokes, and signs out a Better Auth session' ); assert.equal(unavailableGatewayResponse.status, 503); assert.match( - unavailableGatewayResponse.headers.get('content-type') ?? '', + headerValue(unavailableGatewayResponse.headers, 'content-type'), /application\/problem\+json/u, ); assert.equal( @@ -844,11 +854,11 @@ test('creates, resolves, persists, revokes, and signs out a Better Auth session' privateJwk: { alg: 'EdDSA', crv: 'Ed25519', - d: privateJwk.d ?? '', + d: optionalText(privateJwk.d), kid: 'integration-current', kty: 'OKP', use: 'sig', - x: privateJwk.x ?? '', + x: optionalText(privateJwk.x), }, }), }; @@ -884,7 +894,7 @@ test('creates, resolves, persists, revokes, and signs out a Better Auth session' verifiedAssertion.payload['principal'], ); assert.equal(verifiedPrincipal.authBindingId, fixtureAuthBindingId); - assert.match(verifiedPrincipal.authContextRef ?? '', /^better-auth-session:/u); + assert.match(optionalText(verifiedPrincipal.authContextRef), /^better-auth-session:/u); assert.equal(verifiedPrincipal.authMethod, 'session'); assert.equal(verifiedPrincipal.legalEntityId, fixtureLegalEntityId); assert.equal(verifiedPrincipal.principalId, principalId); @@ -947,7 +957,7 @@ test('creates, resolves, persists, revokes, and signs out a Better Auth session' ), ); assert.equal(generatedPrincipal.authBindingId, fixtureAuthBindingId); - assert.match(generatedPrincipal.authContextRef ?? '', /^better-auth-session:/u); + assert.match(optionalText(generatedPrincipal.authContextRef), /^better-auth-session:/u); assert.equal(generatedPrincipal.authMethod, 'session'); assert.equal(generatedPrincipal.legalEntityId, fixtureLegalEntityId); assert.equal(generatedPrincipal.principalId, principalId); @@ -991,7 +1001,10 @@ test('creates, resolves, persists, revokes, and signs out a Better Auth session' }), ); assert.equal(defectResponse.status, 500); - assert.match(defectResponse.headers.get('content-type') ?? '', /application\/problem\+json/u); + assert.match( + headerValue(defectResponse.headers, 'content-type'), + /application\/problem\+json/u, + ); const defectProblem = Schema.decodeUnknownSync(DefectProblemSchema)( await defectResponse.json(), ); @@ -1003,7 +1016,7 @@ test('creates, resolves, persists, revokes, and signs out a Better Auth session' .currentSession(authenticatedHeaders) .pipe(Effect.provide(authenticationContextLayer)), ); - assert.equal(stillAuthenticated.identity?.principalId, principalId); + assertOptionalField(stillAuthenticated.identity, 'principalId', principalId); await runEffectTestPromise( coreDatabase @@ -1018,14 +1031,14 @@ test('creates, resolves, persists, revokes, and signs out a Better Auth session' .pipe(Effect.provide(authenticationContextLayer)), ), ); - assert.equal(revoked._tag, 'OntosIdentityForbiddenError'); + assert.ok(Schema.is(Schema.TaggedStruct('OntosIdentityForbiddenError', {}))(revoked)); const forbiddenModulesResponse = await unavailableHandler.handler( new Request(`${configuration.baseUrl}/shell/composition`, { headers: authenticatedHeaders, }), ); assert.equal(forbiddenModulesResponse.status, 401); - assert.match(forbiddenModulesResponse.headers.get('www-authenticate') ?? '', /^Bearer/u); + assert.match(headerValue(forbiddenModulesResponse.headers, 'www-authenticate'), /^Bearer/u); assert.doesNotMatch(await forbiddenModulesResponse.text(), /30000000|40000000/u); await runEffectTestPromise( @@ -1306,7 +1319,7 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session .from(session) .where(eq(session.userId, betterAuthUserId)), ); - assert.equal(initialSessions[0]?.activeTenantId, firstTenantId); + assertOptionalField(initialSessions[0], 'activeTenantId', firstTenantId); const pair = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true }); const privateJwk = await exportJWK(pair.privateKey); @@ -1321,11 +1334,11 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session privateJwk: { alg: 'EdDSA', crv: 'Ed25519', - d: privateJwk.d ?? '', + d: optionalText(privateJwk.d), kid: 'multi-tenant-current', kty: 'OKP', use: 'sig', - x: privateJwk.x ?? '', + x: optionalText(privateJwk.x), }, }), }), @@ -1342,7 +1355,7 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session }), ); assert.equal(anonymousAvailableResponse.status, 401); - assert.match(anonymousAvailableResponse.headers.get('www-authenticate') ?? '', /^Bearer /u); + assert.match(headerValue(anonymousAvailableResponse.headers, 'www-authenticate'), /^Bearer /u); const availableResponse = await runtime.handler( new Request(`${configuration.baseUrl}/auth/tenants`, { headers: authenticatedHeaders }), @@ -1386,7 +1399,7 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session .from(session) .where(eq(session.userId, betterAuthUserId)), ); - assert.equal(sessionsAfterForbiddenSwitch[0]?.activeTenantId, firstTenantId); + assertOptionalField(sessionsAfterForbiddenSwitch[0], 'activeTenantId', firstTenantId); await runEffectTestPromise( coreDatabase @@ -1412,7 +1425,7 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session .from(session) .where(eq(session.userId, betterAuthUserId)), ); - assert.equal(sessionsAfterInactiveSwitch[0]?.activeTenantId, firstTenantId); + assertOptionalField(sessionsAfterInactiveSwitch[0], 'activeTenantId', firstTenantId); await runEffectTestPromise( coreDatabase .update(principals) @@ -1463,7 +1476,7 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session .from(session) .where(eq(session.userId, betterAuthUserId)), ); - assert.equal(sessionsAfterResolverFailure[0]?.activeTenantId, firstTenantId); + assertOptionalField(sessionsAfterResolverFailure[0], 'activeTenantId', firstTenantId); // Drizzle has no query-builder failure injection. This temporary trigger raises PostgreSQL's // connection-failure class for the fixed test tenant through the real Better Auth adapter path. @@ -1513,7 +1526,7 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session .from(session) .where(eq(session.userId, betterAuthUserId)), ); - assert.equal(sessionsAfterPersistenceFailure[0]?.activeTenantId, firstTenantId); + assertOptionalField(sessionsAfterPersistenceFailure[0], 'activeTenantId', firstTenantId); } finally { await runEffectTestPromise( adminAuthDatabase.execute( @@ -1537,7 +1550,7 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session .from(session) .where(eq(session.userId, betterAuthUserId)), ); - assert.equal(sessionsBeforeSwitch[0]?.activeLegalEntityId, firstLegalEntityId); + assertOptionalField(sessionsBeforeSwitch[0], 'activeLegalEntityId', firstLegalEntityId); const switchResponse = await runtime.handler( new Request(`${configuration.baseUrl}/auth/tenant/switch`, { @@ -1561,14 +1574,14 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session .from(session) .where(eq(session.userId, betterAuthUserId)), ); - assert.equal(sessionsAfterSwitch[0]?.activeTenantId, secondTenantId); - assert.equal(sessionsAfterSwitch[0]?.activeLegalEntityId, null); + assertOptionalField(sessionsAfterSwitch[0], 'activeTenantId', secondTenantId); + assertOptionalField(sessionsAfterSwitch[0], 'activeLegalEntityId', null); const currentSessionAfterSwitch = await runEffectTestPromise( authentication .currentSession(authenticatedHeaders) .pipe(Effect.provide(multiAuthenticationContextLayer)), ); - assert.equal(currentSessionAfterSwitch.identity?.principalId, secondPrincipalId); + assertOptionalField(currentSessionAfterSwitch.identity, 'principalId', secondPrincipalId); const idempotentSwitch = await runEffectTestPromise( authentication .switchTenant(secondTenantId, authenticatedHeaders) @@ -1606,7 +1619,7 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session verified.payload['principal'], ); assert.equal(verifiedPrincipal.authBindingId, secondAuthBindingId); - assert.match(verifiedPrincipal.authContextRef ?? '', /^better-auth-session:/u); + assert.match(optionalText(verifiedPrincipal.authContextRef), /^better-auth-session:/u); assert.equal(verifiedPrincipal.authMethod, 'session'); assert.equal(verifiedPrincipal.legalEntityId, secondLegalEntityId); assert.equal(verifiedPrincipal.principalId, secondPrincipalId); @@ -1665,7 +1678,11 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session .from(session) .where(eq(session.userId, betterAuthUserId)), ); - assert.equal(sessionsAfterUnexpectedSwitchFailure[0]?.activeTenantId, secondTenantId); + assertOptionalField( + sessionsAfterUnexpectedSwitchFailure[0], + 'activeTenantId', + secondTenantId, + ); await runEffectTestPromise( authDatabase @@ -1693,7 +1710,7 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session .from(session) .where(eq(session.userId, betterAuthUserId)), ); - assert.equal(sessionsAfterUnexpectedLegacyUpgrade[0]?.activeTenantId, null); + assertOptionalField(sessionsAfterUnexpectedLegacyUpgrade[0], 'activeTenantId', null); } finally { await runEffectTestPromise( adminAuthDatabase.execute( @@ -1716,14 +1733,14 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session .currentSession(authenticatedHeaders) .pipe(Effect.provide(multiAuthenticationContextLayer)), ); - assert.equal(upgradedSession.identity?.tenantId, firstTenantId); + assertOptionalField(upgradedSession.identity, 'tenantId', firstTenantId); const upgradedSessionRows = await runEffectTestPromise( authDatabase .select({ activeTenantId: session.activeTenantId }) .from(session) .where(eq(session.userId, betterAuthUserId)), ); - assert.equal(upgradedSessionRows[0]?.activeTenantId, firstTenantId); + assertOptionalField(upgradedSessionRows[0], 'activeTenantId', firstTenantId); await runEffectTestPromise( authentication @@ -1743,7 +1760,7 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session .pipe(Effect.provide(multiAuthenticationContextLayer)), ), ); - assert.equal(revokedSession._tag, 'OntosIdentityForbiddenError'); + assert.ok(Schema.is(Schema.TaggedStruct('OntosIdentityForbiddenError', {}))(revokedSession)); await runEffectTestPromise( coreDatabase .update(principalAuthBindings) @@ -1755,7 +1772,7 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session .currentSession(authenticatedHeaders) .pipe(Effect.provide(multiAuthenticationContextLayer)), ); - assert.equal(restoredSession.identity?.tenantId, secondTenantId); + assertOptionalField(restoredSession.identity, 'tenantId', secondTenantId); // Production evidence retains referenced bindings. Clear only this fixture's evidence so the // resolver can still prove that an existing selected session rejects a genuinely missing row. @@ -1774,7 +1791,9 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session .pipe(Effect.provide(multiAuthenticationContextLayer)), ), ); - assert.equal(sessionWithRemovedBinding._tag, 'OntosIdentityForbiddenError'); + assert.ok( + Schema.is(Schema.TaggedStruct('OntosIdentityForbiddenError', {}))(sessionWithRemovedBinding), + ); } finally { await Promise.all(handlers.map(async ({ dispose }) => await dispose())); await cleanup(); diff --git a/app/apps/shell-super-app/tests/integration/generated-owner-isolation.test.ts b/app/apps/shell-super-app/tests/integration/generated-owner-isolation.test.ts index a67ec3315..dae759cae 100644 --- a/app/apps/shell-super-app/tests/integration/generated-owner-isolation.test.ts +++ b/app/apps/shell-super-app/tests/integration/generated-owner-isolation.test.ts @@ -270,6 +270,33 @@ const makeOwnerHandler = ( return handler; }; +const loadClientWiring = async (entrypoints: ReturnType) => { + const [detailClient, listClient, searchClient] = await Promise.all([ + entrypoints.api['resource-detail']?.(), + entrypoints.api['resource-list']?.(), + entrypoints.search['records']?.(), + ]); + return { + action: true, + detailClient: + detailClient !== undefined && + Predicate.isFunction( + Object.getOwnPropertyDescriptor(detailClient, 'executeResourceDetailWithAuthorization') + ?.value, + ), + listClient: + listClient !== undefined && + Predicate.isFunction( + Object.getOwnPropertyDescriptor(listClient, 'executeResourceListWithAuthorization')?.value, + ), + searchClient: + searchClient !== undefined && + Predicate.isFunction( + Object.getOwnPropertyDescriptor(searchClient, 'loadRecordsClientWithAuthorization')?.value, + ), + }; +}; + const loadGeneratedOwner = async ( verticalRoot: string, runtime: ReadRuntimeService, @@ -308,11 +335,7 @@ const loadGeneratedOwner = async ( ); const actions = getVerticalRuntimeActions(registration); const entrypoints = getVerticalRuntimeEntrypoints(registration); - const [detailClient, listClient, searchClient] = await Promise.all([ - entrypoints.api['resource-detail']?.(), - entrypoints.api['resource-list']?.(), - entrypoints.search['records']?.(), - ]); + const wiring = await loadClientWiring(entrypoints); const generatedAction = actions.find( ({ descriptor }) => descriptor.actionKey === GENERATED_OWNER.actionKey, ); @@ -346,27 +369,7 @@ const loadGeneratedOwner = async ( verifyActionPrincipal: Schema.decodeUnknownSync(OwnerVerifierSchema)( verifier['verifyActionPrincipal'], ), - wiring: { - action: true, - detailClient: - detailClient !== undefined && - Predicate.isFunction( - Object.getOwnPropertyDescriptor(detailClient, 'executeResourceDetailWithAuthorization') - ?.value, - ), - listClient: - listClient !== undefined && - Predicate.isFunction( - Object.getOwnPropertyDescriptor(listClient, 'executeResourceListWithAuthorization') - ?.value, - ), - searchClient: - searchClient !== undefined && - Predicate.isFunction( - Object.getOwnPropertyDescriptor(searchClient, 'loadRecordsClientWithAuthorization') - ?.value, - ), - }, + wiring, }; }; diff --git a/app/apps/shell-super-app/tests/support/impersonation-service-doubles.ts b/app/apps/shell-super-app/tests/support/impersonation-service-doubles.ts index 59544336b..929983af0 100644 --- a/app/apps/shell-super-app/tests/support/impersonation-service-doubles.ts +++ b/app/apps/shell-super-app/tests/support/impersonation-service-doubles.ts @@ -1,4 +1,3 @@ -import type { SupportRecoveryPrincipalContextResolverService } from '@app/core-runtime'; import { Effect } from 'effect'; import type { SupportAuthProvider, @@ -40,10 +39,6 @@ const storeDefaults: SupportImpersonationStore = { updateImpersonationSession: () => unconfiguredEffect('updateImpersonationSession'), }; -const supportRecoveryDefaults: SupportRecoveryPrincipalContextResolverService = { - resolveStoppedImpersonation: () => unconfiguredEffect('resolveStoppedImpersonation'), -}; - export const makeAuthenticationServiceDouble = ( overrides: Partial = {}, ): AuthenticationServiceContract => ({ ...authenticationDefaults, ...overrides }); @@ -55,10 +50,3 @@ export const makeSupportAuthProviderDouble = ( export const makeSupportImpersonationStoreDouble = ( overrides: Partial = {}, ): SupportImpersonationStore => ({ ...storeDefaults, ...overrides }); - -export const makeSupportRecoveryPrincipalDouble = ( - overrides: Partial = {}, -): SupportRecoveryPrincipalContextResolverService => ({ - ...supportRecoveryDefaults, - ...overrides, -}); diff --git a/app/apps/shell-super-app/tests/unit/impersonation-service.test.ts b/app/apps/shell-super-app/tests/unit/impersonation-service.test.ts index ba81be304..d6c520c56 100644 --- a/app/apps/shell-super-app/tests/unit/impersonation-service.test.ts +++ b/app/apps/shell-super-app/tests/unit/impersonation-service.test.ts @@ -596,26 +596,7 @@ test('persists and completes stopped evidence on the first stop after impersonat expect(deleteCalls).toBe(2); }); -test('restores the original session and stopped checkpoint after the provider response is lost', async () => { - const originalSessionToken = 'original-session-token'; - const adminValue = `${originalSessionToken}:true`; - const adminCookie = encodeURIComponent( - `${adminValue}.${await makeSignature(adminValue, configuration.secret)}`, - ); - const requestHeaders = new Headers({ - cookie: `better-auth.admin_session=${adminCookie}; better-auth.session_token=deleted`, - }); - const recovery = { - actionId: 'impersonation-action', - createdAt: new Date('2026-08-09T00:00:00.000Z'), - impersonationSessionId, - originalAuthBindingId, - originalPrincipalId, - originalSessionId: restoredSessionId, - reason: 'Investigate support request', - targetPrincipalId, - tenantId, - }; +const makeLostResponseRecoveryService = (recovery: SupportRecoveryRecord, expiresAt: Date) => { let deleted = false; const actionRuntime = makeActionRuntimeDouble([ actionSuccess({ checkpoint: 'stopped', recorded: true }), @@ -637,7 +618,7 @@ test('restores the original session and stopped checkpoint after the provider re loadOriginalSession: () => Effect.succeed( Option.some({ - expiresAt: new Date('2099-01-01T00:00:00.000Z'), + expiresAt, id: restoredSessionId, }), ), @@ -645,6 +626,33 @@ test('restores the original session and stopped checkpoint after the provider re }), supportRecoveryPrincipal, }); + return { actionRuntime, deleted: () => deleted, service }; +}; + +test('restores the original session and stopped checkpoint after the provider response is lost', async () => { + const originalSessionToken = 'original-session-token'; + const adminValue = `${originalSessionToken}:true`; + const adminCookie = encodeURIComponent( + `${adminValue}.${await makeSignature(adminValue, configuration.secret)}`, + ); + const requestHeaders = new Headers({ + cookie: `better-auth.admin_session=${adminCookie}; better-auth.session_token=deleted`, + }); + const recovery = { + actionId: 'impersonation-action', + createdAt: new Date('2026-08-09T00:00:00.000Z'), + impersonationSessionId, + originalAuthBindingId, + originalPrincipalId, + originalSessionId: restoredSessionId, + reason: 'Investigate support request', + targetPrincipalId, + tenantId, + }; + const { actionRuntime, deleted, service } = makeLostResponseRecoveryService( + recovery, + new Date('2099-01-01T00:00:00.000Z'), + ); const result = await runEffectTestPromise( service @@ -658,7 +666,7 @@ test('restores the original session and stopped checkpoint after the provider re expect(result.active).toBe(false); expect(result.checkpointPending).toBe(false); expect(actionRuntime.invocationCount()).toBe(1); - expect(deleted).toBe(true); + expect(deleted()).toBe(true); const restoredSessionCookie = result.setCookieHeaders.find((header) => header.startsWith('better-auth.session_token='), ); @@ -693,35 +701,10 @@ test('completes stopped recovery when a lost response leaves only an expired ori targetPrincipalId, tenantId, }; - let deleted = false; - const actionRuntime = makeActionRuntimeDouble([ - actionSuccess({ checkpoint: 'stopped', recorded: true }), - ]); - const service = makeService({ - actionRuntime: actionRuntime.runtime, - authentication: makeAuthenticationServiceDouble(), - configuration, - provider: makeSupportAuthProviderDouble({ - getSession: async () => ({ headers: new Headers(), response: null }), - }), - resolver: makePrincipalResolverDouble(), - store: makeSupportImpersonationStoreDouble({ - deleteRecovery: () => - Effect.sync(() => { - deleted = true; - }), - deleteSession: () => Effect.void, - loadOriginalSession: () => - Effect.succeed( - Option.some({ - expiresAt: new Date('2000-01-01T00:00:00.000Z'), - id: restoredSessionId, - }), - ), - loadRecoveries: () => Effect.succeed([recovery]), - }), - supportRecoveryPrincipal, - }); + const { actionRuntime, deleted, service } = makeLostResponseRecoveryService( + recovery, + new Date('2000-01-01T00:00:00.000Z'), + ); const result = await runEffectTestPromise( service @@ -742,7 +725,7 @@ test('completes stopped recovery when a lost response leaves only an expired ori expect(result.active).toBe(false); expect(result.checkpointPending).toBe(false); expect(actionRuntime.invocationCount()).toBe(1); - expect(deleted).toBe(true); + expect(deleted()).toBe(true); expect(result.setCookieHeaders.every((header) => header.includes('Max-Age=0'))).toBe(true); }); diff --git a/app/apps/shell-super-app/tests/unit/installed-module-catalog.test.ts b/app/apps/shell-super-app/tests/unit/installed-module-catalog.test.ts index 9e898a4e9..2587d5c89 100644 --- a/app/apps/shell-super-app/tests/unit/installed-module-catalog.test.ts +++ b/app/apps/shell-super-app/tests/unit/installed-module-catalog.test.ts @@ -1,3 +1,4 @@ +import { makeModuleContractFixture } from '../../../../packages/core-runtime/src/testing/module-contract.ts'; import { runEffectTestPromise } from '@app/core-runtime/testing/effect-runtime'; // @effect-diagnostics asyncFunction:off preferSchemaOverJson:off -- Existing compatibility boundary; expires: 2026-12-31. import { expect, test } from '@rstest/core'; @@ -9,53 +10,20 @@ import { makeInstalledModuleCatalogLoader, } from '../../api/modules/installed-module-catalog.ts'; -const contract = (appId: string, moduleId: string) => ({ - deployment: { appId, buildMarker: `${appId}-build` }, - manifest: { - activation: { - defaultState: 'inactive', - preservesHistoryWhenInactive: true, - scope: 'tenant', - supportedStates: [ - 'inactive', - 'active', - 'read_only', - 'suspended', - 'quarantined', - 'deprecated', - 'archived', - ], - }, - module: { - description: `${moduleId} module`, - displayName: moduleId, - id: moduleId, - implementedAs: 'ultramodern_microvertical', - kind: 'business_module', - }, - publicSurface: { - actions: [], - api: [], - components: [], - events: [], - reports: [], - resourceTypes: [], - search: [], - shellContributions: { - mediaAttachments: [], - navigation: [], - pages: [], - publicComponents: [], - reports: [], - resourceDetails: [], - search: [], - timelines: [], - }, - }, - }, - runtime: { outboxSubscriptions: [] }, - schemaVersion: '2', -}); +const contract = (appId: string, moduleId: string) => + makeModuleContractFixture({ + appId, + moduleId, + supportedStates: [ + 'inactive', + 'active', + 'read_only', + 'suspended', + 'quarantined', + 'deprecated', + 'archived', + ], + }); const allowlist = (entries: DeploymentAllowlist['entries']): DeploymentAllowlist => Object.freeze({ entries: Object.freeze([...entries]), revision: JSON.stringify(entries) }); diff --git a/app/apps/shell-super-app/tests/unit/installed-outbox-matcher.test.ts b/app/apps/shell-super-app/tests/unit/installed-outbox-matcher.test.ts index 778f3c44d..d117af760 100644 --- a/app/apps/shell-super-app/tests/unit/installed-outbox-matcher.test.ts +++ b/app/apps/shell-super-app/tests/unit/installed-outbox-matcher.test.ts @@ -1,52 +1,12 @@ +import { makeModuleContractFixture } from '../../../../packages/core-runtime/src/testing/module-contract.ts'; import { runEffectTestPromise } from '@app/core-runtime/testing/effect-runtime'; import { expect, test } from '@rstest/core'; import { buildInstalledModuleCatalog } from '@app/core-runtime'; import { Effect } from 'effect'; import { matchInstalledOutboxMessagesOnce } from '../../api/modules/installed-outbox-matcher.ts'; -const contract = ( - appId: string, - moduleId: string, - outboxSubscriptions: readonly object[] = [], -) => ({ - deployment: { appId, buildMarker: `${appId}-build` }, - manifest: { - activation: { - defaultState: 'inactive', - preservesHistoryWhenInactive: true, - scope: 'tenant', - supportedStates: ['inactive', 'active'], - }, - module: { - description: `${moduleId} module`, - displayName: moduleId, - id: moduleId, - implementedAs: 'ultramodern_microvertical', - kind: 'business_module', - }, - publicSurface: { - actions: [], - api: [], - components: [], - events: [], - reports: [], - resourceTypes: [], - search: [], - shellContributions: { - mediaAttachments: [], - navigation: [], - pages: [], - publicComponents: [], - reports: [], - resourceDetails: [], - search: [], - timelines: [], - }, - }, - }, - runtime: { outboxSubscriptions }, - schemaVersion: '2', -}); +const contract = (appId: string, moduleId: string, outboxSubscriptions: readonly object[] = []) => + makeModuleContractFixture({ appId, moduleId, outboxSubscriptions }); test('passes a dormant subscription with an absent producer to Core matching', async () => { const subscription = { diff --git a/app/package.json b/app/package.json index b8daae1f6..95d510e01 100644 --- a/app/package.json +++ b/app/package.json @@ -24,7 +24,7 @@ "local:initialize": "node ./scripts/initialize-local-development.mts", "test:unit": "pnpm -r --if-present run test:unit && pnpm -r --if-present run test:component", "test:integration": "pnpm -r --if-present run test:integration", - "test:scripts": "node --test scripts/local-environment-values.test.mts scripts/tests/audit-database-trust-boundaries.test.mts scripts/tests/authorization-rollout-contract.test.mts scripts/tests/check-authorization-readiness.test.mts scripts/tests/database-access-boundaries.test.mts scripts/tests/initialize-local-development.test.mts scripts/tests/locki-feature.test.mts scripts/tests/migrate-contacts-authorization.test.mts scripts/tests/module-entrypoint-boundaries.test.mts scripts/tests/plan-deployment-impact.test.mts scripts/tests/protected-entrypoint-inventory.test.mts scripts/tests/provision-current-action-authorization.test.mts scripts/tests/report-fail-closed-authorization-impact.test.mts scripts/tests/api-only-tooling.test.mts scripts/tests/root-environment.test.mts scripts/tests/typecheck-project-references.test.mts", + "test:scripts": "node --test scripts/local-environment-values.test.mts scripts/tests/audit-database-trust-boundaries.test.mts scripts/tests/authorization-rollout-contract.test.mts scripts/tests/check-authorization-readiness.test.mts scripts/tests/database-access-boundaries.test.mts scripts/tests/initialize-local-development.test.mts scripts/tests/locki-feature.test.mts scripts/tests/migrate-contacts-authorization.test.mts scripts/tests/module-entrypoint-boundaries.test.mts scripts/tests/plan-deployment-impact.test.mts scripts/tests/protected-entrypoint-inventory.test.mts scripts/tests/provision-current-action-authorization.test.mts scripts/tests/report-fail-closed-authorization-impact.test.mts scripts/tests/api-only-tooling.test.mts scripts/tests/root-environment.test.mts scripts/tests/typecheck-project-references.test.mts scripts/tests/ultramodern-command.test.mts", "test:lint-rules": "node --test tools/oxlint/effect-native/tests/*.test.mts", "typecheck:lint-rules": "tsc -p tools/oxlint/effect-native/tsconfig.json", "lint:effect": "node tools/oxlint/effect-native/report.mts", @@ -89,9 +89,7 @@ }, "dependencies": { "@authzed/authzed-node": "1.6.1", - "@better-auth/drizzle-adapter": "1.7.2", "better-auth": "1.7.2", - "dotenv": "17.4.2", "drizzle-orm": "1.0.0-rc.5-ab785fc", "pg": "8.22.0", "@effect/sql-pg": "4.0.0-beta.107" @@ -111,16 +109,13 @@ "@typescript/native-preview": "npm:typescript@7.0.2", "effect": "4.0.0-beta.107", "esbuild": "0.28.1", - "gel": "2.2.0", "jose": "6.2.5", "lefthook": "^2.1.10", "miniflare": "4.20260708.1", "oxfmt": "0.64.0", "oxlint": "1.79.0", - "oxc-parser": "0.143.0", + "oxc-parser": "0.147.0", "ultracite": "7.10.7", - "wrangler": "4.110.0", - "zephyr-agent": "1.1.1", "@nkzw/eslint-plugin": "2.0.0", "eslint-plugin-github": "6.1.2", "eslint-plugin-perfectionist": "5.10.1", @@ -130,8 +125,7 @@ "knip": "6.34.0", "jscpd": "5.1.2", "jsonc-parser": "3.3.1", - "fallow": "3.22.0", - "oxc-parser": "0.147.0" + "fallow": "3.22.0" }, "engines": { "node": ">=26", diff --git a/app/packages/core-runtime/drizzle.config.ts b/app/packages/core-runtime/drizzle.config.ts index 9aef2210d..bd12a2738 100644 --- a/app/packages/core-runtime/drizzle.config.ts +++ b/app/packages/core-runtime/drizzle.config.ts @@ -1,33 +1,7 @@ -import { defineConfig } from 'drizzle-kit'; -import { Redacted, Result, Schema } from 'effect'; -import { APP_ENV_PATH } from './src/environment/workspace-environment.ts'; +import { defineWorkspaceDrizzleConfig } from './src/environment/drizzle-config.ts'; -const nodeFileSystem = process.getBuiltinModule('node:fs'); -const nodeProcess = process.getBuiltinModule('node:process'); -const nodeUtilities = process.getBuiltinModule('node:util'); -const fileConfig = nodeFileSystem.existsSync(APP_ENV_PATH) - ? Result.getOrThrow( - Result.try(() => nodeUtilities.parseEnv(nodeFileSystem.readFileSync(APP_ENV_PATH, 'utf-8'))), - ) - : {}; -const configValues = { ...fileConfig, ...nodeProcess.env }; -const databaseUrl = Redacted.value( - Result.getOrThrow( - Schema.decodeUnknownResult( - Schema.RedactedFromValue(Schema.Trim.pipe(Schema.check(Schema.isMinLength(1)))), - )(configValues['DATABASE_ADMIN_URL']), - ), -); - -export default defineConfig({ - dbCredentials: { - url: databaseUrl, - }, - dialect: 'postgresql', - migrations: { - schema: 'drizzle', - table: '__drizzle_migrations_core', - }, +export default defineWorkspaceDrizzleConfig({ out: './drizzle', schema: './src/db/schema.ts', + table: '__drizzle_migrations_core', }); diff --git a/app/packages/core-runtime/package.json b/app/packages/core-runtime/package.json index c61d1925a..2221b25fc 100644 --- a/app/packages/core-runtime/package.json +++ b/app/packages/core-runtime/package.json @@ -33,7 +33,6 @@ "@authzed/authzed-node": "1.6.1", "@effect/platform-node": "4.0.0-beta.107", "drizzle-orm": "1.0.0-rc.5-ab785fc", - "dotenv": "17.4.2", "effect": "4.0.0-beta.107", "pg": "8.22.0", "@effect/sql-pg": "4.0.0-beta.107" diff --git a/app/packages/core-runtime/scripts/verify-db-schema.mts b/app/packages/core-runtime/scripts/verify-db-schema.mts index 59ea1e9e7..8e2cea252 100644 --- a/app/packages/core-runtime/scripts/verify-db-schema.mts +++ b/app/packages/core-runtime/scripts/verify-db-schema.mts @@ -67,7 +67,7 @@ const verifyTypedQuery = ( Effect.asVoid, ); -const verifyDatabase = Effect.gen(function* verifyDatabaseEffect() { +const verifyRuntimeRole = Effect.gen(function* verifyRuntimeRoleEffect() { const database = yield* CoreDatabase; const runtimeRole = yield* database.executor .execute( @@ -90,7 +90,11 @@ const verifyDatabase = Effect.gen(function* verifyDatabaseEffect() { reason: 'The application runtime role must be non-superuser and must not bypass RLS', }); } + return yield* Effect.void; +}); +const verifySearchIsolation = Effect.gen(function* verifySearchIsolationEffect() { + const database = yield* CoreDatabase; for (const [tableName, operations] of [ ['search_index_entries', ['delete', 'insert', 'select', 'update']], ['search_projection_generations', ['insert', 'select', 'update']], @@ -144,7 +148,108 @@ const verifyDatabase = Effect.gen(function* verifyDatabaseEffect() { }); } } + return yield* Effect.void; +}); +const verifyCatalog = Effect.gen(function* verifyCatalogEffect() { + const database = yield* CoreDatabase; + // Necessary migration-verification exception: Drizzle has no typed builder + // for PostgreSQL catalog metadata. Values stay parameterized and the query is + // covered by exact-set mismatch tests. + const catalogResult = yield* database.executor + .execute( + sql` + with application_tables as ( + select + ${'table'}::text as kind, + namespace.nspname as schema_name, + relation.relname as table_name + from pg_catalog.pg_namespace as namespace + inner join pg_catalog.pg_class as relation + on relation.relnamespace = namespace.oid + where relation.relkind in (${'r'}, ${'p'}) + and namespace.nspname = ${CORE_SCHEMA_NAME} + ), + migration_bookkeeping as ( + select + ${'migration'}::text as kind, + namespace.nspname as schema_name, + relation.relname as table_name + from pg_catalog.pg_namespace as namespace + inner join pg_catalog.pg_class as relation + on relation.relnamespace = namespace.oid + where relation.relkind = ${'r'} + and namespace.nspname = ${'drizzle'} + and relation.relname = ${'__drizzle_migrations_core'} + ) + select kind, schema_name, table_name from application_tables + union all + select kind, schema_name, table_name from migration_bookkeeping + order by kind, schema_name, table_name + `, + 'objects', + ) + .pipe( + Effect.mapError( + () => + new DatabaseVerificationError({ + reason: 'Unable to compare the PostgreSQL application catalog', + }), + ), + ); + + const entries: CatalogEntry[] = []; + const migrationBookkeepingTables: string[] = []; + + for (const row of catalogResult) { + if (row.kind === 'migration') { + if (row.table_name !== null) { + migrationBookkeepingTables.push(row.table_name); + } + continue; + } + + if (row.table_name === null) { + return yield* new DatabaseVerificationError({ + reason: `Catalog table ${row.schema_name} is missing its table name`, + }); + } + + entries.push({ + kind: 'table', + schemaName: row.schema_name, + tableName: row.table_name, + }); + } + + const expectedMigrationBookkeepingTables = ['__drizzle_migrations_core']; + migrationBookkeepingTables.sort(); + + if ( + migrationBookkeepingTables.length !== expectedMigrationBookkeepingTables.length || + migrationBookkeepingTables.some( + (tableName, index) => tableName !== expectedMigrationBookkeepingTables[index], + ) + ) { + return yield* new DatabaseVerificationError({ + reason: `Expected Drizzle migration bookkeeping tables [${expectedMigrationBookkeepingTables.join(', ')}], found [${migrationBookkeepingTables.join(', ')}]`, + }); + } + + const difference = compareApplicationCatalog(entries); + + if (difference.missing.length > 0 || difference.unexpected.length > 0) { + return yield* new DatabaseVerificationError({ + reason: `Core catalog mismatch; missing=[${difference.missing.join(', ')}], unexpected=[${difference.unexpected.join(', ')}]`, + }); + } + return yield* Effect.void; +}); + +const verifyDatabase = Effect.gen(function* verifyDatabaseEffect() { + const database = yield* CoreDatabase; + yield* verifyRuntimeRole; + yield* verifySearchIsolation; const requiredCompositeConstraints = [ 'core_action_invocations_tenant_auth_binding_fk', 'core_action_invocations_tenant_impersonator_fk', @@ -263,96 +368,7 @@ const verifyDatabase = Effect.gen(function* verifyDatabaseEffect() { yield* query; } - // Necessary migration-verification exception: Drizzle has no typed builder - // for PostgreSQL catalog metadata. Values stay parameterized and the query is - // covered by exact-set mismatch tests. - const catalogResult = yield* database.executor - .execute( - sql` - with application_tables as ( - select - ${'table'}::text as kind, - namespace.nspname as schema_name, - relation.relname as table_name - from pg_catalog.pg_namespace as namespace - inner join pg_catalog.pg_class as relation - on relation.relnamespace = namespace.oid - where relation.relkind in (${'r'}, ${'p'}) - and namespace.nspname = ${CORE_SCHEMA_NAME} - ), - migration_bookkeeping as ( - select - ${'migration'}::text as kind, - namespace.nspname as schema_name, - relation.relname as table_name - from pg_catalog.pg_namespace as namespace - inner join pg_catalog.pg_class as relation - on relation.relnamespace = namespace.oid - where relation.relkind = ${'r'} - and namespace.nspname = ${'drizzle'} - and relation.relname = ${'__drizzle_migrations_core'} - ) - select kind, schema_name, table_name from application_tables - union all - select kind, schema_name, table_name from migration_bookkeeping - order by kind, schema_name, table_name - `, - 'objects', - ) - .pipe( - Effect.mapError( - () => - new DatabaseVerificationError({ - reason: 'Unable to compare the PostgreSQL application catalog', - }), - ), - ); - - const entries: CatalogEntry[] = []; - const migrationBookkeepingTables: string[] = []; - - for (const row of catalogResult) { - if (row.kind === 'migration') { - if (row.table_name !== null) { - migrationBookkeepingTables.push(row.table_name); - } - continue; - } - - if (row.table_name === null) { - return yield* new DatabaseVerificationError({ - reason: `Catalog table ${row.schema_name} is missing its table name`, - }); - } - - entries.push({ - kind: 'table', - schemaName: row.schema_name, - tableName: row.table_name, - }); - } - - const expectedMigrationBookkeepingTables = ['__drizzle_migrations_core']; - migrationBookkeepingTables.sort(); - - if ( - migrationBookkeepingTables.length !== expectedMigrationBookkeepingTables.length || - migrationBookkeepingTables.some( - (tableName, index) => tableName !== expectedMigrationBookkeepingTables[index], - ) - ) { - return yield* new DatabaseVerificationError({ - reason: `Expected Drizzle migration bookkeeping tables [${expectedMigrationBookkeepingTables.join(', ')}], found [${migrationBookkeepingTables.join(', ')}]`, - }); - } - - const difference = compareApplicationCatalog(entries); - - if (difference.missing.length > 0 || difference.unexpected.length > 0) { - return yield* new DatabaseVerificationError({ - reason: `Core catalog mismatch; missing=[${difference.missing.join(', ')}], unexpected=[${difference.unexpected.join(', ')}]`, - }); - } + yield* verifyCatalog; return { tableCount: typedQueries.length, diff --git a/app/packages/core-runtime/src/actions/collector.ts b/app/packages/core-runtime/src/actions/collector.ts index c8652a2f7..a8af4848a 100644 --- a/app/packages/core-runtime/src/actions/collector.ts +++ b/app/packages/core-runtime/src/actions/collector.ts @@ -73,13 +73,33 @@ const redactedPayloadPolicyFields = ( redactionProfile: policy.redactionProfile, }) as const; +const hasIncompleteRedactedEvidence = (event: DataAccessEvent): boolean => + event.evidenceCaptureMode === 'redacted_payload' && + (event.redactionProfile === undefined || event.evidencePayloadJson === undefined); + +const hasUnexpectedRedactionProfile = (event: DataAccessEvent): boolean => + event.evidenceCaptureMode !== 'redacted_payload' && event.redactionProfile !== undefined; + +const hasMetadataResultEvidence = (event: DataAccessEvent): boolean => + event.evidenceCaptureMode === 'metadata_only' && + (event.evidencePayloadJson !== undefined || + event.resultFingerprintHash !== undefined || + event.resultFingerprintSchema !== undefined); + +const hasInvalidHashEvidence = (event: DataAccessEvent): boolean => + event.evidenceCaptureMode === 'hash_only' && + (event.evidencePayloadJson !== undefined || + (event.resultFingerprintHash === undefined) !== (event.resultFingerprintSchema === undefined)); + +const hasUnsupportedResultEvidence = (event: DataAccessEvent): boolean => + event.evidenceCaptureMode === 'stored_artifact' || + (event.evidenceCaptureMode === 'redacted_payload' && + (event.resultFingerprintHash !== undefined || event.resultFingerprintSchema !== undefined)); + const validateDataAccessInvariant = ( event: DataAccessEvent, ): Effect.Effect => { - if ( - event.evidenceCaptureMode === 'redacted_payload' && - (event.redactionProfile === undefined || event.evidencePayloadJson === undefined) - ) { + if (hasIncompleteRedactedEvidence(event)) { return Effect.fail( invalidCollectorInput( 'A redacted Data Access Event requires a redaction profile and evidence payload', @@ -87,28 +107,19 @@ const validateDataAccessInvariant = ( ); } - if (event.evidenceCaptureMode !== 'redacted_payload' && event.redactionProfile !== undefined) { + if (hasUnexpectedRedactionProfile(event)) { return Effect.fail( invalidCollectorInput('A redaction profile is allowed only for redacted Data Access Events'), ); } - if ( - event.evidenceCaptureMode === 'metadata_only' && - (event.evidencePayloadJson !== undefined || - event.resultFingerprintHash !== undefined || - event.resultFingerprintSchema !== undefined) - ) { + if (hasMetadataResultEvidence(event)) { return Effect.fail( invalidCollectorInput('Metadata-only Data Access evidence cannot contain result evidence'), ); } - if ( - event.evidenceCaptureMode === 'hash_only' && - (event.evidencePayloadJson !== undefined || - (event.resultFingerprintHash === undefined) !== (event.resultFingerprintSchema === undefined)) - ) { + if (hasInvalidHashEvidence(event)) { return Effect.fail( invalidCollectorInput( 'Hash-only Data Access evidence requires a paired result fingerprint and schema', @@ -116,11 +127,7 @@ const validateDataAccessInvariant = ( ); } - if ( - event.evidenceCaptureMode === 'stored_artifact' || - (event.evidenceCaptureMode === 'redacted_payload' && - (event.resultFingerprintHash !== undefined || event.resultFingerprintSchema !== undefined)) - ) { + if (hasUnsupportedResultEvidence(event)) { return Effect.fail( invalidCollectorInput('The Action runtime does not accept this result evidence shape'), ); diff --git a/app/packages/core-runtime/src/actions/definition.ts b/app/packages/core-runtime/src/actions/definition.ts index 584c143a0..d8e825537 100644 --- a/app/packages/core-runtime/src/actions/definition.ts +++ b/app/packages/core-runtime/src/actions/definition.ts @@ -42,13 +42,13 @@ class ActionPrivateStorage { } } -export const ActionIdempotencyRuleSchema = Schema.Literals(['optional', 'required']); +const ActionIdempotencyRuleSchema = Schema.Literals(['optional', 'required']); export type ActionIdempotencyRule = typeof ActionIdempotencyRuleSchema.Type; -export const ActionAuditProfileSchema = Schema.Literals(['minimal', 'sensitive', 'standard']); +const ActionAuditProfileSchema = Schema.Literals(['minimal', 'sensitive', 'standard']); export type ActionAuditProfile = typeof ActionAuditProfileSchema.Type; export type ActionTenantPermission = Exclude; export type ActionLegalEntityPermission = 'manage_counterparty'; -export const ActionResourcePermissionSchema = Schema.Literals(['read', 'write']); +const ActionResourcePermissionSchema = Schema.Literals(['read', 'write']); export type ActionResourcePermission = typeof ActionResourcePermissionSchema.Type; export interface ActionResourcePermissionTarget { readonly permission: ActionResourcePermission; @@ -257,7 +257,7 @@ export interface ActionDescriptorValidationInput { readonly tenantPermission?: unknown; } -export const validateActionDescriptorInput = ( +const validateActionEntrypoint = ( descriptor: ActionDescriptorValidationInput, ): void => { if ( @@ -272,6 +272,10 @@ export const validateActionDescriptorInput = ( 'Action entrypoint must be an immutable action/write descriptor with the required owner scope', ); } +}; +const validateActionLegalEntityScope = ( + descriptor: ActionDescriptorValidationInput, +): void => { if (!LEGAL_ENTITY_SCOPES.some((scope) => scope === descriptor.legalEntityScope)) { return failActionDefinition( 'Action legal-entity scope must be required, optional, or forbidden', @@ -286,11 +290,10 @@ export const validateActionDescriptorInput = ( 'Action Legal Entity permission must be supported and require trusted Legal Entity scope', ); } - if (!Array.isArray(descriptor.policies)) { - return failActionDefinition( - 'Action policies must be an explicit readonly array of Policy references', - ); - } +}; +const validateActionPermissions = ( + descriptor: ActionDescriptorValidationInput, +): void => { if ( (descriptor.resourcePermission !== undefined && !Schema.is(ActionResourcePermissionDeclarationSchema)(descriptor.resourcePermission)) || @@ -299,7 +302,18 @@ export const validateActionDescriptorInput = ( ) { return failActionDefinition('Action permission declarations and resolvers must be valid'); } - for (const policy of descriptor.policies) { +}; +const validateActionPolicies = ( + descriptor: ActionDescriptorValidationInput, + policies: readonly Policy[] | undefined, +): void => { + if (!Array.isArray(policies)) { + return failActionDefinition( + 'Action policies must be an explicit readonly array of Policy references', + ); + } + validateActionPermissions(descriptor); + for (const policy of policies) { if (!isActionPolicy(policy)) { return failActionDefinition('Action policies must contain direct Policy object references'); } @@ -310,6 +324,13 @@ export const validateActionDescriptorInput = ( } } }; +export const validateActionDescriptorInput = ( + descriptor: ActionDescriptorValidationInput, +): void => { + validateActionEntrypoint(descriptor); + validateActionLegalEntityScope(descriptor); + validateActionPolicies(descriptor, descriptor.policies); +}; export function defineAction< PayloadSchema extends Schema.ConstraintDecoder, diff --git a/app/packages/core-runtime/src/actions/repository.ts b/app/packages/core-runtime/src/actions/repository.ts index e7c6f737b..d72b133f9 100644 --- a/app/packages/core-runtime/src/actions/repository.ts +++ b/app/packages/core-runtime/src/actions/repository.ts @@ -87,6 +87,47 @@ const compareCodeUnits = (left: string, right: string): number => { return 0; }; +const normalizeObjectForHash = ( + value: Value, + seen: WeakSet, + normalize: (value: Item, seen: WeakSet) => CanonicalValue, +): CanonicalValue => { + if (Array.isArray(value)) { + if (seen.has(value)) { + throw new CanonicalValueError({ + reason: 'Action payloads must not contain cyclic values', + }); + } + seen.add(value); + const normalized = value.map((item) => normalize(item, seen)); + seen.delete(value); + return ['array', normalized]; + } + if (seen.has(value)) { + throw new CanonicalValueError({ + reason: 'Action payloads must not contain cyclic values', + }); + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new CanonicalValueError({ + reason: 'Action payloads must contain only canonical data values', + }); + } + seen.add(value); + const entries = Object.entries(value) + .toSorted(([left], [right]) => compareCodeUnits(left, right)) + .map(([key, item]) => [key, normalize(item, seen)] as const); + seen.delete(value); + return ['object', entries]; +}; + +const normalizeNumberForHash = (value: number): CanonicalValue => [ + 'number', + Object.is(value, -0) ? '-0' : String(value), +]; +const isCanonicalDate = Schema.is(Schema.instanceOf(Date)); + const normalizeForHash = (value: Value, seen: WeakSet): CanonicalValue => { if (value === undefined) { return ['undefined']; @@ -101,43 +142,16 @@ const normalizeForHash = (value: Value, seen: WeakSet): Canonical return ['string', value]; } if (Predicate.isNumber(value)) { - return ['number', Object.is(value, -0) ? '-0' : String(value)]; + return normalizeNumberForHash(value); } if (Predicate.isBigInt(value)) { return ['bigint', value.toString(10)]; } - if (value instanceof Date) { + if (isCanonicalDate(value)) { return ['date', value.toISOString()]; } - if (Array.isArray(value)) { - if (seen.has(value)) { - throw new CanonicalValueError({ - reason: 'Action payloads must not contain cyclic values', - }); - } - seen.add(value); - const normalized = value.map((item) => normalizeForHash(item, seen)); - seen.delete(value); - return ['array', normalized]; - } if (Predicate.isObjectKeyword(value)) { - if (seen.has(value)) { - throw new CanonicalValueError({ - reason: 'Action payloads must not contain cyclic values', - }); - } - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) { - throw new CanonicalValueError({ - reason: 'Action payloads must contain only canonical data values', - }); - } - seen.add(value); - const entries = Object.entries(value) - .toSorted(([left], [right]) => compareCodeUnits(left, right)) - .map(([key, item]) => [key, normalizeForHash(item, seen)] as const); - seen.delete(value); - return ['object', entries]; + return normalizeObjectForHash(value, seen, normalizeForHash); } const unsupportedKind = Predicate.isFunction(value) ? 'function' : 'symbol'; throw new CanonicalValueError({ @@ -145,6 +159,24 @@ const normalizeForHash = (value: Value, seen: WeakSet): Canonical }); }; +const markInvocationRejected = Effect.fnUntraced(function* markInvocationRejected( + transaction: CoreTransaction, + actionInvocationId: string, +) { + const completedAt = yield* DateTime.nowAsDate; + return yield* transaction + .update(actionInvocations) + .set({ completedAt, status: 'rejected' }) + .where( + and( + eq(actionInvocations.actionInvocationId, actionInvocationId), + eq(actionInvocations.status, 'received'), + isNull(actionInvocations.completedAt), + ), + ) + .returning({ actionInvocationId: actionInvocations.actionInvocationId }); +}); + export const computeActionRequestHash = (input: ActionRequestHashInput): string => { const canonicalEnvelope = normalizeForHash( { @@ -178,7 +210,7 @@ export interface PrepareActionInvocationInput { readonly transport: ActionTransportMetadata; } -export interface ResolveActionInvocationInput { +interface ResolveActionInvocationInput { readonly invocationId: string; readonly principal: TrustedPrincipalContext; } @@ -567,19 +599,9 @@ export const makeActionRepository = (): ActionRepositoryService => { }) .pipe(Effect.mapError((cause) => transactionFailure(failureReason, cause))); - const completedAt = yield* DateTime.nowAsDate; - const rejected = yield* transaction - .update(actionInvocations) - .set({ completedAt, status: 'rejected' }) - .where( - and( - eq(actionInvocations.actionInvocationId, input.actionInvocationId), - eq(actionInvocations.status, 'received'), - isNull(actionInvocations.completedAt), - ), - ) - .returning({ actionInvocationId: actionInvocations.actionInvocationId }) - .pipe(Effect.mapError((cause) => transactionFailure(failureReason, cause))); + const rejected = yield* markInvocationRejected(transaction, input.actionInvocationId).pipe( + Effect.mapError((cause) => transactionFailure(failureReason, cause)), + ); if (rejected.length !== 1) { return yield* transactionFailure( failureReason, @@ -693,19 +715,9 @@ export const makeActionRepository = (): ActionRepositoryService => { ]) .pipe(Effect.mapError((cause) => persistenceFailure(failureReason, cause))); - const completedAt = yield* DateTime.nowAsDate; - const rejected = yield* transaction - .update(actionInvocations) - .set({ completedAt, status: 'rejected' }) - .where( - and( - eq(actionInvocations.actionInvocationId, input.actionInvocationId), - eq(actionInvocations.status, 'received'), - isNull(actionInvocations.completedAt), - ), - ) - .returning({ actionInvocationId: actionInvocations.actionInvocationId }) - .pipe(Effect.mapError((cause) => persistenceFailure(failureReason, cause))); + const rejected = yield* markInvocationRejected(transaction, input.actionInvocationId).pipe( + Effect.mapError((cause) => persistenceFailure(failureReason, cause)), + ); if (rejected.length !== 1) { return yield* persistenceFailure( failureReason, diff --git a/app/packages/core-runtime/src/actions/runtime.ts b/app/packages/core-runtime/src/actions/runtime.ts index 10f33d428..2da9ab2dd 100644 --- a/app/packages/core-runtime/src/actions/runtime.ts +++ b/app/packages/core-runtime/src/actions/runtime.ts @@ -72,6 +72,16 @@ import { logActionTransactionFailureCause, } from './repository.ts'; +const requireIdempotencyKey = (idempotency: string, transport: ActionTransportMetadata) => + idempotency === 'required' && transport.idempotencyKey === undefined + ? Effect.fail( + new ActionIdempotencyKeyRequired({ + code: 'action_idempotency_key_required', + reason: 'This Action requires an idempotency key', + }), + ) + : Effect.void; + const withOptionalProperty = < Base extends object, Key extends PropertyKey, @@ -143,7 +153,7 @@ const ActionInvocationIdSchema = Schema.String.check(Schema.isUUID()).pipe( Schema.brand('ActionInvocationId'), ); -export const ActionCommitOpenSchema = Schema.TaggedStruct('ActionCommitOpen', { +const ActionCommitOpenSchema = Schema.TaggedStruct('ActionCommitOpen', { invocationId: ActionInvocationIdSchema, }); @@ -580,15 +590,7 @@ export const makeActionRuntime = ( ); notifyStage('module_state_gate'); - if ( - input.registration.descriptor.idempotency === 'required' && - transport.idempotencyKey === undefined - ) { - return yield* new ActionIdempotencyKeyRequired({ - code: 'action_idempotency_key_required', - reason: 'This Action requires an idempotency key', - }); - } + yield* requireIdempotencyKey(input.registration.descriptor.idempotency, transport); const tenantPermission = isTrustedSupportRecoveryPrincipalContext( principal, diff --git a/app/packages/core-runtime/src/auth/legal-entity-context.ts b/app/packages/core-runtime/src/auth/legal-entity-context.ts index 63b076e66..186b31f50 100644 --- a/app/packages/core-runtime/src/auth/legal-entity-context.ts +++ b/app/packages/core-runtime/src/auth/legal-entity-context.ts @@ -153,9 +153,6 @@ interface LegalEntityContextRecordReader Result; } -export type LegalEntityContextRepositoryService = - LegalEntityContextRecordReader; - const attachCause = ( failure: Failure, cause?: FailureCause, @@ -201,7 +198,7 @@ const legalEntityContextRepositoryFromDatabase = (database: { ), }); -export const legalEntityContextFromRepository = ( +const legalEntityContextFromRepository = ( repository: LegalEntityContextRecordReader, ): LegalEntityContextService => { const loadRecords = ( diff --git a/app/packages/core-runtime/src/auth/principal-management.ts b/app/packages/core-runtime/src/auth/principal-management.ts index d4119aeab..0d128b2ff 100644 --- a/app/packages/core-runtime/src/auth/principal-management.ts +++ b/app/packages/core-runtime/src/auth/principal-management.ts @@ -279,6 +279,17 @@ export interface ChangePrincipalStatusInput { readonly tenantId: string; } +const hasStatusChangeReason = (reason: string | undefined): boolean => + reason !== undefined && reason.trim().length > 0; + +const principalTransitionAllowed = (current: PrincipalStatus, next: PrincipalStatus): boolean => + (current === 'active' && ['disabled', 'archived'].includes(next)) || + (current === 'disabled' && ['active', 'archived'].includes(next)); + +const bindingTransitionAllowed = (current: BindingStatus, next: BindingStatus): boolean => + (current === 'active' && ['disabled', 'revoked'].includes(next)) || + (current === 'disabled' && ['active', 'revoked'].includes(next)); + const changePrincipalStatusFor = (persistence: PrincipalManagementPersistence) => Effect.fn('PrincipalManagement.changePrincipalStatus')(function* changeStatus( input: ChangePrincipalStatusInput, @@ -293,15 +304,10 @@ const changePrincipalStatusFor = (persistence: PrincipalManagementPersistence) = if (target.value.status === 'archived' || input.newStatus === target.value.status) { return yield* conflict('The principal status transition is not allowed'); } - if ( - input.newStatus !== 'active' && - (input.reason === undefined || input.reason.trim().length === 0) - ) { + if (input.newStatus !== 'active' && !hasStatusChangeReason(input.reason)) { return yield* invalid('A reason is required for disable or archive'); } - const allowed = - (target.value.status === 'active' && ['disabled', 'archived'].includes(input.newStatus)) || - (target.value.status === 'disabled' && ['active', 'archived'].includes(input.newStatus)); + const allowed = principalTransitionAllowed(target.value.status, input.newStatus); if (!allowed) { return yield* conflict('The principal status transition is not allowed'); } @@ -387,6 +393,15 @@ const validateSupportImpersonationFor = (persistence: PrincipalManagementPersist }, ); +const isEligibleBindingTarget = ( + managed: boolean, + status: PrincipalStatus, + kind: PrincipalKind, +): boolean => { + const allowedKinds: readonly PrincipalKind[] = managed ? ['service', 'integration'] : ['human']; + return status === 'active' && allowedKinds.includes(kind); +}; + const setApiKeyBindingStatusFor = (persistence: PrincipalManagementPersistence) => Effect.fn('PrincipalManagement.setApiKeyBindingStatus')(function* setBindingStatus( input: SetApiKeyBindingStatusInput, @@ -395,12 +410,12 @@ const setApiKeyBindingStatusFor = (persistence: PrincipalManagementPersistence) if (Option.isNone(binding)) { return yield* invalid('The API key binding is unavailable'); } - const allowedKinds: readonly PrincipalKind[] = input.managed - ? ['service', 'integration'] - : ['human']; if ( - binding.value.principalStatus !== 'active' || - !allowedKinds.includes(binding.value.principalKind) + !isEligibleBindingTarget( + input.managed, + binding.value.principalStatus, + binding.value.principalKind, + ) ) { return yield* invalid('The API key binding target is not eligible'); } @@ -413,17 +428,10 @@ const setApiKeyBindingStatusFor = (persistence: PrincipalManagementPersistence) ) { return yield* conflict('The binding transition is not allowed'); } - if ( - input.newStatus === 'revoked' && - (input.reason === undefined || input.reason.trim().length === 0) - ) { + if (input.newStatus === 'revoked' && !hasStatusChangeReason(input.reason)) { return yield* invalid('A reason is required for revocation'); } - const allowed = - (binding.value.bindingStatus === 'active' && - ['disabled', 'revoked'].includes(input.newStatus)) || - (binding.value.bindingStatus === 'disabled' && - ['active', 'revoked'].includes(input.newStatus)); + const allowed = bindingTransitionAllowed(binding.value.bindingStatus, input.newStatus); if (!allowed) { return yield* conflict('The binding transition is not allowed'); } diff --git a/app/packages/core-runtime/src/auth/principal-resolver.ts b/app/packages/core-runtime/src/auth/principal-resolver.ts index 6ca962eac..a7d42212d 100644 --- a/app/packages/core-runtime/src/auth/principal-resolver.ts +++ b/app/packages/core-runtime/src/auth/principal-resolver.ts @@ -88,7 +88,7 @@ interface PrincipalResolutionRecordReader Result; } -export type PrincipalResolutionRecordRepository = +type PrincipalResolutionRecordRepository = PrincipalResolutionRecordReader; const attachCause = (failure: Failure, cause: unknown): Failure => @@ -186,9 +186,7 @@ export const classifyAvailableTenants = ( ), ); -export const listAvailableTenantsFromRepository = < - Result extends PrincipalResolutionRecordLoadResult, ->( +const listAvailableTenantsFromRepository = ( repository: PrincipalResolutionRecordReader, betterAuthUserId: string, ): Effect.Effect => diff --git a/app/packages/core-runtime/src/auth/support-recovery-principal-context.ts b/app/packages/core-runtime/src/auth/support-recovery-principal-context.ts index 3f313a6ba..47dc98b11 100644 --- a/app/packages/core-runtime/src/auth/support-recovery-principal-context.ts +++ b/app/packages/core-runtime/src/auth/support-recovery-principal-context.ts @@ -52,9 +52,6 @@ interface SupportRecoveryPrincipalContextRecordReader< readonly load: (input: SupportRecoveryPrincipalContextRepositoryInput) => Result; } -export type SupportRecoveryPrincipalContextRepositoryService = - SupportRecoveryPrincipalContextRecordReader; - interface SupportRecoveryPrincipalContextEffectRecordReader { readonly load: ( input: SupportRecoveryPrincipalContextRepositoryInput, @@ -119,6 +116,18 @@ const supportRecoveryPrincipalContextRepositoryFromDatabase = (database: { ), }); +const isInvalidRecoveryInput = ( + input: Parameters< + SupportRecoveryPrincipalContextResolverService['resolveStoppedImpersonation'] + >[0], +): boolean => + !Schema.is(uuid)(input.originalAuthBindingId) || + !Schema.is(uuid)(input.originalPrincipalId) || + !Schema.is(uuid)(input.tenantId) || + input.originalSessionId.length === 0 || + input.originalSessionId.length > 280 || + /\s/u.test(input.originalSessionId); + const supportRecoveryPrincipalContextResolverFromEffectRecordReader = ( repository: SupportRecoveryPrincipalContextEffectRecordReader, ): SupportRecoveryPrincipalContextResolverService => ({ @@ -128,14 +137,7 @@ const supportRecoveryPrincipalContextResolverFromEffectRecordReader = ( TrustedPrincipalContext, SupportRecoveryPrincipalContextError > { - if ( - !Schema.is(uuid)(input.originalAuthBindingId) || - !Schema.is(uuid)(input.originalPrincipalId) || - !Schema.is(uuid)(input.tenantId) || - input.originalSessionId.length === 0 || - input.originalSessionId.length > 280 || - /\s/u.test(input.originalSessionId) - ) { + if (isInvalidRecoveryInput(input)) { return yield* new SupportRecoveryPrincipalContextDeniedError({ code: 'support_recovery_context_denied', reason: 'The support recovery identity is invalid', diff --git a/app/packages/core-runtime/src/auth/system-principal-context.ts b/app/packages/core-runtime/src/auth/system-principal-context.ts index 7dcc1abdb..8babe4471 100644 --- a/app/packages/core-runtime/src/auth/system-principal-context.ts +++ b/app/packages/core-runtime/src/auth/system-principal-context.ts @@ -68,9 +68,6 @@ interface SystemPrincipalContextRecordReader< readonly load: (input: { readonly principalId: string; readonly tenantId: string }) => Result; } -export type SystemPrincipalContextRepositoryService = - SystemPrincipalContextRecordReader; - const attachCause = (failure: Failure, cause: unknown): Failure => cause === undefined ? failure : Object.defineProperty(failure, 'cause', { value: cause }); @@ -121,6 +118,15 @@ const systemPrincipalContextRepositoryFromDatabase = (database: { ), }); +const isEligibleSystemPrincipal = ( + record: SystemPrincipalContextRecord, + registration: SystemWorkloadRegistration, +): boolean => { + const kindAllowed = + record.kind === 'system' || (registration.allowServicePrincipal && record.kind === 'service'); + return record.principalStatus === 'active' && record.tenantStatus === 'active' && kindAllowed; +}; + export const systemPrincipalContextResolverFromRepository = < Result extends SystemPrincipalContextRepositoryLoadResult, >( @@ -155,10 +161,7 @@ export const systemPrincipalContextResolverFromRepository = < }); } const record = maybeRecord.value; - const kindAllowed = - record.kind === 'system' || - (input.registration.allowServicePrincipal && record.kind === 'service'); - if (record.principalStatus !== 'active' || record.tenantStatus !== 'active' || !kindAllowed) { + if (!isEligibleSystemPrincipal(record, input.registration)) { return yield* new SystemPrincipalContextDeniedError({ code: 'system_principal_context_denied', reason: 'The configured system principal is not active and eligible in this tenant', diff --git a/app/packages/core-runtime/src/authorization/rollout-decision.ts b/app/packages/core-runtime/src/authorization/rollout-decision.ts index dbe7253fb..dae1523dc 100644 --- a/app/packages/core-runtime/src/authorization/rollout-decision.ts +++ b/app/packages/core-runtime/src/authorization/rollout-decision.ts @@ -2,9 +2,9 @@ import { DateTime, Schema } from 'effect'; export const AUTHORIZATION_WOULD_DENY_SCHEMA_VERSION = 1 as const; -export const AuthorizationRolloutModeSchema = Schema.Literals(['enforced', 'report_only']); +const AuthorizationRolloutModeSchema = Schema.Literals(['enforced', 'report_only']); export type AuthorizationRolloutMode = typeof AuthorizationRolloutModeSchema.Type; -export const AuthorizationDenialReasonSchema = Schema.Literals([ +const AuthorizationDenialReasonSchema = Schema.Literals([ 'cross_tenant', 'expired_credential', 'infrastructure_unavailable', @@ -62,6 +62,14 @@ const nonBypassableReasons = new Set([ 'wrong_audience', ]); +const isReportOnlyActive = ( + contract: AuthorizationRolloutRuntimeContract, + nowEpochMs: number, +): boolean => + contract.mode === 'report_only' && + nowEpochMs >= contract.activatedAtEpochMs && + nowEpochMs < contract.expiresAtEpochMs; + export const decideAuthorizationRollout = ( input: AuthorizationRolloutDecisionInput, options: AuthorizationRolloutDecisionOptions, @@ -76,10 +84,7 @@ export const decideAuthorizationRollout = ( if (nonBypassableReasons.has(reason)) { return 'denied'; } - const active = - options.contract.mode === 'report_only' && - input.nowEpochMs >= options.contract.activatedAtEpochMs && - input.nowEpochMs < options.contract.expiresAtEpochMs; + const active = isReportOnlyActive(options.contract, input.nowEpochMs); const compatible = active && input.current === 'allowed' && diff --git a/app/packages/core-runtime/src/database/postgres-failure.ts b/app/packages/core-runtime/src/database/postgres-failure.ts index e2ed7419b..74e6adf4f 100644 --- a/app/packages/core-runtime/src/database/postgres-failure.ts +++ b/app/packages/core-runtime/src/database/postgres-failure.ts @@ -14,6 +14,27 @@ const decodePostgresFailureCode = Schema.decodeUnknownOption(PostgresFailureCode const decodePostgresFailureConstraint = Schema.decodeUnknownOption(PostgresFailureConstraintSchema); const decodeCauseWrapper = Schema.decodeUnknownOption(CauseWrapperSchema); +const enqueueFailureReasons = (cause: Cause.Cause, pending: unknown[]): void => { + for (const reason of cause.reasons.toReversed()) { + if (Cause.isFailReason(reason)) { + pending.push(reason.error); + } else if (Cause.isDieReason(reason)) { + pending.push(reason.defect); + } + } +}; + +const decodeFailureMetadata = ( + current: PostgresFailureInput, +): Option.Option> => + Option.map(decodePostgresFailureCode(current), ({ code }) => { + const constraint = decodePostgresFailureConstraint(current); + const metadata: PostgresFailureMetadata = Option.isSome(constraint) + ? { code, constraint: constraint.value.constraint } + : { code }; + return Object.freeze(metadata); + }); + /** * Finds sanitized technical PostgreSQL metadata without assigning it domain meaning. * PostgreSQL code 23505 is a uniqueness signal, not a universal public conflict. @@ -32,24 +53,11 @@ export const findPostgresFailure = ( } visited.add(current); if (Cause.isCause(current)) { - for (const reason of current.reasons.toReversed()) { - if (Cause.isFailReason(reason)) { - pending.push(reason.error); - } else if (Cause.isDieReason(reason)) { - pending.push(reason.defect); - } - } + enqueueFailureReasons(current, pending); } else { - const code = decodePostgresFailureCode(current); - if (Option.isSome(code)) { - const constraint = decodePostgresFailureConstraint(current); - const metadata: PostgresFailureMetadata = Option.isSome(constraint) - ? { code: code.value.code, constraint: constraint.value.constraint } - : { code: code.value.code }; - const sanitizedMetadata = Object.freeze(metadata); - if (predicate(sanitizedMetadata)) { - return Option.some(sanitizedMetadata); - } + const metadata = Option.filter(decodeFailureMetadata(current), predicate); + if (Option.isSome(metadata)) { + return metadata; } const wrapper = decodeCauseWrapper(current); diff --git a/app/packages/core-runtime/src/db/client.ts b/app/packages/core-runtime/src/db/client.ts index 549203153..7a93119c6 100644 --- a/app/packages/core-runtime/src/db/client.ts +++ b/app/packages/core-runtime/src/db/client.ts @@ -14,7 +14,6 @@ import { coreRelations } from './schema.ts'; import type { CoreDatabaseExecutor } from './types.ts'; export { DatabaseConnectionError } from './connection-error.ts'; -export { DEFAULT_DATABASE_POOL_DEADLINES } from './pool-configuration.ts'; export type { DatabasePoolDeadlines } from './pool-configuration.ts'; export class CoreDatabase extends Context.Service< diff --git a/app/packages/core-runtime/src/db/config.ts b/app/packages/core-runtime/src/db/config.ts index 7627aab7c..9a487cdad 100644 --- a/app/packages/core-runtime/src/db/config.ts +++ b/app/packages/core-runtime/src/db/config.ts @@ -1,14 +1,5 @@ -import { - Config, - ConfigProvider, - Context, - Effect, - Layer, - Match, - Predicate, - Redacted, - Schema, -} from 'effect'; +import { Config, ConfigProvider, Context, Effect, Layer, Redacted, Schema } from 'effect'; +import { loadDotEnvProvider } from '../environment/dotenv-provider.ts'; import { APP_ENV_PATH } from '../environment/workspace-environment.ts'; import { DatabaseConfigError } from './config-error.ts'; @@ -70,6 +61,19 @@ interface ReadDatabaseUrlOptions { readonly requiredReason: string; } +const hasValidDatabaseFields = ({ + database, + host, + port, + user, +}: Omit): boolean => + database.length > 0 && + host.length > 0 && + Number.isSafeInteger(port) && + port >= 1 && + port <= 65_535 && + user.length > 0; + const readDatabaseUrl = Effect.fn('Config.readDatabaseUrl')(function* readDatabaseUrlEffect( options: ReadDatabaseUrlOptions, ) { @@ -100,14 +104,7 @@ const readDatabaseUrl = Effect.fn('Config.readDatabaseUrl')(function* readDataba const user = queryUser === undefined || queryUser.length === 0 ? decoded.authorityUser : queryUser; - if ( - decoded.database.length === 0 || - host.length === 0 || - !Number.isSafeInteger(port) || - port < 1 || - port > 65_535 || - user.length === 0 - ) { + if (!hasValidDatabaseFields({ database: decoded.database, host, port, user })) { return yield* configFailure(INVALID_DATABASE_URL_REASON); } @@ -167,41 +164,6 @@ export const parseDatabaseConnectionPair = ( ConfigProvider.fromUnknown(environment, { preserveEmptyStrings: true }), ); -const nodeFileSystem = process.getBuiltinModule('node:fs'); - -const loadDotEnvProvider = Effect.fn('Config.loadDotEnvProvider')(function* loadProvider( - envPath: string, -) { - const result = yield* Effect.sync(() => { - try { - return { - contents: nodeFileSystem.readFileSync(envPath, 'utf-8'), - status: 'loaded', - } as const; - } catch (error) { - if ( - Predicate.hasProperty(error, 'code') && - (error.code === 'ENOENT' || error.code === 'NOT_FOUND_DOTENV_ENVIRONMENT') - ) { - return { status: 'missing' } as const; - } - return { - error: configFailure(`Unable to load the root environment from ${envPath}`, error), - status: 'failed', - } as const; - } - }); - - return yield* Match.value(result).pipe( - Match.discriminatorsExhaustive('status')({ - failed: ({ error }) => Effect.fail(error), - loaded: ({ contents }) => - Effect.succeed(ConfigProvider.fromDotEnvContents(contents, { preserveEmptyStrings: true })), - missing: () => Effect.succeed(ConfigProvider.fromUnknown({})), - }), - ); -}); - const loadWithProvider = ( parse: (provider: ConfigProvider.ConfigProvider) => Effect.Effect, options: LoadDatabaseConfigOptions, @@ -214,7 +176,8 @@ const loadWithProvider = ( }); const envPath = options.envPath ?? ROOT_ENV_PATH; - return loadDotEnvProvider(envPath).pipe( + return loadDotEnvProvider(envPath, configFailure).pipe( + Effect.withSpan('Config.loadDotEnvProvider'), Effect.flatMap((fileProvider) => parse(ConfigProvider.orElse(environmentProvider, fileProvider)), ), diff --git a/app/packages/core-runtime/src/environment/dotenv-provider.ts b/app/packages/core-runtime/src/environment/dotenv-provider.ts new file mode 100644 index 000000000..462faeefa --- /dev/null +++ b/app/packages/core-runtime/src/environment/dotenv-provider.ts @@ -0,0 +1,36 @@ +import { ConfigProvider, Effect, Match, Predicate } from 'effect'; + +const nodeFileSystem = process.getBuiltinModule('node:fs'); + +export const loadDotEnvProvider = Effect.fn('Config.loadDotEnvProvider')(function* loadProvider< + Failure, +>(envPath: string, configFailure: (reason: string, cause: unknown) => Failure) { + const result = yield* Effect.sync(() => { + try { + return { + contents: nodeFileSystem.readFileSync(envPath, 'utf-8'), + status: 'loaded', + } as const; + } catch (error) { + if ( + Predicate.hasProperty(error, 'code') && + (error.code === 'ENOENT' || error.code === 'NOT_FOUND_DOTENV_ENVIRONMENT') + ) { + return { status: 'missing' } as const; + } + return { + error: configFailure(`Unable to load the root environment from ${envPath}`, error), + status: 'failed', + } as const; + } + }); + + return yield* Match.value(result).pipe( + Match.discriminatorsExhaustive('status')({ + failed: ({ error }) => Effect.fail(error), + loaded: ({ contents }) => + Effect.succeed(ConfigProvider.fromDotEnvContents(contents, { preserveEmptyStrings: true })), + missing: () => Effect.succeed(ConfigProvider.fromUnknown({})), + }), + ); +}); diff --git a/app/packages/core-runtime/src/environment/drizzle-config.ts b/app/packages/core-runtime/src/environment/drizzle-config.ts new file mode 100644 index 000000000..3987b6c1d --- /dev/null +++ b/app/packages/core-runtime/src/environment/drizzle-config.ts @@ -0,0 +1,33 @@ +import { defineConfig } from 'drizzle-kit'; +import { Redacted, Result, Schema } from 'effect'; +import { APP_ENV_PATH } from './workspace-environment.ts'; + +const nodeFileSystem = process.getBuiltinModule('node:fs'); +const nodeProcess = process.getBuiltinModule('node:process'); +const nodeUtilities = process.getBuiltinModule('node:util'); +const fileConfig = nodeFileSystem.existsSync(APP_ENV_PATH) + ? Result.getOrThrow( + Result.try(() => nodeUtilities.parseEnv(nodeFileSystem.readFileSync(APP_ENV_PATH, 'utf-8'))), + ) + : {}; +const configValues = { ...fileConfig, ...nodeProcess.env }; +const databaseUrl = Redacted.value( + Result.getOrThrow( + Schema.decodeUnknownResult( + Schema.RedactedFromValue(Schema.Trim.pipe(Schema.check(Schema.isMinLength(1)))), + )(configValues['DATABASE_ADMIN_URL']), + ), +); + +export const defineWorkspaceDrizzleConfig = (options: { + readonly out: string; + readonly schema: string; + readonly table: string; +}) => + defineConfig({ + dbCredentials: { url: databaseUrl }, + dialect: 'postgresql', + migrations: { schema: 'drizzle', table: options.table }, + out: options.out, + schema: options.schema, + }); diff --git a/app/packages/core-runtime/src/index.ts b/app/packages/core-runtime/src/index.ts index f195170a8..3dd91839f 100644 --- a/app/packages/core-runtime/src/index.ts +++ b/app/packages/core-runtime/src/index.ts @@ -322,10 +322,9 @@ export { CoreSearchQuerySchema, CoreSearchResourceRefSchema, CoreSearchTemporalFacetSchema, - createCoreSearchQueryRuntime, decodeCoreSearchProjectionMutation, decodeCoreSearchProjectionReplacement, - makeCoreSearchQueryRuntime, + createCoreSearchQueryRuntime, makeInMemoryCoreSearchProjectionStore, } from './search/projection.ts'; export type { diff --git a/app/packages/core-runtime/src/install/action-authorization-provisioning.ts b/app/packages/core-runtime/src/install/action-authorization-provisioning.ts index dec2e3611..2dee8ff90 100644 --- a/app/packages/core-runtime/src/install/action-authorization-provisioning.ts +++ b/app/packages/core-runtime/src/install/action-authorization-provisioning.ts @@ -18,7 +18,7 @@ export interface ActionAuthorizationProvisioningInput { readonly explicitActionAssertions?: readonly ActionAuthorizationExplicitAssertionSet[]; } -export interface ActionAuthorizationExplicitAssertionSet { +interface ActionAuthorizationExplicitAssertionSet { readonly actionKey: string; readonly assertions: readonly { readonly expected: 'allowed' | 'denied'; @@ -70,15 +70,9 @@ const failure = ( ): ActionAuthorizationProvisioningError => new ActionAuthorizationProvisioningError({ code, reason }); -const assertProvisioningInput = (input: ActionAuthorizationProvisioningInput) => { - const actions = input.actions.toSorted((left, right) => - left.actionKey.localeCompare(right.actionKey), - ); +const hasInvalidActions = (actions: readonly ActionAuthorizationProvisioningAction[]): boolean => { const actionKeys = actions.map(({ actionKey }) => actionKey); - const contexts = input.contexts.toSorted((left, right) => - left.tenantId.localeCompare(right.tenantId), - ); - if ( + return ( actions.length === 0 || actionKeys.some((actionKey) => actionKey.length === 0 || actionKey.length > 256) || new Set(actionKeys).size !== actionKeys.length || @@ -86,19 +80,53 @@ const assertProvisioningInput = (input: ActionAuthorizationProvisioningInput) => ({ provisioning }) => provisioning !== 'tenant_membership_default' && provisioning !== 'explicit', ) - ) { + ); +}; + +const hasInvalidContexts = (contexts: readonly ActionAuthorizationContext[]): boolean => + contexts.length === 0 || + contexts.some(({ principalId, tenantId }) => principalId.length === 0 || tenantId.length === 0) || + new Set(contexts.map(({ tenantId }) => tenantId)).size !== contexts.length; + +const isInvalidExplicitAssertionSet = ( + { actionKey, assertions }: ActionAuthorizationExplicitAssertionSet, + explicitActionKeys: ReadonlySet, +): boolean => + !explicitActionKeys.has(actionKey) || + assertions.length < 2 || + new Set(assertions.map(({ principalId }) => principalId)).size !== assertions.length || + assertions.some( + ({ expected, principalId }) => + principalId.length === 0 || (expected !== 'allowed' && expected !== 'denied'), + ) || + !assertions.some(({ expected }) => expected === 'allowed') || + !assertions.some(({ expected }) => expected === 'denied'); + +const hasInvalidExplicitAssertions = ( + explicitActionAssertions: readonly ActionAuthorizationExplicitAssertionSet[], + explicitActionKeys: ReadonlySet, +): boolean => + explicitActionAssertions.length !== explicitActionKeys.size || + explicitActionAssertions.some((assertionSet) => + isInvalidExplicitAssertionSet(assertionSet, explicitActionKeys), + ) || + new Set(explicitActionAssertions.map(({ actionKey }) => actionKey)).size !== + explicitActionAssertions.length; + +const assertProvisioningInput = (input: ActionAuthorizationProvisioningInput) => { + const actions = input.actions.toSorted((left, right) => + left.actionKey.localeCompare(right.actionKey), + ); + const contexts = input.contexts.toSorted((left, right) => + left.tenantId.localeCompare(right.tenantId), + ); + if (hasInvalidActions(actions)) { throw failure( 'action_authorization_input_invalid', 'Current Action discovery must produce a non-empty unique set', ); } - if ( - contexts.length === 0 || - contexts.some( - ({ principalId, tenantId }) => principalId.length === 0 || tenantId.length === 0, - ) || - new Set(contexts.map(({ tenantId }) => tenantId)).size !== contexts.length - ) { + if (hasInvalidContexts(contexts)) { throw failure( 'action_authorization_input_invalid', 'Authorization provisioning requires unique fixed Tenant contexts', @@ -123,23 +151,7 @@ const assertProvisioningInput = (input: ActionAuthorizationProvisioningInput) => const explicitActionAssertions = (input.explicitActionAssertions ?? []).toSorted((left, right) => left.actionKey.localeCompare(right.actionKey), ); - if ( - explicitActionAssertions.length !== explicitActionKeys.size || - explicitActionAssertions.some( - ({ actionKey, assertions }) => - !explicitActionKeys.has(actionKey) || - assertions.length < 2 || - new Set(assertions.map(({ principalId }) => principalId)).size !== assertions.length || - assertions.some( - ({ expected, principalId }) => - principalId.length === 0 || (expected !== 'allowed' && expected !== 'denied'), - ) || - !assertions.some(({ expected }) => expected === 'allowed') || - !assertions.some(({ expected }) => expected === 'denied'), - ) || - new Set(explicitActionAssertions.map(({ actionKey }) => actionKey)).size !== - explicitActionAssertions.length - ) { + if (hasInvalidExplicitAssertions(explicitActionAssertions, explicitActionKeys)) { throw failure( 'action_authorization_input_invalid', 'Each explicit Action requires unique recorded allowed and denied verification assertions', diff --git a/app/packages/core-runtime/src/install/context-bootstrap-shared.ts b/app/packages/core-runtime/src/install/context-bootstrap-shared.ts new file mode 100644 index 000000000..0ae17ea37 --- /dev/null +++ b/app/packages/core-runtime/src/install/context-bootstrap-shared.ts @@ -0,0 +1,125 @@ +import { v1 } from '@authzed/authzed-node'; +import { and, eq, or } from 'drizzle-orm'; +import { legalEntities, principalAuthBindings, principals } from '../db/schema.ts'; +import type { CoreTransaction } from '../db/types.ts'; + +interface BootstrapIdentity { + readonly authBindingId: string; + readonly legalEntityId: string; + readonly legalName: string; + readonly principalDisplayName: string; + readonly principalId: string; + readonly registrationCountry: string; + readonly registrationNumber: string; + readonly tenantId: string; +} + +export const selectBootstrapLegalEntities = ( + transaction: CoreTransaction, + context: BootstrapIdentity, +) => + transaction + .select({ + legalEntityId: legalEntities.legalEntityId, + legalName: legalEntities.legalName, + registrationCountry: legalEntities.registrationCountry, + registrationNumber: legalEntities.registrationNumber, + status: legalEntities.status, + tenantId: legalEntities.tenantId, + }) + .from(legalEntities) + .where( + or( + eq(legalEntities.legalEntityId, context.legalEntityId), + and( + eq(legalEntities.tenantId, context.tenantId), + eq(legalEntities.registrationCountry, context.registrationCountry), + eq(legalEntities.registrationNumber, context.registrationNumber), + ), + ), + ) + .limit(2); + +export const selectBootstrapPrincipals = ( + transaction: CoreTransaction, + context: BootstrapIdentity, +) => + transaction + .select({ + displayName: principals.displayName, + kind: principals.kind, + principalId: principals.principalId, + status: principals.status, + tenantId: principals.tenantId, + }) + .from(principals) + .where(eq(principals.principalId, context.principalId)) + .limit(1); + +export const selectBootstrapAuthBindings = ( + transaction: CoreTransaction, + context: BootstrapIdentity, + authUserId: string, +) => + transaction + .select({ + principalAuthBindingId: principalAuthBindings.principalAuthBindingId, + principalId: principalAuthBindings.principalId, + provider: principalAuthBindings.provider, + providerSubjectId: principalAuthBindings.providerSubjectId, + status: principalAuthBindings.status, + subjectType: principalAuthBindings.subjectType, + tenantId: principalAuthBindings.tenantId, + }) + .from(principalAuthBindings) + .where( + or( + eq(principalAuthBindings.principalAuthBindingId, context.authBindingId), + and( + eq(principalAuthBindings.tenantId, context.tenantId), + eq(principalAuthBindings.provider, 'better_auth'), + eq(principalAuthBindings.subjectType, 'user'), + eq(principalAuthBindings.providerSubjectId, authUserId), + ), + ), + ) + .limit(2); + +export const bootstrapPrincipalRecord = (context: BootstrapIdentity) => + ({ + displayName: context.principalDisplayName, + kind: 'human', + principalId: context.principalId, + status: 'active', + tenantId: context.tenantId, + }) as const; + +interface BootstrapRelationship { + readonly relation: string; + readonly resourceId: string; + readonly resourceType: string; + readonly subjectId: string; + readonly subjectType: string; +} + +export const bootstrapRelationshipRequest = (relationships: readonly BootstrapRelationship[]) => + v1.WriteRelationshipsRequest.create({ + updates: relationships.map((item) => + v1.RelationshipUpdate.create({ + operation: v1.RelationshipUpdate_Operation.TOUCH, + relationship: v1.Relationship.create({ + relation: item.relation, + resource: v1.ObjectReference.create({ + objectId: item.resourceId, + objectType: item.resourceType, + }), + subject: v1.SubjectReference.create({ + object: v1.ObjectReference.create({ + objectId: item.subjectId, + objectType: item.subjectType, + }), + }), + }), + }), + ), + }); diff --git a/app/packages/core-runtime/src/install/stage-context-bootstrap.ts b/app/packages/core-runtime/src/install/stage-context-bootstrap.ts index 57281daa0..4bb2135a2 100644 --- a/app/packages/core-runtime/src/install/stage-context-bootstrap.ts +++ b/app/packages/core-runtime/src/install/stage-context-bootstrap.ts @@ -1,3 +1,10 @@ +import { + bootstrapPrincipalRecord, + bootstrapRelationshipRequest, + selectBootstrapLegalEntities, + selectBootstrapPrincipals, + selectBootstrapAuthBindings, +} from './context-bootstrap-shared.ts'; import { v1 } from '@authzed/authzed-node'; import { and, eq, or } from 'drizzle-orm'; import { Config, Effect, Option, Redacted, Schema } from 'effect'; @@ -239,28 +246,9 @@ const reconcilePostgresTransaction = Effect.fn( .pipe(Effect.mapError(bootstrapFailureFromCause)); } - const legalEntityCandidates = yield* transaction - .select({ - legalEntityId: legalEntities.legalEntityId, - legalName: legalEntities.legalName, - registrationCountry: legalEntities.registrationCountry, - registrationNumber: legalEntities.registrationNumber, - status: legalEntities.status, - tenantId: legalEntities.tenantId, - }) - .from(legalEntities) - .where( - or( - eq(legalEntities.legalEntityId, context.legalEntityId), - and( - eq(legalEntities.tenantId, context.tenantId), - eq(legalEntities.registrationCountry, context.registrationCountry), - eq(legalEntities.registrationNumber, context.registrationNumber), - ), - ), - ) - .limit(2) - .pipe(Effect.mapError(bootstrapFailureFromCause)); + const legalEntityCandidates = yield* selectBootstrapLegalEntities(transaction, context).pipe( + Effect.mapError(bootstrapFailureFromCause), + ); if (legalEntityCandidates.length > 1) { return yield* failure('The stage legal-entity identity conflicts'); } @@ -282,25 +270,10 @@ const reconcilePostgresTransaction = Effect.fn( .pipe(Effect.mapError(bootstrapFailureFromCause)); } - const expectedPrincipal = { - displayName: context.principalDisplayName, - kind: 'human', - principalId: context.principalId, - status: 'active', - tenantId: context.tenantId, - } as const; - const principalCandidates = yield* transaction - .select({ - displayName: principals.displayName, - kind: principals.kind, - principalId: principals.principalId, - status: principals.status, - tenantId: principals.tenantId, - }) - .from(principals) - .where(eq(principals.principalId, context.principalId)) - .limit(1) - .pipe(Effect.mapError(bootstrapFailureFromCause)); + const expectedPrincipal = bootstrapPrincipalRecord(context); + const principalCandidates = yield* selectBootstrapPrincipals(transaction, context).pipe( + Effect.mapError(bootstrapFailureFromCause), + ); if ( (yield* classifyExactRecord('principal', principalCandidates[0], expectedPrincipal)) === 'create' @@ -311,30 +284,11 @@ const reconcilePostgresTransaction = Effect.fn( .pipe(Effect.mapError(bootstrapFailureFromCause)); } - const bindingCandidates = yield* transaction - .select({ - principalAuthBindingId: principalAuthBindings.principalAuthBindingId, - principalId: principalAuthBindings.principalId, - provider: principalAuthBindings.provider, - providerSubjectId: principalAuthBindings.providerSubjectId, - status: principalAuthBindings.status, - subjectType: principalAuthBindings.subjectType, - tenantId: principalAuthBindings.tenantId, - }) - .from(principalAuthBindings) - .where( - or( - eq(principalAuthBindings.principalAuthBindingId, context.authBindingId), - and( - eq(principalAuthBindings.tenantId, context.tenantId), - eq(principalAuthBindings.provider, 'better_auth'), - eq(principalAuthBindings.subjectType, 'user'), - eq(principalAuthBindings.providerSubjectId, authUserId), - ), - ), - ) - .limit(2) - .pipe(Effect.mapError(bootstrapFailureFromCause)); + const bindingCandidates = yield* selectBootstrapAuthBindings( + transaction, + context, + authUserId, + ).pipe(Effect.mapError(bootstrapFailureFromCause)); if (bindingCandidates.length > 1) { return yield* failure('The stage authentication binding conflicts'); } @@ -481,26 +435,7 @@ const touchRelationships = Effect.fn('StageContextBootstrap.touchRelationships') context: StageContext, ): Effect.fn.Return { const relationships = yield* buildRelationships(context); - const request = v1.WriteRelationshipsRequest.create({ - updates: relationships.map((item) => - v1.RelationshipUpdate.create({ - operation: v1.RelationshipUpdate_Operation.TOUCH, - relationship: v1.Relationship.create({ - relation: item.relation, - resource: v1.ObjectReference.create({ - objectId: item.resourceId, - objectType: item.resourceType, - }), - subject: v1.SubjectReference.create({ - object: v1.ObjectReference.create({ - objectId: item.subjectId, - objectType: item.subjectType, - }), - }), - }), - }), - ), - }); + const request = bootstrapRelationshipRequest(relationships); yield* Effect.acquireUseRelease( Effect.try({ catch: bootstrapFailureFromCause, diff --git a/app/packages/core-runtime/src/modules/actions/bind-managed-api-key.action.ts b/app/packages/core-runtime/src/modules/actions/bind-managed-api-key.action.ts index 2d60699fd..2589d9154 100644 --- a/app/packages/core-runtime/src/modules/actions/bind-managed-api-key.action.ts +++ b/app/packages/core-runtime/src/modules/actions/bind-managed-api-key.action.ts @@ -17,16 +17,15 @@ const ProviderSubjectIdSchema = Schema.String.check( const AuthBindingIdSchema = Schema.String.check(Schema.isUUID()).pipe( Schema.brand('AuthBindingId'), ); -export const BindManagedApiKeyPayloadSchema = Schema.Struct({ +const BindManagedApiKeyPayloadSchema = Schema.Struct({ principalId: PrincipalIdSchema, providerSubjectId: ProviderSubjectIdSchema, }); export type BindManagedApiKeyPayload = Schema.Schema.Type; -export const BindManagedApiKeyResultSchema = Schema.Struct({ +const BindManagedApiKeyResultSchema = Schema.Struct({ authBindingId: AuthBindingIdSchema, status: Schema.Literal('active'), }); -export type BindManagedApiKeyResult = Schema.Schema.Type; type BindApiKey = PrincipalManagementRepositoryService['bindApiKey']; type Input = Parameters[0]; type Result = ReturnType; diff --git a/app/packages/core-runtime/src/modules/actions/bind-self-api-key.action.ts b/app/packages/core-runtime/src/modules/actions/bind-self-api-key.action.ts index a93402c0f..a7907a090 100644 --- a/app/packages/core-runtime/src/modules/actions/bind-self-api-key.action.ts +++ b/app/packages/core-runtime/src/modules/actions/bind-self-api-key.action.ts @@ -16,15 +16,14 @@ const ProviderSubjectIdSchema = Schema.String.check( const AuthBindingIdSchema = Schema.String.check(Schema.isUUID()).pipe( Schema.brand('AuthBindingId'), ); -export const BindSelfApiKeyPayloadSchema = Schema.Struct({ +const BindSelfApiKeyPayloadSchema = Schema.Struct({ providerSubjectId: ProviderSubjectIdSchema, }); export type BindSelfApiKeyPayload = Schema.Schema.Type; -export const BindSelfApiKeyResultSchema = Schema.Struct({ +const BindSelfApiKeyResultSchema = Schema.Struct({ authBindingId: AuthBindingIdSchema, status: Schema.Literal('active'), }); -export type BindSelfApiKeyResult = Schema.Schema.Type; type BindApiKey = PrincipalManagementRepositoryService['bindApiKey']; type Input = Parameters[0]; type Result = ReturnType; diff --git a/app/packages/core-runtime/src/modules/actions/change-principal-status.action.ts b/app/packages/core-runtime/src/modules/actions/change-principal-status.action.ts index c59d71ad5..345808b18 100644 --- a/app/packages/core-runtime/src/modules/actions/change-principal-status.action.ts +++ b/app/packages/core-runtime/src/modules/actions/change-principal-status.action.ts @@ -12,7 +12,7 @@ import { defineSystemModuleEntrypoint } from '../module-entrypoint.ts'; const PrincipalIdSchema = Schema.String.check(Schema.isUUID()).pipe(Schema.brand('PrincipalId')); const status = Schema.Literals(['active', 'disabled', 'archived']); const reason = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(500)); -export const ChangePrincipalStatusPayloadSchema = Schema.Union([ +const ChangePrincipalStatusPayloadSchema = Schema.Union([ Schema.Struct({ expectedStatus: status, newStatus: Schema.Literal('active'), @@ -29,10 +29,7 @@ export const ChangePrincipalStatusPayloadSchema = Schema.Union([ export type ChangePrincipalStatusPayload = Schema.Schema.Type< typeof ChangePrincipalStatusPayloadSchema >; -export const ChangePrincipalStatusResultSchema = Schema.Struct({ previousStatus: status, status }); -export type ChangePrincipalStatusResult = Schema.Schema.Type< - typeof ChangePrincipalStatusResultSchema ->; +const ChangePrincipalStatusResultSchema = Schema.Struct({ previousStatus: status, status }); type ChangePrincipalStatus = PrincipalManagementRepositoryService['changePrincipalStatus']; type Input = Parameters[0]; type Result = ReturnType; diff --git a/app/packages/core-runtime/src/modules/actions/change-tenant-module-state.action.ts b/app/packages/core-runtime/src/modules/actions/change-tenant-module-state.action.ts index 43fb6a8ba..dcc74c771 100644 --- a/app/packages/core-runtime/src/modules/actions/change-tenant-module-state.action.ts +++ b/app/packages/core-runtime/src/modules/actions/change-tenant-module-state.action.ts @@ -44,7 +44,7 @@ const withOptionalProperty = < const moduleKeySchema = OntosModuleIdSchema.check(Schema.isMaxLength(128)); const reasonSchema = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(500)); -export const ChangeTenantModuleStatePayloadSchema = Schema.Struct({ +const ChangeTenantModuleStatePayloadSchema = Schema.Struct({ expectedState: Schema.optionalKey(TenantModuleStateSchema), moduleKey: moduleKeySchema, newState: TenantModuleStateSchema, @@ -54,16 +54,13 @@ export type ChangeTenantModuleStatePayload = Schema.Schema.Type< typeof ChangeTenantModuleStatePayloadSchema >; -export const ChangeTenantModuleStateResultSchema = Schema.Struct({ +const ChangeTenantModuleStateResultSchema = Schema.Struct({ moduleKey: moduleKeySchema, newState: TenantModuleStateSchema, previousState: Schema.Union([TenantModuleStateSchema, Schema.Null]), }); -export type ChangeTenantModuleStateResult = Schema.Schema.Type< - typeof ChangeTenantModuleStateResultSchema ->; -export const ChangeTenantModuleStateError = Schema.Union([ +const ChangeTenantModuleStateError = Schema.Union([ TenantModuleStateConcurrentChangeError, TenantModuleStatePersistenceUnavailableError, TenantModuleStateTenantMissingError, diff --git a/app/packages/core-runtime/src/modules/actions/create-non-human-principal.action.ts b/app/packages/core-runtime/src/modules/actions/create-non-human-principal.action.ts index 3f303a2d2..43ec75b36 100644 --- a/app/packages/core-runtime/src/modules/actions/create-non-human-principal.action.ts +++ b/app/packages/core-runtime/src/modules/actions/create-non-human-principal.action.ts @@ -12,20 +12,17 @@ import { defineSystemModuleEntrypoint } from '../module-entrypoint.ts'; const uuid = Schema.String.check(Schema.isUUID()); const PrincipalIdSchema = uuid.pipe(Schema.brand('PrincipalId')); const displayName = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(200)); -export const CreateNonHumanPrincipalPayloadSchema = Schema.Struct({ +const CreateNonHumanPrincipalPayloadSchema = Schema.Struct({ displayName, kind: Schema.Literals(['service', 'integration', 'system']), }); export type CreateNonHumanPrincipalPayload = Schema.Schema.Type< typeof CreateNonHumanPrincipalPayloadSchema >; -export const CreateNonHumanPrincipalResultSchema = Schema.Struct({ +const CreateNonHumanPrincipalResultSchema = Schema.Struct({ principalId: PrincipalIdSchema, status: Schema.Literal('active'), }); -export type CreateNonHumanPrincipalResult = Schema.Schema.Type< - typeof CreateNonHumanPrincipalResultSchema ->; type CreateNonHumanPrincipal = PrincipalManagementRepositoryService['createNonHumanPrincipal']; type Input = Parameters[0]; type Result = ReturnType; diff --git a/app/packages/core-runtime/src/modules/actions/record-support-impersonation.action.ts b/app/packages/core-runtime/src/modules/actions/record-support-impersonation.action.ts index e098f294b..827e0ab50 100644 --- a/app/packages/core-runtime/src/modules/actions/record-support-impersonation.action.ts +++ b/app/packages/core-runtime/src/modules/actions/record-support-impersonation.action.ts @@ -23,7 +23,7 @@ const checkpointFields = { reason, targetPrincipalId: PrincipalIdSchema, }; -export const RecordSupportImpersonationPayloadSchema = Schema.Union([ +const RecordSupportImpersonationPayloadSchema = Schema.Union([ Schema.Struct({ ...checkpointFields, checkpoint: Schema.Literal('requested') }), Schema.Struct({ ...checkpointFields, @@ -34,13 +34,10 @@ export const RecordSupportImpersonationPayloadSchema = Schema.Union([ export type RecordSupportImpersonationPayload = Schema.Schema.Type< typeof RecordSupportImpersonationPayloadSchema >; -export const RecordSupportImpersonationResultSchema = Schema.Struct({ +const RecordSupportImpersonationResultSchema = Schema.Struct({ checkpoint: Schema.Literals(['requested', 'started', 'stopped']), recorded: Schema.Literal(true), }); -export type RecordSupportImpersonationResult = Schema.Schema.Type< - typeof RecordSupportImpersonationResultSchema ->; type ValidateSupportImpersonation = PrincipalManagementRepositoryService['validateSupportImpersonation']; const handle = Effect.fn('RecordSupportImpersonationAction.handle')( diff --git a/app/packages/core-runtime/src/modules/actions/set-managed-api-key-binding-status.action.ts b/app/packages/core-runtime/src/modules/actions/set-managed-api-key-binding-status.action.ts index b6f1d1c3b..da5e406d4 100644 --- a/app/packages/core-runtime/src/modules/actions/set-managed-api-key-binding-status.action.ts +++ b/app/packages/core-runtime/src/modules/actions/set-managed-api-key-binding-status.action.ts @@ -14,7 +14,7 @@ const AuthBindingIdSchema = uuid.pipe(Schema.brand('AuthBindingId')); const PrincipalIdSchema = uuid.pipe(Schema.brand('PrincipalId')); const status = Schema.Literals(['active', 'disabled', 'revoked']); const reason = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(500)); -export const SetManagedApiKeyBindingStatusPayloadSchema = Schema.Union([ +const SetManagedApiKeyBindingStatusPayloadSchema = Schema.Union([ Schema.Struct({ authBindingId: AuthBindingIdSchema, expectedStatus: status, @@ -33,13 +33,10 @@ export const SetManagedApiKeyBindingStatusPayloadSchema = Schema.Union([ export type SetManagedApiKeyBindingStatusPayload = Schema.Schema.Type< typeof SetManagedApiKeyBindingStatusPayloadSchema >; -export const SetManagedApiKeyBindingStatusResultSchema = Schema.Struct({ +const SetManagedApiKeyBindingStatusResultSchema = Schema.Struct({ previousStatus: status, status, }); -export type SetManagedApiKeyBindingStatusResult = Schema.Schema.Type< - typeof SetManagedApiKeyBindingStatusResultSchema ->; type SetStatus = PrincipalManagementRepositoryService['setApiKeyBindingStatus']; type Input = Parameters[0]; type Result = ReturnType; diff --git a/app/packages/core-runtime/src/modules/actions/set-self-api-key-binding-status.action.ts b/app/packages/core-runtime/src/modules/actions/set-self-api-key-binding-status.action.ts index f90497325..072cc47f8 100644 --- a/app/packages/core-runtime/src/modules/actions/set-self-api-key-binding-status.action.ts +++ b/app/packages/core-runtime/src/modules/actions/set-self-api-key-binding-status.action.ts @@ -14,7 +14,7 @@ const AuthBindingIdSchema = Schema.String.check(Schema.isUUID()).pipe( ); const status = Schema.Literals(['active', 'disabled', 'revoked']); const reason = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(500)); -export const SetSelfApiKeyBindingStatusPayloadSchema = Schema.Union([ +const SetSelfApiKeyBindingStatusPayloadSchema = Schema.Union([ Schema.Struct({ authBindingId: AuthBindingIdSchema, expectedStatus: status, @@ -31,13 +31,10 @@ export const SetSelfApiKeyBindingStatusPayloadSchema = Schema.Union([ export type SetSelfApiKeyBindingStatusPayload = Schema.Schema.Type< typeof SetSelfApiKeyBindingStatusPayloadSchema >; -export const SetSelfApiKeyBindingStatusResultSchema = Schema.Struct({ +const SetSelfApiKeyBindingStatusResultSchema = Schema.Struct({ previousStatus: status, status, }); -export type SetSelfApiKeyBindingStatusResult = Schema.Schema.Type< - typeof SetSelfApiKeyBindingStatusResultSchema ->; type SetStatus = PrincipalManagementRepositoryService['setApiKeyBindingStatus']; type Input = Parameters[0]; type Result = ReturnType; diff --git a/app/packages/core-runtime/src/modules/application-composition.ts b/app/packages/core-runtime/src/modules/application-composition.ts index 7e5b85039..57b4c0aa0 100644 --- a/app/packages/core-runtime/src/modules/application-composition.ts +++ b/app/packages/core-runtime/src/modules/application-composition.ts @@ -9,17 +9,16 @@ export const ONTOS_APPLICATION_COMPOSITION_SCHEMA_VERSION = '1' as const; const sha256 = Schema.String.check(Schema.isPattern(/^[\da-f]{64}$/u)); const version = Schema.String.check(Schema.isPattern(/^[0-9]+(?:\.[0-9]+){0,2}$/u)); +const isLoopbackHostname = (hostname: string): boolean => + ['localhost', '127.0.0.1', '[::1]'].includes(hostname) || hostname.endsWith('.localhost'); + const artifactUrl = Schema.String.check( Schema.makeFilter((value) => { const url = URL.parse(value); if (url === null) { return 'artifact URL must be absolute'; } - const loopback = - url.hostname === 'localhost' || - url.hostname === '127.0.0.1' || - url.hostname === '[::1]' || - url.hostname.endsWith('.localhost'); + const loopback = isLoopbackHostname(url.hostname); return (url.protocol === 'https:' || (url.protocol === 'http:' && loopback)) && url.username === '' && url.password === '' && @@ -280,15 +279,21 @@ const assertShellCompatibility = Effect.fnUntraced(function* checkCompatibility( return yield* Effect.void; }); +const matchesObservedArtifact = ( + module: ApplicationCompositionModule, + contract: ObservedApplicationCompositionContract, +): boolean => + contract.contractUrl === module.contract.url && + contract.sha256 === module.contract.sha256 && + sameDeployment(contract.deployment, module.deployment); + const assertObservedDeployment = Effect.fnUntraced(function* checkDeployment( module: ApplicationCompositionModule, contract: ObservedApplicationCompositionContract | undefined, ) { if ( contract === undefined || - contract.contractUrl !== module.contract.url || - contract.sha256 !== module.contract.sha256 || - !sameDeployment(contract.deployment, module.deployment) || + !matchesObservedArtifact(module, contract) || contract.moduleId !== module.moduleId || contract.mfBoundaryId !== module.federation.remoteName || !samePublicContract(contract.publicContract, module.publicContract) || diff --git a/app/packages/core-runtime/src/modules/catalog.ts b/app/packages/core-runtime/src/modules/catalog.ts index 90f0b59f7..1b3553d91 100644 --- a/app/packages/core-runtime/src/modules/catalog.ts +++ b/app/packages/core-runtime/src/modules/catalog.ts @@ -34,7 +34,7 @@ export interface InstalledDeploymentContractInput { readonly expectedAppId: OntosDeploymentAppId; } -export const InstalledDeploymentFailureReasonSchema = Schema.Literals([ +const InstalledDeploymentFailureReasonSchema = Schema.Literals([ 'incompatible', 'timeout', 'unavailable', @@ -252,12 +252,42 @@ export const buildInstalledModuleCatalog = ( ); }; -/** Resolves each installed deployment independently while excluding contradictory candidates. */ -export const resolveInstalledModuleCatalog = ( +const findConflictingDeploymentAppIds = ( + candidates: readonly OntosModuleDeploymentContract[], +): ReadonlySet => { + const conflictingAppIds = new Set(); + const byAppId = new Map(); + const byModuleId = new Map(); + const byWorkerKey = new Map(); + for (const contract of candidates) { + const { + deployment: { appId }, + manifest: { + module: { id: moduleId }, + }, + } = contract; + byAppId.set(appId, [...(byAppId.get(appId) ?? []), contract]); + byModuleId.set(moduleId, [...(byModuleId.get(moduleId) ?? []), contract]); + for (const { workerKey } of contract.runtime.outboxSubscriptions) { + byWorkerKey.set(workerKey, [...(byWorkerKey.get(workerKey) ?? []), contract]); + } + } + for (const conflicts of [...byAppId.values(), ...byModuleId.values(), ...byWorkerKey.values()]) { + if (conflicts.length > 1) { + for (const contract of conflicts) { + conflictingAppIds.add(contract.deployment.appId); + } + } + } + + return conflictingAppIds; +}; + +const collectDeploymentCandidates = ( inputs: readonly InstalledDeploymentResolutionInput[], -): InstalledModuleCatalog => { - const authoritativeStatuses = collectAuthoritativeDeploymentStatuses(inputs); - const statuses = new Map(); + authoritativeStatuses: ReadonlyMap, + statuses: Map, +): OntosModuleDeploymentContract[] => { const candidates: OntosModuleDeploymentContract[] = []; for (const input of inputs) { const authoritative = authoritativeStatuses.get(input.expectedAppId); @@ -289,30 +319,17 @@ export const resolveInstalledModuleCatalog = ( } } - const conflictingAppIds = new Set(); - const byAppId = new Map(); - const byModuleId = new Map(); - const byWorkerKey = new Map(); - for (const contract of candidates) { - const { - deployment: { appId }, - manifest: { - module: { id: moduleId }, - }, - } = contract; - byAppId.set(appId, [...(byAppId.get(appId) ?? []), contract]); - byModuleId.set(moduleId, [...(byModuleId.get(moduleId) ?? []), contract]); - for (const { workerKey } of contract.runtime.outboxSubscriptions) { - byWorkerKey.set(workerKey, [...(byWorkerKey.get(workerKey) ?? []), contract]); - } - } - for (const conflicts of [...byAppId.values(), ...byModuleId.values(), ...byWorkerKey.values()]) { - if (conflicts.length > 1) { - for (const contract of conflicts) { - conflictingAppIds.add(contract.deployment.appId); - } - } - } + return candidates; +}; + +/** Resolves each installed deployment independently while excluding contradictory candidates. */ +export const resolveInstalledModuleCatalog = ( + inputs: readonly InstalledDeploymentResolutionInput[], +): InstalledModuleCatalog => { + const authoritativeStatuses = collectAuthoritativeDeploymentStatuses(inputs); + const statuses = new Map(); + const candidates = collectDeploymentCandidates(inputs, authoritativeStatuses, statuses); + const conflictingAppIds = findConflictingDeploymentAppIds(candidates); const healthy = candidates.filter( (contract) => !conflictingAppIds.has(contract.deployment.appId), diff --git a/app/packages/core-runtime/src/modules/manifest.ts b/app/packages/core-runtime/src/modules/manifest.ts index 3cbfa6f1f..f694d0b26 100644 --- a/app/packages/core-runtime/src/modules/manifest.ts +++ b/app/packages/core-runtime/src/modules/manifest.ts @@ -339,6 +339,33 @@ export const validateOntosModuleExecutableReferences = < } }; +const validateSearchDescriptorReferences = ( + descriptor: typeof OntosSearchDescriptorSchema.Type, + moduleId: string, + resourceSet: ReadonlySet, +): void => { + assertOwner(descriptor.owningModuleId, moduleId, 'search descriptor'); + if (!resourceSet.has(descriptor.resourceType)) { + throw invalidManifest( + `search descriptor references undeclared resource type ${descriptor.resourceType}`, + ); + } + if ( + (descriptor.accessFiltering === 'tenant_scope') !== + (descriptor.tenantPermission !== undefined) + ) { + throw invalidManifest( + 'tenant-scoped search requires exactly one explicit Tenant permission declaration', + ); + } + if ( + descriptor.requestFilters !== undefined && + new Set(descriptor.requestFilters).size !== descriptor.requestFilters.length + ) { + throw invalidManifest('search request filter declarations must be unique'); + } +}; + /** * Defines the owner-authored contract. Executable values remain direct references in this * in-process value and are never part of the serializable deployment contract. @@ -415,26 +442,7 @@ export const defineOntosModuleManifest = diff --git a/app/packages/core-runtime/src/modules/module-state-gate.ts b/app/packages/core-runtime/src/modules/module-state-gate.ts index 88441cf55..72f6adc83 100644 --- a/app/packages/core-runtime/src/modules/module-state-gate.ts +++ b/app/packages/core-runtime/src/modules/module-state-gate.ts @@ -26,7 +26,7 @@ import { export type { ModuleStateSnapshot } from './module-state-snapshot.ts'; -export const ModuleStateDecisionSchema = Schema.Literals(['allow', 'deny']); +const ModuleStateDecisionSchema = Schema.Literals(['allow', 'deny']); export type ModuleStateDecision = typeof ModuleStateDecisionSchema.Type; const allowedAccessByState: Readonly< diff --git a/app/packages/core-runtime/src/modules/runtime-registration.ts b/app/packages/core-runtime/src/modules/runtime-registration.ts index dfbcbd0da..179ea710c 100644 --- a/app/packages/core-runtime/src/modules/runtime-registration.ts +++ b/app/packages/core-runtime/src/modules/runtime-registration.ts @@ -93,9 +93,7 @@ const assertUnique = (values: readonly string[], label: string): void => { } }; -export const defineVerticalRuntimeRegistration = ( - input: VerticalRuntimeRegistrationInput, -): VerticalRuntimeRegistration => { +const validateRuntimeActions = (input: VerticalRuntimeRegistrationInput): void => { const allowed = new Set(['actions', 'entrypoints', 'manifest', 'outboxWorkers']); for (const key of Reflect.ownKeys(input)) { if (!Predicate.isString(key) || !allowed.has(key)) { @@ -115,13 +113,9 @@ export const defineVerticalRuntimeRegistration = { const entrypointCategories = new Set(['api', 'components', 'pages', 'reports', 'search']); for (const key of Reflect.ownKeys(entrypoints)) { if (!Predicate.isString(key) || !entrypointCategories.has(key)) { @@ -133,6 +127,20 @@ export const defineVerticalRuntimeRegistration = ( + input: VerticalRuntimeRegistrationInput, +): VerticalRuntimeRegistration => { + validateRuntimeActions(input); + const workers = validateOutboxWorkerRegistrations(input.outboxWorkers); + for (const worker of workers) { + if (worker.descriptor.consumerModuleKey !== input.manifest.module.id) { + failRuntimeRegistration('runtime Outbox Worker owner must match the manifest module ID'); + } + } + const entrypoints = input.entrypoints ?? emptyEntrypoints(); + validateRuntimeEntrypoints(entrypoints); return new VerticalRuntimeRegistrationValue(input.manifest.module.id, { actions: Object.freeze([...input.actions]), entrypoints: Object.freeze({ diff --git a/app/packages/core-runtime/src/modules/shell-contribution.ts b/app/packages/core-runtime/src/modules/shell-contribution.ts index af6a07f83..5da350cf0 100644 --- a/app/packages/core-runtime/src/modules/shell-contribution.ts +++ b/app/packages/core-runtime/src/modules/shell-contribution.ts @@ -190,32 +190,10 @@ const referenceIssue = ( ): string | undefined => set.has(key) ? undefined : `${label} references undeclared manifest key ${key}`; -const validateReferences = ( +const validatePageReferences = ( contributions: OntosShellContributions, references: ShellContributionReferenceSets, ): string | undefined => { - const all = [ - ...contributions.mediaAttachments, - ...contributions.navigation, - ...contributions.pages, - ...contributions.publicComponents, - ...contributions.reports, - ...contributions.resourceDetails, - ...contributions.search, - ...contributions.timelines, - ]; - const contributionKeys = all.map(({ contributionKey: key }) => key); - if (new Set(contributionKeys).size !== contributionKeys.length) { - return 'duplicate Shell contribution key'; - } - for (const contribution of all) { - if ( - contribution.entrypoint.moduleKey !== references.moduleId || - !contribution.entrypoint.entrypointKey.startsWith(`${references.moduleId}.`) - ) { - return 'Shell contribution entrypoint owner must match the manifest module'; - } - } const pageKeys = new Set(contributions.pages.map(({ contributionKey: key }) => key)); for (const contribution of contributions.navigation) { const issue = referenceIssue(pageKeys, contribution.pageKey, 'navigation contribution'); @@ -233,6 +211,14 @@ const validateReferences = ( return issue; } } + + return undefined; +}; + +const validateDiscoveryReferences = ( + contributions: OntosShellContributions, + references: ShellContributionReferenceSets, +): string | undefined => { for (const contribution of contributions.search) { const issue = referenceIssue( references.searchKeys, @@ -253,6 +239,14 @@ const validateReferences = ( return issue; } } + + return undefined; +}; + +const validateResourceReferences = ( + contributions: OntosShellContributions, + references: ShellContributionReferenceSets, +): string | undefined => { for (const contribution of [...contributions.resourceDetails, ...contributions.timelines]) { const apiIssue = referenceIssue( references.apiKeys, @@ -271,6 +265,14 @@ const validateReferences = ( return resourceIssue; } } + + return undefined; +}; + +const validateMediaReferences = ( + contributions: OntosShellContributions, + references: ShellContributionReferenceSets, +): string | undefined => { for (const contribution of contributions.mediaAttachments) { const actionIssue = referenceIssue( references.actionKeys, @@ -296,6 +298,40 @@ const validateReferences = ( return undefined; }; +const validateReferences = ( + contributions: OntosShellContributions, + references: ShellContributionReferenceSets, +): string | undefined => { + const all = [ + ...contributions.mediaAttachments, + ...contributions.navigation, + ...contributions.pages, + ...contributions.publicComponents, + ...contributions.reports, + ...contributions.resourceDetails, + ...contributions.search, + ...contributions.timelines, + ]; + const contributionKeys = all.map(({ contributionKey: key }) => key); + if (new Set(contributionKeys).size !== contributionKeys.length) { + return 'duplicate Shell contribution key'; + } + for (const contribution of all) { + if ( + contribution.entrypoint.moduleKey !== references.moduleId || + !contribution.entrypoint.entrypointKey.startsWith(`${references.moduleId}.`) + ) { + return 'Shell contribution entrypoint owner must match the manifest module'; + } + } + return ( + validatePageReferences(contributions, references) ?? + validateDiscoveryReferences(contributions, references) ?? + validateResourceReferences(contributions, references) ?? + validateMediaReferences(contributions, references) + ); +}; + export const validateShellContributions = ( input: Input, references: ShellContributionReferenceSets, diff --git a/app/packages/core-runtime/src/modules/tenant-module-state-service.ts b/app/packages/core-runtime/src/modules/tenant-module-state-service.ts index 68747742a..fd7ea39f4 100644 --- a/app/packages/core-runtime/src/modules/tenant-module-state-service.ts +++ b/app/packages/core-runtime/src/modules/tenant-module-state-service.ts @@ -58,7 +58,7 @@ export const TenantModuleStateRecordSchema = Schema.Struct({ }); export type TenantModuleStateRecord = Schema.Schema.Type; -export const TenantModuleStateChangeSourceSchema = Schema.Literals(['support', 'system', 'user']); +const TenantModuleStateChangeSourceSchema = Schema.Literals(['support', 'system', 'user']); export type TenantModuleStateChangeSource = typeof TenantModuleStateChangeSourceSchema.Type; export const validateTenantModuleStateTransition = ( diff --git a/app/packages/core-runtime/src/operations/context.ts b/app/packages/core-runtime/src/operations/context.ts index d06d6f9c6..8e381e7de 100644 --- a/app/packages/core-runtime/src/operations/context.ts +++ b/app/packages/core-runtime/src/operations/context.ts @@ -197,6 +197,16 @@ const validateRequestedScope = ( return undefined; }; +const hasInvalidPersistedBinding = ( + principal: TrustedPrincipalContext, + persisted: PersistedScopeRecord, + supportRecovery: boolean, +): boolean => + persisted.bindingTenantId !== principal.tenantId || + persisted.bindingPrincipalId !== principal.principalId || + (!supportRecovery && + (persisted.bindingStatus !== 'active' || persisted.bindingRevokedAt !== null)); + const validatePersistedPrincipal = ( principal: TrustedPrincipalContext, persisted: PersistedScopeRecord, @@ -215,10 +225,7 @@ const validatePersistedPrincipal = ( } if ( principal.authBindingId !== undefined && - (persisted.bindingTenantId !== principal.tenantId || - persisted.bindingPrincipalId !== principal.principalId || - (!supportRecovery && - (persisted.bindingStatus !== 'active' || persisted.bindingRevokedAt !== null))) + hasInvalidPersistedBinding(principal, persisted, supportRecovery) ) { return new OperationAuthenticationRequired({ code: 'operation_authentication_required', diff --git a/app/packages/core-runtime/src/outbox/definition.ts b/app/packages/core-runtime/src/outbox/definition.ts index 3f2ad6625..e8dfe82e3 100644 --- a/app/packages/core-runtime/src/outbox/definition.ts +++ b/app/packages/core-runtime/src/outbox/definition.ts @@ -202,25 +202,7 @@ const assertFiniteInteger = ( } }; -export const defineOutboxWorker = < - PayloadSchema extends Schema.ConstraintDecoder, - const Consumer extends string, - const Producer extends string, - HandlerError, - HandlerRequirements, ->( - descriptor: OutboxWorkerDescriptor, - handler: OutboxWorkerHandler, -): OutboxWorkerRegistration< - PayloadSchema, - Consumer, - Producer, - HandlerError, - HandlerRequirements -> => { - if (!moduleKeyPattern.test(descriptor.consumerModuleKey)) { - throw descriptorError('consumerModuleKey must be a stable module key'); - } +const assertWorkerEntrypoint = (descriptor: OutboxWorkerSubscription, reason: string): void => { if ( descriptor.entrypoint.scope !== 'tenant' || descriptor.entrypoint.role !== 'worker' || @@ -229,10 +211,18 @@ export const defineOutboxWorker = < descriptor.entrypoint.entrypointKey !== descriptor.workerKey || !Object.isFrozen(descriptor.entrypoint) ) { - throw descriptorError( - 'Worker entrypoint must be an immutable tenant worker/background descriptor owned by consumerModuleKey', - ); + throw descriptorError(reason); } +}; + +const assertWorkerSubscription = ( + descriptor: OutboxWorkerSubscription, + entrypointError: string, +): void => { + if (!moduleKeyPattern.test(descriptor.consumerModuleKey)) { + throw descriptorError('consumerModuleKey must be a stable module key'); + } + assertWorkerEntrypoint(descriptor, entrypointError); if (!moduleKeyPattern.test(descriptor.producerModuleKey)) { throw descriptorError('producerModuleKey must be a stable module key'); } @@ -249,6 +239,28 @@ export const defineOutboxWorker = < 'workerKey must be owned by consumerModuleKey and end in lower-kebab-case', ); } +}; + +export const defineOutboxWorker = < + PayloadSchema extends Schema.ConstraintDecoder, + const Consumer extends string, + const Producer extends string, + HandlerError, + HandlerRequirements, +>( + descriptor: OutboxWorkerDescriptor, + handler: OutboxWorkerHandler, +): OutboxWorkerRegistration< + PayloadSchema, + Consumer, + Producer, + HandlerError, + HandlerRequirements +> => { + assertWorkerSubscription( + descriptor, + 'Worker entrypoint must be an immutable tenant worker/background descriptor owned by consumerModuleKey', + ); assertFiniteInteger(descriptor.leaseDurationMs, 1000, 3_600_000, 'leaseDurationMs'); assertFiniteInteger(descriptor.retryPolicy.maxAttempts, 1, 100, 'retryPolicy.maxAttempts'); assertFiniteInteger( @@ -311,37 +323,10 @@ export const validateOutboxWorkerSubscriptions = ( ): readonly OutboxWorkerSubscription[] => { const workerKeys = new Set(); for (const subscription of subscriptions) { - if (!moduleKeyPattern.test(subscription.consumerModuleKey)) { - throw descriptorError('consumerModuleKey must be a stable module key'); - } - if ( - subscription.entrypoint.scope !== 'tenant' || - subscription.entrypoint.role !== 'worker' || - subscription.entrypoint.access !== 'background' || - subscription.entrypoint.moduleKey !== subscription.consumerModuleKey || - subscription.entrypoint.entrypointKey !== subscription.workerKey || - !Object.isFrozen(subscription.entrypoint) - ) { - throw descriptorError( - 'installed Worker entrypoint is inconsistent with its subscription owner', - ); - } - if (!moduleKeyPattern.test(subscription.producerModuleKey)) { - throw descriptorError('producerModuleKey must be a stable module key'); - } - if (!topicPattern.test(subscription.topic)) { - throw descriptorError('topic must be an exact lowercase dot-separated identifier'); - } - const expectedWorkerPrefix = `${subscription.consumerModuleKey}.`; - const workerSlug = subscription.workerKey.slice(expectedWorkerPrefix.length); - if ( - !subscription.workerKey.startsWith(expectedWorkerPrefix) || - !workerSlugPattern.test(workerSlug) - ) { - throw descriptorError( - 'workerKey must be owned by consumerModuleKey and end in lower-kebab-case', - ); - } + assertWorkerSubscription( + subscription, + 'installed Worker entrypoint is inconsistent with its subscription owner', + ); if (workerKeys.has(subscription.workerKey)) { throw descriptorError(`duplicate Outbox Worker key ${subscription.workerKey}`); } diff --git a/app/packages/core-runtime/src/outbox/errors.ts b/app/packages/core-runtime/src/outbox/errors.ts index 5f0f29b64..ede6ac5f3 100644 --- a/app/packages/core-runtime/src/outbox/errors.ts +++ b/app/packages/core-runtime/src/outbox/errors.ts @@ -1,4 +1,5 @@ -import { Cause, Schema } from 'effect'; +import { Schema } from 'effect'; +import type { Cause } from 'effect'; const reason = { reason: Schema.String } as const; @@ -112,14 +113,6 @@ export const outboxPersistenceError = ( return failure; }; -export const getOutboxPersistenceCause = ( - failure: OutboxPersistenceError, -): Cause.Cause | undefined => { - const cause = - PERSISTENCE_CAUSE_PROPERTY in failure ? failure[PERSISTENCE_CAUSE_PROPERTY] : undefined; - return cause === undefined ? undefined : Cause.die(cause); -}; - export const sanitizeOutboxErrorMessage = (message: string): string => message .replaceAll(/[\r\n\t]+/gu, ' ') diff --git a/app/packages/core-runtime/src/outbox/process.ts b/app/packages/core-runtime/src/outbox/process.ts index 1ee120716..253ddc6d8 100644 --- a/app/packages/core-runtime/src/outbox/process.ts +++ b/app/packages/core-runtime/src/outbox/process.ts @@ -22,7 +22,7 @@ import { parseOutboxPollingConfig, runOutboxPollingLoop } from './poller.ts'; import type { RunOutboxPollingLoopInput } from './poller.ts'; import type { OutboxRuntime } from './runtime.ts'; -export const ShutdownSignalSchema = Schema.Literals(['SIGINT', 'SIGTERM']); +const ShutdownSignalSchema = Schema.Literals(['SIGINT', 'SIGTERM']); export type ShutdownSignal = typeof ShutdownSignalSchema.Type; export interface RunOutboxWorkerProcessInput< diff --git a/app/packages/core-runtime/src/outbox/repository.ts b/app/packages/core-runtime/src/outbox/repository.ts index ad06f54ff..930d64eec 100644 --- a/app/packages/core-runtime/src/outbox/repository.ts +++ b/app/packages/core-runtime/src/outbox/repository.ts @@ -43,7 +43,7 @@ const withOptionalProperty = < trailing: Trailing, ) => (condition ? { ...base, [key]: value, ...trailing } : { ...base, ...trailing }); const BACKGROUND_ELIGIBLE_STATES = tenantStatesAllowingAccess('background'); -export interface OutboxMatchResult { +interface OutboxMatchResult { readonly deliveriesCreated: number; readonly messagesMatched: number; } @@ -64,7 +64,7 @@ export interface OutboxClaim { readonly topic: string; readonly workerKey: string; } -export const OutboxFailureStatusSchema = Schema.Literals(['dead', 'pending']); +const OutboxFailureStatusSchema = Schema.Literals(['dead', 'pending']); export type OutboxFailureStatus = typeof OutboxFailureStatusSchema.Type; export interface OutboxRepositoryService { readonly claimNext: ( @@ -104,6 +104,20 @@ const streamKeyFor = (producerModuleKey: string, topic: string): string => `${producerModuleKey}:${topic}`; const addMilliseconds = (date: Date, milliseconds: number): Date => DateTime.toDateUtc(DateTime.addDuration(DateTime.makeUnsafe(date), milliseconds)); +const loadClaimCorrelationId = Effect.fnUntraced(function* loadClaimCorrelationId( + transaction: CoreTransaction, + actionInvocationId: string | null, +) { + if (actionInvocationId === null) { + return null; + } + const [invocation] = yield* transaction + .select({ correlationId: actionInvocations.correlationId }) + .from(actionInvocations) + .where(eq(actionInvocations.actionInvocationId, actionInvocationId)); + return invocation?.correlationId; +}); + export const makeOutboxRepository = (executor: CoreDatabaseExecutor): OutboxRepositoryService => ({ claimNext: (registrations, claimOwner, now) => { if (registrations.length === 0) { @@ -230,14 +244,10 @@ export const makeOutboxRepository = (executor: CoreDatabaseExecutor): OutboxRepo reason: 'Attempt insert returned no row', }); } - const [invocation] = - candidate.actionInvocationId === null - ? [] - : yield* transaction - .select({ correlationId: actionInvocations.correlationId }) - .from(actionInvocations) - .where(eq(actionInvocations.actionInvocationId, candidate.actionInvocationId)); - const correlationId = invocation?.correlationId; + const correlationId = yield* loadClaimCorrelationId( + transaction, + candidate.actionInvocationId, + ); return Option.some( withOptionalProperty( { diff --git a/app/packages/core-runtime/src/outbox/runtime.ts b/app/packages/core-runtime/src/outbox/runtime.ts index a7fe9bc42..69e27c5c8 100644 --- a/app/packages/core-runtime/src/outbox/runtime.ts +++ b/app/packages/core-runtime/src/outbox/runtime.ts @@ -17,10 +17,9 @@ import { import { OutboxHandlerExecutionError, OutboxPayloadDecodeError, - OutboxPersistenceError, OutboxWorkerDescriptorError, } from './errors.ts'; -import type { OutboxClaimLostError } from './errors.ts'; +import type { OutboxClaimLostError, OutboxPersistenceError } from './errors.ts'; import { OutboxRepository } from './repository.ts'; import type { OutboxClaim, OutboxRepositoryService as OutboxRepositoryPort } from './repository.ts'; @@ -101,7 +100,7 @@ const validateCycleInput = Effect.fn('OutboxRuntime.validateCycleInput')( } const registrations = yield* Effect.try({ catch: (error) => - error instanceof OutboxWorkerDescriptorError + Schema.is(OutboxWorkerDescriptorError)(error) ? error : descriptorFailure('The Outbox Worker descriptor set is invalid'), try: () => validateOutboxWorkerRegistrations(input.registrations), @@ -273,6 +272,30 @@ const matchMessagesWithRepository = Effect.fn('makeOutboxRuntime.matchMessages') }, ); +const failOutboxDelivery = Effect.fn('OutboxRuntime.failDelivery')( + function* failOutboxDeliveryEffect( + repository: OutboxRepositoryPort, + claim: OutboxClaim, + now: Date, + state: OutboxCycleProgress, + reason: string, + outcome: string, + ) { + const status = yield* repository.fail(claim, reason, now).pipe( + Effect.catchTag('OutboxPersistenceError', (error) => + Effect.andThen(logUnexpectedPersistence(claim), Effect.fail(error)), + ), + (effect) => withOutcomeSpan(effect, claim, outcome), + ); + return { + ...state, + dead: state.dead + (status === 'dead' ? 1 : 0), + failed: state.failed + 1, + retried: state.retried + (status === 'pending' ? 1 : 0), + }; + }, +); + const processNextOutboxDelivery = Effect.fn('makeOutboxRuntime.processNextDelivery')( function* processNextOutboxDeliveryEffect( repository: OutboxRepositoryPort, @@ -301,18 +324,14 @@ const processNextOutboxDelivery = Effect.fn('makeOutboxRuntime.processNextDelive code: 'outbox_payload_invalid', reason: 'The Outbox Message payload does not match its published schema', }); - const status = yield* repository.fail(claim, decodeError.reason, execution.now).pipe( - Effect.tapError((error) => - error instanceof OutboxPersistenceError ? logUnexpectedPersistence(claim) : Effect.void, - ), - (effect) => withOutcomeSpan(effect, claim, 'payload_decode_failure'), + return yield* failOutboxDelivery( + repository, + claim, + execution.now, + claimedState, + decodeError.reason, + 'payload_decode_failure', ); - return { - ...claimedState, - dead: claimedState.dead + (status === 'dead' ? 1 : 0), - failed: claimedState.failed + 1, - retried: claimedState.retried + (status === 'pending' ? 1 : 0), - }; } const handler = getOutboxWorkerHandler(registration); @@ -341,23 +360,19 @@ const processNextOutboxDelivery = Effect.fn('makeOutboxRuntime.processNextDelive ? 'The Outbox Worker handler failed unexpectedly' : 'The Outbox Worker handler returned a declared failure', }); - const status = yield* repository.fail(claim, executionError.reason, execution.now).pipe( - Effect.tapError((error) => - error instanceof OutboxPersistenceError ? logUnexpectedPersistence(claim) : Effect.void, - ), - (effect) => withOutcomeSpan(effect, claim, 'handler_failure'), + return yield* failOutboxDelivery( + repository, + claim, + execution.now, + claimedState, + executionError.reason, + 'handler_failure', ); - return { - ...claimedState, - dead: claimedState.dead + (status === 'dead' ? 1 : 0), - failed: claimedState.failed + 1, - retried: claimedState.retried + (status === 'pending' ? 1 : 0), - }; } yield* repository.complete(claim, execution.now).pipe( - Effect.tapError((error) => - error instanceof OutboxPersistenceError ? logUnexpectedPersistence(claim) : Effect.void, + Effect.catchTag('OutboxPersistenceError', (error) => + Effect.andThen(logUnexpectedPersistence(claim), Effect.fail(error)), ), (effect) => withOutcomeSpan(effect, claim, 'success'), ); diff --git a/app/packages/core-runtime/src/permissions/config.ts b/app/packages/core-runtime/src/permissions/config.ts index d40b74275..d6787cac8 100644 --- a/app/packages/core-runtime/src/permissions/config.ts +++ b/app/packages/core-runtime/src/permissions/config.ts @@ -1,15 +1,5 @@ -import { - Config, - ConfigProvider, - Context, - Effect, - Layer, - Match, - Option, - Predicate, - Redacted, - Schema, -} from 'effect'; +import { Config, ConfigProvider, Effect, Option, Redacted, Schema } from 'effect'; +import { loadDotEnvProvider } from '../environment/dotenv-provider.ts'; import { APP_ENV_PATH } from '../environment/workspace-environment.ts'; import { SpiceDbConfigError } from './config-error.ts'; @@ -36,10 +26,6 @@ const makeSpiceDbConfigValue = (settings: { export type SpiceDbConfigValue = ReturnType & Partial>; -export class SpiceDbConfig extends Context.Service()( - '@app/core-runtime/permissions/config/SpiceDbConfig', -) {} - export type SpiceDbEnvironment = Readonly< Partial< Record< @@ -177,41 +163,6 @@ export const parseSpiceDbConfig = ( ): Effect.Effect => parseSpiceDbConfigWith(ConfigProvider.fromUnknown(environment, { preserveEmptyStrings: true })); -const nodeFileSystem = process.getBuiltinModule('node:fs'); - -const loadFileConfigProvider = Effect.fn('Config.loadFileConfigProvider')(function* loadProvider( - envPath: string, -) { - const result = yield* Effect.sync(() => { - try { - return { - contents: nodeFileSystem.readFileSync(envPath, 'utf-8'), - status: 'loaded', - } as const; - } catch (error) { - if ( - Predicate.hasProperty(error, 'code') && - (error.code === 'ENOENT' || error.code === 'NOT_FOUND_DOTENV_ENVIRONMENT') - ) { - return { status: 'missing' } as const; - } - return { - error: configFailureWithCause(`Unable to load the root environment from ${envPath}`, error), - status: 'failed', - } as const; - } - }); - - return yield* Match.value(result).pipe( - Match.discriminatorsExhaustive('status')({ - failed: ({ error }) => Effect.fail(error), - loaded: ({ contents }) => - Effect.succeed(ConfigProvider.fromDotEnvContents(contents, { preserveEmptyStrings: true })), - missing: () => Effect.succeed(ConfigProvider.fromUnknown({})), - }), - ); -}); - export const loadSpiceDbConfig = ( options: LoadSpiceDbConfigOptions = {}, ): Effect.Effect => { @@ -221,11 +172,10 @@ export const loadSpiceDbConfig = ( : ConfigProvider.fromUnknown(options.environment, { preserveEmptyStrings: true }); const envPath = options.envPath ?? SPICEDB_ROOT_ENV_PATH; - return loadFileConfigProvider(envPath).pipe( + return loadDotEnvProvider(envPath, configFailureWithCause).pipe( + Effect.withSpan('Config.loadFileConfigProvider'), Effect.flatMap((fileProvider) => parseSpiceDbConfigWith(ConfigProvider.orElse(environmentProvider, fileProvider)), ), ); }; - -export const SpiceDbConfigLive = Layer.effect(SpiceDbConfig, loadSpiceDbConfig()); diff --git a/app/packages/core-runtime/src/permissions/context-access.ts b/app/packages/core-runtime/src/permissions/context-access.ts index 3aac454a4..27efef050 100644 --- a/app/packages/core-runtime/src/permissions/context-access.ts +++ b/app/packages/core-runtime/src/permissions/context-access.ts @@ -13,7 +13,7 @@ import { loadSpiceDbConfig } from './config.ts'; import type { SpiceDbConfigValue } from './config.ts'; import type { SpiceDbConfigError } from './config-error.ts'; -export const ContextAccessDecisionSchema = Schema.Literals(['allowed', 'denied', 'unavailable']); +const ContextAccessDecisionSchema = Schema.Literals(['allowed', 'denied', 'unavailable']); export type ContextAccessDecision = typeof ContextAccessDecisionSchema.Type; export const TENANT_PERMISSION_KEYS = [ @@ -156,15 +156,19 @@ const makeRequestItem = (item: BatchItem, principalId: string) => subject: principalReference(principalId), }); +const sameObjectReference = ( + expected: v1.ObjectReference | undefined, + actual: v1.ObjectReference | undefined, +): boolean => + actual?.objectId === expected?.objectId && actual?.objectType === expected?.objectType; + const sameRequest = ( expected: v1.CheckBulkPermissionsRequestItem, actual: v1.CheckBulkPermissionsRequestItem | undefined, ): boolean => actual?.permission === expected.permission && - actual.resource?.objectId === expected.resource?.objectId && - actual.resource?.objectType === expected.resource?.objectType && - actual.subject?.object?.objectId === expected.subject?.object?.objectId && - actual.subject?.object?.objectType === expected.subject?.object?.objectType; + sameObjectReference(expected.resource, actual.resource) && + sameObjectReference(expected.subject?.object, actual.subject?.object); export const makeContextAccess = (client: SpiceDbPermissionClient): ContextAccessService => { const checkBatch = ( diff --git a/app/packages/core-runtime/src/permissions/service.ts b/app/packages/core-runtime/src/permissions/service.ts index 8efec3881..ecc5a1e06 100644 --- a/app/packages/core-runtime/src/permissions/service.ts +++ b/app/packages/core-runtime/src/permissions/service.ts @@ -30,7 +30,7 @@ export const SPICEDB_EXECUTE_PERMISSION = 'execute'; export const toSpiceDbActionObjectId = (actionKey: string): string => `ak_${Buffer.from(actionKey, 'utf-8').toString('base64url')}`; -export const ActionPermissionDecisionSchema = Schema.Literals(['allowed', 'denied']); +const ActionPermissionDecisionSchema = Schema.Literals(['allowed', 'denied']); export type ActionPermissionDecision = typeof ActionPermissionDecisionSchema.Type; interface ActionPermissionTargetInput { @@ -140,7 +140,7 @@ const runCheck = ( .checkPermission(request) .pipe(Effect.mapError(checkFailure), Effect.flatMap(classifyPermissionship)); -export interface ActionPermissionRolloutOptions { +interface ActionPermissionRolloutOptions { readonly emit: (event: AuthorizationWouldDenyEvent) => void; readonly nowEpochMs: () => number; readonly rollout: AuthorizationRolloutDecisionOptions['contract']; diff --git a/app/packages/core-runtime/src/reads/context.ts b/app/packages/core-runtime/src/reads/context.ts index f371ab92b..d33d1de1b 100644 --- a/app/packages/core-runtime/src/reads/context.ts +++ b/app/packages/core-runtime/src/reads/context.ts @@ -62,6 +62,27 @@ const ReadEvidenceCandidateSchema = Schema.Struct({ resultFingerprintSchema: Schema.optional(Schema.Unknown), }); +type ReadEvidenceCandidate = typeof ReadEvidenceCandidateSchema.Type; + +const isValidResultCount = Schema.is( + Schema.Finite.check(Schema.isInt(), Schema.isBetween({ maximum: 2_147_483_647, minimum: 0 })), +); + +const hasInvalidFingerprintHash = ( + value: ReadEvidenceCandidate['resultFingerprintHash'], +): boolean => value !== undefined && (!Predicate.isString(value) || !sha256.test(value)); + +const hasInvalidFingerprintSchema = ( + value: ReadEvidenceCandidate['resultFingerprintSchema'], +): boolean => + value !== undefined && (!Predicate.isString(value) || value.length === 0 || value.length > 300); + +const hasInvalidHashEvidence = (record: ReadEvidenceCandidate): boolean => + record.queryHash !== undefined || + (record.resultFingerprintHash === undefined) !== (record.resultFingerprintSchema === undefined) || + hasInvalidFingerprintHash(record.resultFingerprintHash) || + hasInvalidFingerprintSchema(record.resultFingerprintSchema); + export const validateReadEvidenceMetadata = ( captureMode: ReadEvidenceCaptureMode, value: Value, @@ -79,10 +100,7 @@ export const validateReadEvidenceMetadata = ( } = record; if ( Object.keys(record).some((key) => !evidenceKeys.has(key)) || - !Predicate.isNumber(resultCount) || - !Number.isSafeInteger(resultCount) || - resultCount < 0 || - resultCount > 2_147_483_647 + !isValidResultCount(resultCount) ) { return Effect.fail(invalidEvidence()); } @@ -94,17 +112,7 @@ export const validateReadEvidenceMetadata = ( ) { return Effect.fail(invalidEvidence()); } - if ( - captureMode === 'hash_only' && - (queryHash !== undefined || - (fingerprintHash === undefined) !== (fingerprintSchema === undefined) || - (fingerprintHash !== undefined && - (!Predicate.isString(fingerprintHash) || !sha256.test(fingerprintHash))) || - (fingerprintSchema !== undefined && - (!Predicate.isString(fingerprintSchema) || - fingerprintSchema.length === 0 || - fingerprintSchema.length > 300))) - ) { + if (captureMode === 'hash_only' && hasInvalidHashEvidence(record)) { return Effect.fail(invalidEvidence()); } return Effect.succeed( diff --git a/app/packages/core-runtime/src/reads/definition.ts b/app/packages/core-runtime/src/reads/definition.ts index adc46af5c..76a63fffa 100644 --- a/app/packages/core-runtime/src/reads/definition.ts +++ b/app/packages/core-runtime/src/reads/definition.ts @@ -205,6 +205,29 @@ export type ReadRegistration< readonly [registrationMarker]: true; }; +const validateReadVocabulary = ( + descriptor: ReadDescriptor< + Schema.ConstraintDecoder, + Schema.ConstraintDecoder, + string + >, + permissionTargetResolver: ReadPermissionTargetResolver, + resultPermissionTargetResolver: ReadResultPermissionTargetResolver | undefined, +): void => { + if ( + !READ_ACCESS_KINDS.includes(descriptor.accessKind) || + !READ_EVIDENCE_CAPTURE_MODES.includes(descriptor.evidencePolicy.captureMode) || + !READ_PERMISSION_TARGETS.includes(descriptor.permissionTarget) || + (descriptor.accessKind === 'search' && !Predicate.isFunction(resultPermissionTargetResolver)) || + !Predicate.isFunction(permissionTargetResolver) || + descriptor.evidencePolicy.policyKey.length === 0 || + descriptor.readKey.length === 0 || + descriptor.schemaVersion.length === 0 + ) { + return failReadDefinition('Read metadata must use the closed governed-read vocabulary'); + } +}; + export const defineRead = < InputSchema extends Schema.ConstraintDecoder, ResultSchema extends Schema.ConstraintDecoder, @@ -230,18 +253,7 @@ export const defineRead = < executablePolicies = [], ] = definition; validateReadDescriptorInput(descriptor); - if ( - !READ_ACCESS_KINDS.includes(descriptor.accessKind) || - !READ_EVIDENCE_CAPTURE_MODES.includes(descriptor.evidencePolicy.captureMode) || - !READ_PERMISSION_TARGETS.includes(descriptor.permissionTarget) || - (descriptor.accessKind === 'search' && !Predicate.isFunction(resultPermissionTargetResolver)) || - !Predicate.isFunction(permissionTargetResolver) || - descriptor.evidencePolicy.policyKey.length === 0 || - descriptor.readKey.length === 0 || - descriptor.schemaVersion.length === 0 - ) { - return failReadDefinition('Read metadata must use the closed governed-read vocabulary'); - } + validateReadVocabulary(descriptor, permissionTargetResolver, resultPermissionTargetResolver); if ( !Array.isArray(descriptor.policies) || descriptor.policies.some( diff --git a/app/packages/core-runtime/src/reads/runtime.ts b/app/packages/core-runtime/src/reads/runtime.ts index fea172fbd..fdbc688e3 100644 --- a/app/packages/core-runtime/src/reads/runtime.ts +++ b/app/packages/core-runtime/src/reads/runtime.ts @@ -95,8 +95,8 @@ export interface ReadRuntimeOptions { } const stableTargetKey = (value: string): boolean => value.length > 0 && value.length <= 300; -export const PermissionDecisionSchema = Schema.Literals(['allowed', 'denied', 'unavailable']); -export type PermissionDecision = typeof PermissionDecisionSchema.Type; +const PermissionDecisionSchema = Schema.Literals(['allowed', 'denied', 'unavailable']); +type PermissionDecision = typeof PermissionDecisionSchema.Type; const atomicTargetIsValid = (target: AtomicResolvedReadPermissionTarget): boolean => { if (target.kind === 'tenant') { return true; @@ -270,6 +270,34 @@ const preserveFailureCause = (failure: Failure, cause: u writable: false, }); +const checkTenantResultPermission = Effect.fnUntraced(function* checkTenantResultPermission< + AccessValue extends (typeof ContextAccess)['Service'], +>( + contextAccess: AccessValue, + scope: OperationalScope, + permissionTarget: Extract, +) { + const decisions = yield* contextAccess.tenants({ + permission: permissionTarget.permission, + principalId: scope.principalId, + tenantIds: [scope.tenantId], + }); + const decision = decisionFor(decisions, scope.tenantId); + if (decision === 'unavailable') { + return yield* new ReadPermissionUnavailable({ + code: 'read_permission_unavailable', + reason: 'Read result authorization is temporarily unavailable', + }); + } + if (decision === 'denied') { + return yield* new ReadPermissionDenied({ + code: 'read_permission_denied', + reason: 'The read result contains a forbidden resource', + }); + } + return yield* Effect.void; +}); + const checkResultPermissions = Effect.fn('ReadRuntime.checkResultPermissions')( function* checkResultPermissionsEffect< Result, @@ -316,25 +344,7 @@ const checkResultPermissions = Effect.fn('ReadRuntime.checkResultPermissions')( return yield* Effect.void; } if (permissionTarget.kind === 'tenant') { - const decisions = yield* contextAccess.tenants({ - permission: permissionTarget.permission, - principalId: scope.principalId, - tenantIds: [scope.tenantId], - }); - const decision = decisionFor(decisions, scope.tenantId); - if (decision === 'unavailable') { - return yield* new ReadPermissionUnavailable({ - code: 'read_permission_unavailable', - reason: 'Read result authorization is temporarily unavailable', - }); - } - if (decision === 'denied') { - return yield* new ReadPermissionDenied({ - code: 'read_permission_denied', - reason: 'The read result contains a forbidden resource', - }); - } - return yield* Effect.void; + return yield* checkTenantResultPermission(contextAccess, scope, permissionTarget); } if (scope.legalEntityId === undefined) { return yield* new ReadHandlerExecutionError({ diff --git a/app/packages/core-runtime/src/search/persistence.ts b/app/packages/core-runtime/src/search/persistence.ts index 28c96cd6a..60cd8acc1 100644 --- a/app/packages/core-runtime/src/search/persistence.ts +++ b/app/packages/core-runtime/src/search/persistence.ts @@ -661,7 +661,7 @@ export const CoreSearchProjectionStoreLive = Layer.effect( /** Fully composed production query layer; owner adapters never import Core database capabilities. */ export const CoreSearchQueryRuntimeLive = Layer.effect( CoreSearchQueryRuntime, - Effect.gen(function* makeCoreSearchQueryRuntimeLive() { + Effect.gen(function* createCoreSearchQueryRuntimeLive() { const database = yield* CoreDatabase; const store = makePostgresCoreSearchProjectionStore(database); return createCoreSearchQueryRuntime(store); diff --git a/app/packages/core-runtime/src/search/projection.ts b/app/packages/core-runtime/src/search/projection.ts index c0c882552..e9a7872c8 100644 --- a/app/packages/core-runtime/src/search/projection.ts +++ b/app/packages/core-runtime/src/search/projection.ts @@ -241,34 +241,46 @@ const invalidPeriod = ({ return from === undefined || (validTo !== undefined && (to === undefined || to <= from)); }; +const hasForeignDocumentReference = ( + document: CoreSearchProjectionDocument, + tenant: string, +): boolean => + [document.matchedRef, document.subjectRef, document.matchedSubjectRef].some( + (ref) => ref !== undefined && ref.tenantId !== tenant, + ); + +const hasInvalidDocumentFacets = (document: CoreSearchProjectionDocument): boolean => + !hasUniqueKeys(document.facets) || + !hasUniqueKeys(document.metadata) || + document.facets.some( + ({ values }) => values.length === 0 || new Set(values).size !== values.length, + ); + +const hasInvalidDocumentPeriods = (document: CoreSearchProjectionDocument): boolean => + (document.temporalFacets ?? []).some(invalidPeriod) || + (document.temporalSearchableText ?? []).some(invalidPeriod); + +const hasInvalidDocumentAliases = ( + document: CoreSearchProjectionDocument, + tenant: string, +): boolean => + (document.aliases ?? []).some( + (alias) => + alias.ref.tenantId !== tenant || (alias.temporalSearchableText ?? []).some(invalidPeriod), + ); + const validateDocument = ( document: CoreSearchProjectionDocument, - expected: Readonly<{ - readonly moduleId: string; - readonly resourceType: string; - readonly tenantId: string; - }>, + expected: Readonly<{ moduleId: string; resourceType: string; tenantId: string }>, ): Result.Result => { if ( document.ref.tenantId !== expected.tenantId || document.ref.moduleId !== expected.moduleId || document.ref.resourceType !== expected.resourceType || - (document.matchedRef !== undefined && document.matchedRef.tenantId !== expected.tenantId) || - (document.subjectRef !== undefined && document.subjectRef.tenantId !== expected.tenantId) || - (document.matchedSubjectRef !== undefined && - document.matchedSubjectRef.tenantId !== expected.tenantId) || - !hasUniqueKeys(document.facets) || - !hasUniqueKeys(document.metadata) || - document.facets.some( - ({ values }) => values.length === 0 || new Set(values).size !== values.length, - ) || - (document.temporalFacets ?? []).some(invalidPeriod) || - (document.temporalSearchableText ?? []).some(invalidPeriod) || - (document.aliases ?? []).some( - (alias) => - alias.ref.tenantId !== expected.tenantId || - (alias.temporalSearchableText ?? []).some(invalidPeriod), - ) + hasForeignDocumentReference(document, expected.tenantId) || + hasInvalidDocumentFacets(document) || + hasInvalidDocumentPeriods(document) || + hasInvalidDocumentAliases(document, expected.tenantId) ) { return Result.fail(invalid('Core Search replacement contains an inconsistent document')); } @@ -388,11 +400,94 @@ const decodeReplacementEffect = (input: UnparsedCoreSearchInput) => Effect.flatMap((replacement) => Effect.fromResult(validateReplacement(replacement))), ); +type Stored = Readonly<{ + readonly document?: CoreSearchProjectionDocument; + readonly projectionVersion: string; +}>; + +const sameStoredDocument = ( + current: Stored, + next: CoreSearchProjectionDocument | undefined, +): boolean => + current.document === undefined + ? next === undefined + : next !== undefined && projectionDocumentEquivalence(current.document, next); + +const shouldApplyMutation = ( + current: Stored | undefined, + next: Stored, +): Result.Result => { + if (current === undefined) { + return Result.succeed(true); + } + const order = BigInt(next.projectionVersion) - BigInt(current.projectionVersion); + if (order < 0n) { + return Result.succeed(false); + } + if (order > 0n) { + return Result.succeed(true); + } + return sameStoredDocument(current, next.document) + ? Result.succeed(false) + : Result.fail(invalid('Core Search mutation reuses a version for different content')); +}; + +const shouldReplaceProjection = ( + prior: Readonly<{ fingerprint: string; version: bigint }> | undefined, + version: bigint, + fingerprint: string, +): Result.Result => { + if (prior === undefined || version > prior.version) { + return Result.succeed(true); + } + if (version < prior.version) { + return Result.succeed(false); + } + return fingerprint === prior.fingerprint + ? Result.succeed(false) + : Result.fail(invalid('Core Search rebuild reuses a version for different content')); +}; + +const mergeReplacementDocuments = ( + current: Map, + documents: readonly CoreSearchProjectionDocument[], +): Result.Result => { + for (const document of documents) { + const existing = current.get(document.ref.resourceId); + if ( + existing === undefined || + BigInt(existing.projectionVersion) < BigInt(document.projectionVersion) + ) { + current.set(document.ref.resourceId, { + document, + projectionVersion: document.projectionVersion, + }); + } else if ( + existing.projectionVersion === document.projectionVersion && + !sameStoredDocument(existing, document) + ) { + return Result.fail(invalid('Core Search rebuild reuses a version for different content')); + } + } + return Result.succeed(true); +}; + +const retireMissingDocuments = ( + current: Map, + replacement: CoreSearchProjectionReplacement, +): void => { + const nextIds = new Set(replacement.documents.map(({ ref }) => ref.resourceId)); + for (const [id, existing] of current) { + if ( + !nextIds.has(id) && + BigInt(existing.projectionVersion) < BigInt(replacement.rebuildVersion) + ) { + current.set(id, { projectionVersion: replacement.rebuildVersion }); + } + } +}; + export const makeInMemoryCoreSearchProjectionStore = (): CoreSearchProjectionStoreService => { - type Stored = Readonly<{ - readonly document?: CoreSearchProjectionDocument; - readonly projectionVersion: string; - }>; const units = new Map>(); const rebuilds = new Map(); const apply: CoreSearchProjectionStoreService['apply'] = Effect.fn( @@ -408,30 +503,17 @@ export const makeInMemoryCoreSearchProjectionStore = (): CoreSearchProjectionSto return yield* Effect.void; } const unit = units.get(unitKey) ?? new Map(); - const current = unit.get(ref.resourceId); - if (current !== undefined) { - const order = BigInt(version) - BigInt(current.projectionVersion); - if (order < 0n) { - return yield* Effect.void; - } - if (order === 0n) { - const next = mutation.kind === 'upsert' ? mutation.document : undefined; - const unchanged = - current.document === undefined - ? next === undefined - : next !== undefined && projectionDocumentEquivalence(current.document, next); - if (!unchanged) { - return yield* invalid('Core Search mutation reuses a version for different content'); - } - return yield* Effect.void; - } - } - unit.set( - ref.resourceId, + const next: Stored = mutation.kind === 'upsert' ? { document: mutation.document, projectionVersion: version } - : { projectionVersion: version }, + : { projectionVersion: version }; + const shouldApply = yield* Effect.fromResult( + shouldApplyMutation(unit.get(ref.resourceId), next), ); + if (!shouldApply) { + return yield* Effect.void; + } + unit.set(ref.resourceId, next); units.set(unitKey, unit); return yield* Effect.void; }); @@ -455,50 +537,15 @@ export const makeInMemoryCoreSearchProjectionStore = (): CoreSearchProjectionSto const prior = rebuilds.get(unitKey); const version = BigInt(replacement.rebuildVersion); const fingerprint = coreSearchReplacementFingerprint(replacement); - if (prior !== undefined) { - if (version < prior.version) { - return yield* Effect.void; - } - if (version === prior.version) { - if (fingerprint !== prior.fingerprint) { - return yield* invalid('Core Search rebuild reuses a version for different content'); - } - return yield* Effect.void; - } + const shouldReplace = yield* Effect.fromResult( + shouldReplaceProjection(prior, version, fingerprint), + ); + if (!shouldReplace) { + return yield* Effect.void; } const current = new Map(units.get(unitKey)); - const nextIds = new Set(replacement.documents.map(({ ref }) => ref.resourceId)); - let divergentDocumentVersion = false; - for (const document of replacement.documents) { - const existing = current.get(document.ref.resourceId); - if ( - existing === undefined || - BigInt(existing.projectionVersion) < BigInt(document.projectionVersion) - ) { - current.set(document.ref.resourceId, { - document, - projectionVersion: document.projectionVersion, - }); - } else if ( - existing.projectionVersion === document.projectionVersion && - (existing.document === undefined || - !projectionDocumentEquivalence(existing.document, document)) - ) { - divergentDocumentVersion = true; - break; - } - } - if (divergentDocumentVersion) { - return yield* invalid('Core Search rebuild reuses a version for different content'); - } - for (const [id, existing] of current) { - if ( - !nextIds.has(id) && - BigInt(existing.projectionVersion) < BigInt(replacement.rebuildVersion) - ) { - current.set(id, { projectionVersion: replacement.rebuildVersion }); - } - } + yield* Effect.fromResult(mergeReplacementDocuments(current, replacement.documents)); + retireMissingDocuments(current, replacement); units.set(unitKey, current); rebuilds.set(unitKey, { fingerprint, version }); return yield* Effect.void; @@ -506,6 +553,21 @@ export const makeInMemoryCoreSearchProjectionStore = (): CoreSearchProjectionSto return Object.freeze({ apply, queryCandidates, replace }); }; +const isEffectiveTemporalFacet = ( + temporal: CoreSearchTemporalFacet, + key: string, + effectiveAt: number, +): boolean => { + const from = toEpochMillis(temporal.validFrom); + const to = temporal.validTo === undefined ? undefined : toEpochMillis(temporal.validTo); + return ( + temporal.key === key && + from !== undefined && + from <= effectiveAt && + (to === undefined || effectiveAt < to) + ); +}; + const matchesFacets = ( document: CoreSearchProjectionDocument, requested: readonly CoreSearchFacet[], @@ -515,14 +577,7 @@ const matchesFacets = ( const available = new Set(document.facets.find((candidate) => candidate.key === key)?.values); if (effectiveAt !== undefined) { for (const temporal of document.temporalFacets ?? []) { - const from = toEpochMillis(temporal.validFrom); - const to = temporal.validTo === undefined ? undefined : toEpochMillis(temporal.validTo); - if ( - temporal.key === key && - from !== undefined && - from <= effectiveAt && - (to === undefined || effectiveAt < to) - ) { + if (isEffectiveTemporalFacet(temporal, key, effectiveAt)) { available.add(temporal.value); } } @@ -601,7 +656,7 @@ const matchDocument = ( : { ...hit, matchedSubjectRef: alias.ref }; }; -export const makeCoreSearchQueryRuntime = ( +export const createCoreSearchQueryRuntime = ( store: CoreSearchProjectionStorePort, ): CoreSearchQueryRuntimeService => { const search: CoreSearchQueryRuntimeService['search'] = Effect.fn( @@ -640,5 +695,3 @@ export const makeCoreSearchQueryRuntime = ( }); return Object.freeze({ search }); }; - -export const createCoreSearchQueryRuntime = makeCoreSearchQueryRuntime; diff --git a/app/packages/core-runtime/src/testing/module-contract.ts b/app/packages/core-runtime/src/testing/module-contract.ts new file mode 100644 index 000000000..027c16c0c --- /dev/null +++ b/app/packages/core-runtime/src/testing/module-contract.ts @@ -0,0 +1,59 @@ +import type { OntosModuleDeploymentContract } from '../modules/manifest.ts'; + +interface ModuleContractFixtureOptions { + readonly appId: string; + readonly buildMarker?: string; + readonly description?: string; + readonly displayName?: string; + readonly moduleId: string; + readonly outboxSubscriptions?: readonly Subscription[]; + readonly supportedStates?: OntosModuleDeploymentContract['manifest']['activation']['supportedStates']; +} + +export const makeModuleContractFixture = ({ + appId, + buildMarker = `${appId}-build`, + moduleId, + description = `${moduleId} module`, + displayName = moduleId, + outboxSubscriptions = [], + supportedStates = ['inactive', 'active'], +}: ModuleContractFixtureOptions) => ({ + deployment: { appId, buildMarker }, + manifest: { + activation: { + defaultState: 'inactive' as const, + preservesHistoryWhenInactive: true as const, + scope: 'tenant' as const, + supportedStates, + }, + module: { + description, + displayName, + id: moduleId, + implementedAs: 'ultramodern_microvertical' as const, + kind: 'business_module' as const, + }, + publicSurface: { + actions: [], + api: [], + components: [], + events: [], + reports: [], + resourceTypes: [], + search: [], + shellContributions: { + mediaAttachments: [], + navigation: [], + pages: [], + publicComponents: [], + reports: [], + resourceDetails: [], + search: [], + timelines: [], + }, + }, + }, + runtime: { outboxSubscriptions }, + schemaVersion: '2' as const, +}); diff --git a/app/packages/core-runtime/tests/integration/action-runtime.test.ts b/app/packages/core-runtime/tests/integration/action-runtime.test.ts index 88e29e822..d56b7fbe4 100644 --- a/app/packages/core-runtime/tests/integration/action-runtime.test.ts +++ b/app/packages/core-runtime/tests/integration/action-runtime.test.ts @@ -1,3 +1,4 @@ +import { makeModuleContractFixture } from '../../src/testing/module-contract.ts'; import { runEffectTestPromise, runEffectTestSync } from '@app/core-runtime/testing/effect-runtime'; import { ConnectionError, SqlError, UnknownError } from 'effect/unstable/sql/SqlError'; import assert from 'node:assert/strict'; @@ -101,53 +102,22 @@ const transport = (idempotencyKey: string, targetResourceId = 'primary') => ({ targetResourceType: 'test-state', }); -const inventoryStockContract: OntosModuleDeploymentContract = { - deployment: { appId: 'inventory-stock', buildMarker: 'integration-test' }, - manifest: { - activation: { - defaultState: 'inactive', - preservesHistoryWhenInactive: true, - scope: 'tenant', - supportedStates: [ - 'inactive', - 'active', - 'read_only', - 'suspended', - 'quarantined', - 'deprecated', - 'archived', - ], - }, - module: { - description: 'Inventory integration fixture', - displayName: 'Inventory', - id: 'inventory.stock', - implementedAs: 'ultramodern_microvertical', - kind: 'business_module', - }, - publicSurface: { - actions: [], - api: [], - components: [], - events: [], - reports: [], - resourceTypes: [], - search: [], - shellContributions: { - mediaAttachments: [], - navigation: [], - pages: [], - publicComponents: [], - reports: [], - resourceDetails: [], - search: [], - timelines: [], - }, - }, - }, - runtime: { outboxSubscriptions: [] }, - schemaVersion: '2', -}; +const inventoryStockContract: OntosModuleDeploymentContract = makeModuleContractFixture({ + appId: 'inventory-stock', + buildMarker: 'integration-test', + description: 'Inventory integration fixture', + displayName: 'Inventory', + moduleId: 'inventory.stock', + supportedStates: [ + 'inactive', + 'active', + 'read_only', + 'suspended', + 'quarantined', + 'deprecated', + 'archived', + ], +}); const inventoryInstalledCatalog: InstalledModuleCatalog = Object.freeze({ contracts: Object.freeze([inventoryStockContract]), diff --git a/app/packages/core-runtime/tests/integration/search-persistence.test.ts b/app/packages/core-runtime/tests/integration/search-persistence.test.ts index 4cd1c0c22..bcaa9a978 100644 --- a/app/packages/core-runtime/tests/integration/search-persistence.test.ts +++ b/app/packages/core-runtime/tests/integration/search-persistence.test.ts @@ -12,7 +12,7 @@ import { Pool } from 'pg'; import { loadDatabaseConnectionPair } from '../../src/db/config.ts'; import { coreRelations } from '../../src/db/schema.ts'; import { makePostgresCoreSearchProjectionStore } from '../../src/search/persistence.ts'; -import { makeCoreSearchQueryRuntime } from '../../src/search/projection.ts'; +import { createCoreSearchQueryRuntime } from '../../src/search/projection.ts'; import { makeTestDatabaseFromPool } from '../support/database.ts'; import { runEffectTestSync as runNativeSync } from '../support/effect-runtime.ts'; @@ -61,7 +61,7 @@ effectTest( NativeScope.provide(nativeDatabaseScope), ), }); - const search = makeCoreSearchQueryRuntime(store); + const search = createCoreSearchQueryRuntime(store); const partyDocument = (resourceId: string, projectionVersion: string, title: string) => ({ aliases: [ { @@ -227,7 +227,7 @@ effectTest( ), }); const floorSearch = () => - makeCoreSearchQueryRuntime(restarted).search({ + createCoreSearchQueryRuntime(restarted).search({ includeArchived: false, moduleId: floorRef.moduleId, query: 'unseen', diff --git a/app/packages/core-runtime/tests/integration/tenant-module-state.test.ts b/app/packages/core-runtime/tests/integration/tenant-module-state.test.ts index ac84a1dc2..9b133d3b9 100644 --- a/app/packages/core-runtime/tests/integration/tenant-module-state.test.ts +++ b/app/packages/core-runtime/tests/integration/tenant-module-state.test.ts @@ -1,3 +1,4 @@ +import { makeModuleContractFixture } from '../../src/testing/module-contract.ts'; import { SqlError, UnknownError } from 'effect/unstable/sql/SqlError'; import { runEffectTestPromise } from '@app/core-runtime/testing/effect-runtime'; import { and, asc, eq, inArray } from 'drizzle-orm'; @@ -40,53 +41,23 @@ const tenantIds = [tenantOne, tenantTwo] as const; type DatabaseService = Parameters[0]; -const installedContract = (moduleId: string): OntosModuleDeploymentContract => ({ - deployment: { appId: 'test-module', buildMarker: 'test-build' }, - manifest: { - activation: { - defaultState: 'inactive', - preservesHistoryWhenInactive: true, - scope: 'tenant', - supportedStates: [ - 'inactive', - 'active', - 'read_only', - 'suspended', - 'quarantined', - 'deprecated', - 'archived', - ], - }, - module: { - description: 'Integration test module', - displayName: 'Integration test module', - id: moduleId, - implementedAs: 'ultramodern_microvertical', - kind: 'business_module', - }, - publicSurface: { - actions: [], - api: [], - components: [], - events: [], - reports: [], - resourceTypes: [], - search: [], - shellContributions: { - mediaAttachments: [], - navigation: [], - pages: [], - publicComponents: [], - reports: [], - resourceDetails: [], - search: [], - timelines: [], - }, - }, - }, - runtime: { outboxSubscriptions: [] }, - schemaVersion: '2', -}); +const installedContract = (moduleId: string): OntosModuleDeploymentContract => + makeModuleContractFixture({ + appId: 'test-module', + buildMarker: 'test-build', + description: 'Integration test module', + displayName: 'Integration test module', + moduleId, + supportedStates: [ + 'inactive', + 'active', + 'read_only', + 'suspended', + 'quarantined', + 'deprecated', + 'archived', + ], + }); const noInstalledContracts: readonly OntosModuleDeploymentContract[] = Object.freeze([]); diff --git a/app/packages/core-runtime/tests/support/database.ts b/app/packages/core-runtime/tests/support/database.ts index 85c0e5a74..13383c39c 100644 --- a/app/packages/core-runtime/tests/support/database.ts +++ b/app/packages/core-runtime/tests/support/database.ts @@ -2,9 +2,9 @@ import type { Pool } from 'pg'; import { PgClient } from '@effect/sql-pg'; import type { AnyRelations } from 'drizzle-orm'; import { makeWithDefaults } from 'drizzle-orm/effect-postgres'; -import { Effect, Stream } from 'effect'; +import { Effect } from 'effect'; import { Reactivity } from 'effect/unstable/reactivity'; -import type { Connection } from 'effect/unstable/sql/SqlConnection'; +import { testSqlConnection } from './sql-connection.ts'; import type { SqlError } from 'effect/unstable/sql/SqlError'; import { coreRelations } from '../../src/db/schema.ts'; import { runEffectTestSync } from './effect-runtime.ts'; @@ -16,16 +16,7 @@ export const makeTestDatabase = ( runEffectTestSync( Effect.scoped( Effect.gen(function* makeNativeTestDatabase() { - const values = (sql: string, params: readonly unknown[]) => - execute(sql, params).pipe(Effect.map((rows) => rows.map(Object.values))); - const connection: Connection = { - execute, - executeRaw: execute, - executeStream: (sql, params) => Stream.fromIterableEffect(execute(sql, params)), - executeUnprepared: execute, - executeValues: values, - executeValuesUnprepared: values, - }; + const connection = testSqlConnection(execute); const reactivity = yield* Reactivity.make; const client = yield* PgClient.makeWith({ acquirer: Effect.succeed(connection), diff --git a/app/packages/core-runtime/tests/support/sql-connection.ts b/app/packages/core-runtime/tests/support/sql-connection.ts new file mode 100644 index 000000000..944740229 --- /dev/null +++ b/app/packages/core-runtime/tests/support/sql-connection.ts @@ -0,0 +1,19 @@ +import { Effect, Stream } from 'effect'; +import type { Connection } from 'effect/unstable/sql/SqlConnection'; +import type { SqlError } from 'effect/unstable/sql/SqlError'; + +export const testSqlConnection = ( + execute: (sql: string, params: readonly unknown[]) => Effect.Effect, +): Connection => { + const values = (sql: string, params: readonly unknown[]) => + execute(sql, params).pipe(Effect.map((rows) => rows.map(Object.values))); + const connection: Connection = { + execute, + executeRaw: execute, + executeStream: (sql, params) => Stream.fromIterableEffect(execute(sql, params)), + executeUnprepared: execute, + executeValues: values, + executeValuesUnprepared: values, + }; + return connection; +}; diff --git a/app/packages/core-runtime/tests/unit/action-definition.test.ts b/app/packages/core-runtime/tests/unit/action-definition.test.ts index 41b624853..34b0e42e6 100644 --- a/app/packages/core-runtime/tests/unit/action-definition.test.ts +++ b/app/packages/core-runtime/tests/unit/action-definition.test.ts @@ -16,26 +16,32 @@ import { defineTenantModuleEntrypoint, } from '../../src/modules/module-entrypoint.ts'; +const counterActionDescriptor = () => + ({ + accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: 'counter.read.v1' }, + actionKey: 'shell.counter.change', + auditProfile: 'standard', + domainErrorSchema: Schema.Never, + domainEvents: {}, + entrypoint: defineSystemModuleEntrypoint({ + access: 'write', + authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + entrypointKey: 'shell.counter.change', + moduleKey: 'core.shell', + role: 'action', + }), + idempotency: 'required', + legalEntityScope: 'optional', + owningModuleKey: 'core.shell', + payloadSchema: Schema.Struct({ amount: Schema.Finite }), + policies: [], + schemaVersion: '1', + }) as const; + void test('defines an immutable typed descriptor and decodes typed payloads and results', async () => { const registration = defineAction( { - accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: 'counter.read.v1' }, - actionKey: 'shell.counter.change', - auditProfile: 'standard', - domainErrorSchema: Schema.Never, - domainEvents: {}, - entrypoint: defineSystemModuleEntrypoint({ - access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, - entrypointKey: 'shell.counter.change', - moduleKey: 'core.shell', - role: 'action', - }), - idempotency: 'required', - legalEntityScope: 'optional', - owningModuleKey: 'core.shell', - payloadSchema: Schema.Struct({ amount: Schema.Finite }), - policies: [], + ...counterActionDescriptor(), resultSchema: Schema.Struct({ total: Schema.Finite }), schemaVersion: '1', }, @@ -143,23 +149,7 @@ test('uses Schema.Void for a no-payload Action', async () => { void test('keeps the private handler outside the public Action registration', () => { const registration = defineAction( { - accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: 'counter.read.v1' }, - actionKey: 'shell.counter.change', - auditProfile: 'standard', - domainErrorSchema: Schema.Never, - domainEvents: {}, - entrypoint: defineSystemModuleEntrypoint({ - access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, - entrypointKey: 'shell.counter.change', - moduleKey: 'core.shell', - role: 'action', - }), - idempotency: 'required', - legalEntityScope: 'optional', - owningModuleKey: 'core.shell', - payloadSchema: Schema.Struct({ amount: Schema.Finite }), - policies: [], + ...counterActionDescriptor(), resultSchema: Schema.Finite, schemaVersion: '1', }, diff --git a/app/packages/core-runtime/tests/unit/action-runtime.test.ts b/app/packages/core-runtime/tests/unit/action-runtime.test.ts index eb353548c..b692be9c3 100644 --- a/app/packages/core-runtime/tests/unit/action-runtime.test.ts +++ b/app/packages/core-runtime/tests/unit/action-runtime.test.ts @@ -243,6 +243,22 @@ const makeHarness = (options: HarnessOptions = {}) => { let installedTenantId: string = principal.tenantId; let installedLegalEntityId: string = principal.legalEntityId; + const commitTransaction = () => + Effect.gen(function* commitTransactionEffect() { + const defaultCommitCodes = { 'commit-definite': '40001', uncertain: '08007' }; + const defaultCode = + options.transactionMode === 'uncertain' || options.transactionMode === 'commit-definite' + ? defaultCommitCodes[options.transactionMode] + : undefined; + const code = options.commitFailureCode ?? defaultCode; + if (code !== undefined) { + return yield* new SqlError({ reason: new ConnectionError({ cause: { code } }) }); + } + if (options.commit !== undefined) { + return yield* options.commit; + } + return []; + }); const query = (statement: string, values: readonly unknown[]) => Effect.gen(function* executeQuery() { const text = statement.toLowerCase(); @@ -262,18 +278,7 @@ const makeHarness = (options: HarnessOptions = {}) => { } } if (text === 'commit') { - const defaultCommitCodes = { 'commit-definite': '40001', uncertain: '08007' }; - const defaultCode = - options.transactionMode === 'uncertain' || options.transactionMode === 'commit-definite' - ? defaultCommitCodes[options.transactionMode] - : undefined; - const code = options.commitFailureCode ?? defaultCode; - if (code !== undefined) { - return yield* new SqlError({ reason: new ConnectionError({ cause: { code } }) }); - } - if (options.commit !== undefined) { - return yield* options.commit; - } + return yield* commitTransaction(); } if (text.includes('current_setting')) { return [{ legal_entity_id: installedLegalEntityId, tenant_id: installedTenantId }]; diff --git a/app/packages/core-runtime/tests/unit/module-catalog.test.ts b/app/packages/core-runtime/tests/unit/module-catalog.test.ts index 5b406ecb9..22f22bd4f 100644 --- a/app/packages/core-runtime/tests/unit/module-catalog.test.ts +++ b/app/packages/core-runtime/tests/unit/module-catalog.test.ts @@ -1,3 +1,4 @@ +import { makeModuleContractFixture } from '../../src/testing/module-contract.ts'; import assert from 'node:assert/strict'; import test from 'node:test'; import { @@ -10,45 +11,13 @@ const contract = ( appId: string, moduleId: string, outboxSubscriptions: readonly OntosOutboxSubscriptionContract[] = [], -) => ({ - deployment: { appId, buildMarker: `build-${appId}` }, - manifest: { - activation: { - defaultState: 'inactive', - preservesHistoryWhenInactive: true, - scope: 'tenant', - supportedStates: ['inactive', 'active'], - }, - module: { - description: `${moduleId} module`, - displayName: moduleId, - id: moduleId, - implementedAs: 'ultramodern_microvertical', - kind: 'business_module', - }, - publicSurface: { - actions: [], - api: [], - components: [], - events: [], - reports: [], - resourceTypes: [], - search: [], - shellContributions: { - mediaAttachments: [], - navigation: [], - pages: [], - publicComponents: [], - reports: [], - resourceDetails: [], - search: [], - timelines: [], - }, - }, - }, - runtime: { outboxSubscriptions }, - schemaVersion: '2', -}); +) => + makeModuleContractFixture({ + appId, + buildMarker: `build-${appId}`, + moduleId, + outboxSubscriptions, + }); void test('builds immutable deterministic dual indexes for distinct deployment and module IDs', () => { const catalog = buildInstalledModuleCatalog([ diff --git a/app/packages/core-runtime/tests/unit/read-runtime.test.ts b/app/packages/core-runtime/tests/unit/read-runtime.test.ts index 7dd1cf610..17c24a33f 100644 --- a/app/packages/core-runtime/tests/unit/read-runtime.test.ts +++ b/app/packages/core-runtime/tests/unit/read-runtime.test.ts @@ -408,6 +408,29 @@ void test('preserves scoped service-factory unavailability and never invokes the assert.equal(harness.evidence(), 0); }); +const counterpartyReadRegistration = ( + legalEntityScope: 'required' | 'optional', + onHandler: () => void, +) => + defineRead( + { ...registration().descriptor, legalEntityScope, permissionTarget: 'legal_entity' }, + () => { + onHandler(); + return Effect.succeed({ evidence: { resultCount: 0 }, result: [] }); + }, + () => Effect.succeed({}), + () => ({ kind: 'legal_entity', permission: 'read_counterparty' }), + ); + +const counterpartyReadPrincipal = (legalEntityId: string) => ({ + authBindingId: '00000000-0000-4000-8000-000000000005', + authContextRef: 'better-auth-session:read-runtime', + authMethod: 'session' as const, + legalEntityId, + principalId: scope.principalId, + tenantId: scope.tenantId, +}); + void test('persists sanitized permission denial and never invokes the private handler', async () => { const legalEntityId = '00000000-0000-4000-8000-000000000004'; const legalEntityPermissions: (string | undefined)[] = []; @@ -417,31 +440,14 @@ void test('persists sanitized permission denial and never invokes the private ha resolvedScope: { ...scope, legalEntityId }, }); let handlerCalls = 0; - const deniedRegistration = defineRead( - { - ...registration().descriptor, - legalEntityScope: 'required', - permissionTarget: 'legal_entity', - }, - () => { - handlerCalls += 1; - return Effect.succeed({ evidence: { resultCount: 0 }, result: [] }); - }, - () => Effect.succeed({}), - () => ({ kind: 'legal_entity', permission: 'read_counterparty' }), - ); + const deniedRegistration = counterpartyReadRegistration('required', () => { + handlerCalls += 1; + }); const error = await runEffectTestPromise( Effect.flip( harness.runtime.runRead({ input: {}, - principal: { - authBindingId: '00000000-0000-4000-8000-000000000005', - authContextRef: 'better-auth-session:read-runtime', - authMethod: 'session', - legalEntityId, - principalId: scope.principalId, - tenantId: scope.tenantId, - }, + principal: counterpartyReadPrincipal(legalEntityId), registration: deniedRegistration, transport: { correlationId: scope.correlationId }, }), @@ -460,31 +466,14 @@ test('fails closed when explicit Counterparty read authority is unavailable', as permissionDecision: 'unavailable', resolvedScope: { ...scope, legalEntityId }, }); - const counterpartyRead = defineRead( - { - ...registration().descriptor, - legalEntityScope: 'optional', - permissionTarget: 'legal_entity', - }, - () => { - handlerCalls += 1; - return Effect.succeed({ evidence: { resultCount: 0 }, result: [] }); - }, - () => Effect.succeed({}), - () => ({ kind: 'legal_entity', permission: 'read_counterparty' }), - ); + const counterpartyRead = counterpartyReadRegistration('optional', () => { + handlerCalls += 1; + }); const error = await runEffectTestPromise( Effect.flip( harness.runtime.runRead({ input: {}, - principal: { - authBindingId: '00000000-0000-4000-8000-000000000005', - authContextRef: 'better-auth-session:read-runtime', - authMethod: 'session', - legalEntityId, - principalId: scope.principalId, - tenantId: scope.tenantId, - }, + principal: counterpartyReadPrincipal(legalEntityId), registration: counterpartyRead, transport: { correlationId: scope.correlationId }, }), diff --git a/app/packages/core-runtime/tests/unit/search-ingestion.test.ts b/app/packages/core-runtime/tests/unit/search-ingestion.test.ts index 97aba69fd..213b84191 100644 --- a/app/packages/core-runtime/tests/unit/search-ingestion.test.ts +++ b/app/packages/core-runtime/tests/unit/search-ingestion.test.ts @@ -8,7 +8,7 @@ import { makeCoreSearchIngestion, } from '../../src/search/ingestion.ts'; import { - makeCoreSearchQueryRuntime, + createCoreSearchQueryRuntime, makeInMemoryCoreSearchProjectionStore, } from '../../src/search/projection.ts'; @@ -66,7 +66,7 @@ void test('declares one immutable Core registration for every closed Party lifec effectTest('ingests duplicate and out-of-order post-commit observations idempotently', () => { const store = makeInMemoryCoreSearchProjectionStore(); const ingestion = makeCoreSearchIngestion(store); - const runtime = makeCoreSearchQueryRuntime(store); + const runtime = createCoreSearchQueryRuntime(store); return Effect.gen(function* ingestObservationsIdempotently() { yield* ingestion.ingest(observation('2', 'Current title')); diff --git a/app/packages/core-runtime/tests/unit/search-projection.test.ts b/app/packages/core-runtime/tests/unit/search-projection.test.ts index 0f5d817d8..f3c19cb56 100644 --- a/app/packages/core-runtime/tests/unit/search-projection.test.ts +++ b/app/packages/core-runtime/tests/unit/search-projection.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { Effect, Schema } from 'effect'; import { - makeCoreSearchQueryRuntime, + createCoreSearchQueryRuntime, makeInMemoryCoreSearchProjectionStore, } from '../../src/search/projection.ts'; @@ -63,7 +63,7 @@ effectTest( 'a projection rebuild floor prevents unseen stale resources and rejects divergent equal-version rebuilds', () => { const store = makeInMemoryCoreSearchProjectionStore(); - const runtime = makeCoreSearchQueryRuntime(store); + const runtime = createCoreSearchQueryRuntime(store); const rebuild = { documents: [], moduleId: partyRef.moduleId, @@ -106,7 +106,7 @@ effectTest( 'Core Search identifies alias-only matches while canonical evidence takes precedence', () => { const store = makeInMemoryCoreSearchProjectionStore(); - const runtime = makeCoreSearchQueryRuntime(store); + const runtime = createCoreSearchQueryRuntime(store); return Effect.gen(function* testAliasMatches() { yield* store.apply({ document: party({ @@ -204,7 +204,7 @@ effectTest( 'Core Search honors half-open evidence periods for canonical and subject aliases', () => { const store = makeInMemoryCoreSearchProjectionStore(); - const runtime = makeCoreSearchQueryRuntime(store); + const runtime = createCoreSearchQueryRuntime(store); return Effect.gen(function* testHalfOpenEvidencePeriods() { yield* store.apply({ document: party({ @@ -259,7 +259,7 @@ effectTest( effectTest('Core Search rebuilds one owned projection atomically and isolates tenants', () => { const store = makeInMemoryCoreSearchProjectionStore(); - const runtime = makeCoreSearchQueryRuntime(store); + const runtime = createCoreSearchQueryRuntime(store); return Effect.gen(function* testOwnedProjectionRebuild() { yield* store.replace({ @@ -322,7 +322,7 @@ effectTest( 'Core Search applies typed Legal Entity and role facets without returning match evidence', () => { const store = makeInMemoryCoreSearchProjectionStore(); - const runtime = makeCoreSearchQueryRuntime(store); + const runtime = createCoreSearchQueryRuntime(store); const counterpartyRef = { moduleId: 'party.registry', resourceId: '40000000-0000-4000-8000-000000000001', @@ -416,7 +416,7 @@ effectTest( 'Core Search rejects malformed or cross-owner rebuild documents without partial replacement', () => { const store = makeInMemoryCoreSearchProjectionStore(); - const runtime = makeCoreSearchQueryRuntime(store); + const runtime = createCoreSearchQueryRuntime(store); return Effect.gen(function* testMalformedRebuildDocuments() { yield* store.replace({ documents: [party()], @@ -450,7 +450,7 @@ effectTest( effectTest('Core Search makes duplicate and out-of-order lifecycle observations harmless', () => { const store = makeInMemoryCoreSearchProjectionStore(); - const runtime = makeCoreSearchQueryRuntime(store); + const runtime = createCoreSearchQueryRuntime(store); const versionTwo = party({ projectionVersion: '2', title: 'Current title' }); return Effect.gen(function* testDuplicateLifecycleObservations() { yield* store.apply({ document: versionTwo, kind: 'upsert' }); diff --git a/app/packages/core-runtime/tests/unit/tenant-module-state.test.ts b/app/packages/core-runtime/tests/unit/tenant-module-state.test.ts index 6b189a010..663435e21 100644 --- a/app/packages/core-runtime/tests/unit/tenant-module-state.test.ts +++ b/app/packages/core-runtime/tests/unit/tenant-module-state.test.ts @@ -1,3 +1,4 @@ +import { makeModuleContractFixture } from '../../src/testing/module-contract.ts'; import { runEffectTestPromise } from '@app/core-runtime/testing/effect-runtime'; import assert from 'node:assert/strict'; // @effect-diagnostics asyncFunction:off -- Existing compatibility boundary; expires: 2026-12-31. @@ -27,45 +28,15 @@ import { const contract = ( moduleId: string, supportedStates: OntosModuleDeploymentContract['manifest']['activation']['supportedStates'], -): OntosModuleDeploymentContract => ({ - deployment: { appId: 'unit-module', buildMarker: 'unit-build' }, - manifest: { - activation: { - defaultState: 'inactive', - preservesHistoryWhenInactive: true, - scope: 'tenant', - supportedStates, - }, - module: { - description: 'Unit module', - displayName: 'Unit module', - id: moduleId, - implementedAs: 'ultramodern_microvertical', - kind: 'business_module', - }, - publicSurface: { - actions: [], - api: [], - components: [], - events: [], - reports: [], - resourceTypes: [], - search: [], - shellContributions: { - mediaAttachments: [], - navigation: [], - pages: [], - publicComponents: [], - reports: [], - resourceDetails: [], - search: [], - timelines: [], - }, - }, - }, - runtime: { outboxSubscriptions: [] }, - schemaVersion: '2', -}); +): OntosModuleDeploymentContract => + makeModuleContractFixture({ + appId: 'unit-module', + buildMarker: 'unit-build', + description: 'Unit module', + displayName: 'Unit module', + moduleId, + supportedStates, + }); const catalog = ( ...contracts: readonly OntosModuleDeploymentContract[] diff --git a/app/packages/gateway-principal-verifier/package.json b/app/packages/gateway-principal-verifier/package.json index 90a228b15..d2b6b20c1 100644 --- a/app/packages/gateway-principal-verifier/package.json +++ b/app/packages/gateway-principal-verifier/package.json @@ -18,7 +18,6 @@ "jose": "6.2.5" }, "devDependencies": { - "@effect/tsgo": "0.19.0", "@types/node": "20.19.43" } } diff --git a/app/packages/shared-contracts/package.json b/app/packages/shared-contracts/package.json index bdd2c113c..b08ad3861 100644 --- a/app/packages/shared-contracts/package.json +++ b/app/packages/shared-contracts/package.json @@ -20,7 +20,6 @@ "effect": "4.0.0-beta.107" }, "devDependencies": { - "@effect/tsgo": "0.19.0", "@types/node": "20.19.43" } } diff --git a/app/packages/shared-contracts/src/gateway-context.ts b/app/packages/shared-contracts/src/gateway-context.ts index 1a7aeda79..7383aa176 100644 --- a/app/packages/shared-contracts/src/gateway-context.ts +++ b/app/packages/shared-contracts/src/gateway-context.ts @@ -108,10 +108,7 @@ export const GatewayUnavailableProblemSchema = makeRetryableProblemDetailsSchema ); export const GatewayInternalProblemSchema = makeProblemDetailsSchema('GatewayInternalProblem', 500); -export const GatewayForbiddenProblemSchema = makeProblemDetailsSchema( - 'GatewayForbiddenProblem', - 403, -); +const GatewayForbiddenProblemSchema = makeProblemDetailsSchema('GatewayForbiddenProblem', 403); export const GatewayRateLimitedProblemSchema = makeProblemDetailsSchema( 'GatewayRateLimitedProblem', 429, @@ -128,8 +125,8 @@ export type GatewayAudienceInvalidProblem = Schema.Schema.Type< >; export type GatewayUnavailableProblem = Schema.Schema.Type; export type GatewayInternalProblem = Schema.Schema.Type; -export type GatewayForbiddenProblem = Schema.Schema.Type; -export type GatewayRateLimitedProblem = Schema.Schema.Type; +type GatewayForbiddenProblem = Schema.Schema.Type; +type GatewayRateLimitedProblem = Schema.Schema.Type; export type GatewayContextProblem = | GatewayAuthenticationRequiredProblem @@ -204,7 +201,7 @@ export const gatewayContextAuthorizationEntrypoints = [ type GatewayContextApiGroups = typeof GatewayContextApi extends HttpApi.HttpApi ? Groups : never; -export type GatewayContextClient = HttpApiClient.Client< +type GatewayContextClient = HttpApiClient.Client< Extract >; @@ -262,19 +259,3 @@ export const issueGatewayContext = ( ), ), ); - -export const issueApiKeyGatewayContext = ( - rawKey: string, - payload: GatewayContextRequest, - options: Omit = {}, -): GatewayContextClientEffect => - Schema.decodeUnknownEffect(GatewayContextRequestSchema)(payload).pipe( - Effect.flatMap((decodedPayload) => - invokeGatewayContextClient(options, (client) => - client.gatewayContext.issueApiKeyGatewayContext({ - headers: { 'x-api-key': rawKey }, - payload: decodedPayload, - }), - ), - ), - ); diff --git a/app/packages/shared-contracts/src/problem-details.ts b/app/packages/shared-contracts/src/problem-details.ts index 764e4f453..fab7046f3 100644 --- a/app/packages/shared-contracts/src/problem-details.ts +++ b/app/packages/shared-contracts/src/problem-details.ts @@ -40,9 +40,48 @@ const isJsonLiteral = (ast: SchemaAST.Literal): boolean => (Predicate.isNumber(ast.literal) && Number.isFinite(ast.literal)) || Predicate.isBoolean(ast.literal); +const isConcreteExtensionValue = (ast: SchemaAST.AST): boolean => { + if (isUnsupportedExtensionPrimitive(ast) || SchemaAST.isDeclaration(ast)) { + return false; + } + if (SchemaAST.isLiteral(ast)) { + return isJsonLiteral(ast); + } + if (SchemaAST.isNumber(ast)) { + return hasJsonSafeNumberCheck(ast.checks); + } + if (SchemaAST.isEnum(ast)) { + return ast.enums.every(([, value]) => Predicate.isString(value) || Number.isFinite(value)); + } + return true; +}; + +const visitExtensionChildren = ( + ast: SchemaAST.AST, + visit: (ast: SchemaAST.AST) => boolean, +): boolean => { + if (SchemaAST.isArrays(ast)) { + return ast.elements.every(visit) && ast.rest.every(visit); + } + if (SchemaAST.isObjects(ast)) { + return ( + ast.indexSignatures.length === 0 && + ast.propertySignatures.every( + ({ name, type }) => Predicate.isString(name) && name !== '__proto__' && visit(type), + ) + ); + } + if (SchemaAST.isUnion(ast)) { + return ast.types.every(visit); + } + if (SchemaAST.isSuspend(ast)) { + return visit(ast.thunk()); + } + return true; +}; + const isConcreteExtensionAst = (root: SchemaAST.AST): boolean => { const seen = new Set(); - // eslint-disable-next-line complexity -- The exhaustive AST visitor keeps every JSON-safety rule in one cycle-aware decision point. const visit = (ast: SchemaAST.AST): boolean => { if (seen.has(ast)) { return true; @@ -54,46 +93,28 @@ const isConcreteExtensionAst = (root: SchemaAST.AST): boolean => { } return ast.encoding.every((link) => visit(link.to)); } - if (isUnsupportedExtensionPrimitive(ast)) { - return false; - } - if (SchemaAST.isLiteral(ast) && !isJsonLiteral(ast)) { - return false; - } - if (SchemaAST.isNumber(ast) && !hasJsonSafeNumberCheck(ast.checks)) { - return false; - } - if ( - SchemaAST.isEnum(ast) && - !ast.enums.every(([, value]) => Predicate.isString(value) || Number.isFinite(value)) - ) { - return false; - } - if (SchemaAST.isDeclaration(ast)) { - return false; - } - if (SchemaAST.isArrays(ast)) { - return ast.elements.every(visit) && ast.rest.every(visit); - } - if (SchemaAST.isObjects(ast)) { - return ( - ast.indexSignatures.length === 0 && - ast.propertySignatures.every( - ({ name, type }) => Predicate.isString(name) && name !== '__proto__' && visit(type), - ) - ); - } - if (SchemaAST.isUnion(ast)) { - return ast.types.every(visit); - } - if (SchemaAST.isSuspend(ast)) { - return visit(ast.thunk()); - } - return true; + return isConcreteExtensionValue(ast) && visitExtensionChildren(ast, visit); }; return visit(root); }; +const cloneDescriptors = (value: Value, clone: (value: Current) => Current) => { + const descriptors = Object.getOwnPropertyDescriptors(value); + for (const [key, descriptor] of Object.entries(descriptors)) { + if ('value' in descriptor) { + const descriptorValue = descriptor.value; + descriptor.value = + key === 'thunk' && + SchemaAST.isAST(value) && + SchemaAST.isSuspend(value) && + Predicate.isFunction(descriptorValue) + ? () => clone(descriptorValue()) + : clone(descriptorValue); + } + } + return descriptors; +}; + const cloneAstGraph = (root: Value): Value => { const seen = new Map(); const clone = (value: Current): Current => { @@ -123,25 +144,47 @@ const cloneAstGraph = (root: Value): Value => { } const copy: object = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value)); seen.set(objectValue, copy); - const descriptors = Object.getOwnPropertyDescriptors(value); - for (const [key, descriptor] of Object.entries(descriptors)) { - if ('value' in descriptor) { - const descriptorValue = descriptor.value; - descriptor.value = - key === 'thunk' && - SchemaAST.isAST(value) && - SchemaAST.isSuspend(value) && - Predicate.isFunction(descriptorValue) - ? () => clone(descriptorValue()) - : clone(descriptorValue); - } - } - Object.defineProperties(copy, descriptors); + Object.defineProperties(copy, cloneDescriptors(value, clone)); return copy as Current; }; return clone(root); }; +const stableExtensionField = (name: string, descriptor: PropertyDescriptor) => { + const field = descriptor.value; + if (!Schema.isSchema(field)) { + // eslint-disable-next-line effect-native/no-native-error-construction -- This synchronous, browser-safe schema factory rejects a caller programming error before a contract can be published. + throw new TypeError(`Problem Details extension field "${name}" must use a concrete schema.`); + } + const fieldDescriptors = Object.getOwnPropertyDescriptors(field); + const astDescriptor = fieldDescriptors.ast; + if ( + astDescriptor === undefined || + !('value' in astDescriptor) || + !SchemaAST.isAST(astDescriptor.value) + ) { + // eslint-disable-next-line effect-native/no-native-error-construction -- Schema AST accessors could change after validation; the captured AST must be the one consumed by TaggedStruct. + throw new TypeError(`Problem Details extension field "${name}" must use a concrete schema.`); + } + const stableAst = cloneAstGraph(astDescriptor.value); + if (!isConcreteExtensionAst(stableAst)) { + // eslint-disable-next-line effect-native/no-native-error-construction -- This synchronous, browser-safe schema factory rejects a caller programming error before a contract can be published. + throw new TypeError(`Problem Details extension field "${name}" must use a concrete schema.`); + } + astDescriptor.value = stableAst; + return Object.defineProperties(Object.create(Object.getPrototypeOf(field)), fieldDescriptors); +}; + +const reservedExtensionFields = new Set([ + '__proto__', + '_tag', + 'detail', + 'retryable', + 'status', + 'title', + 'type', +]); + const concreteExtensionsSnapshot = ( extensions: Extensions, ): Extensions => { @@ -161,15 +204,7 @@ const concreteExtensionsSnapshot = ( + artifact: Artifact, + buildMarker: string, + sourceRevision: string, +) => { + const identity = { build: buildMarker, buildMarker, sourceRevision }; + return { + ...artifact, + deliveryUnit: { ...artifact.deliveryUnit, ...identity }, + surfaces: { + api: { ...artifact.surfaces.api, ...identity }, + ui: { ...artifact.surfaces.ui, ...identity }, + }, + } as const; +}; diff --git a/app/packages/shared-contracts/tests/unit/ultramodern-build.test.ts b/app/packages/shared-contracts/tests/unit/ultramodern-build.test.ts new file mode 100644 index 000000000..5c464fa2c --- /dev/null +++ b/app/packages/shared-contracts/tests/unit/ultramodern-build.test.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { withUltramodernBuildIdentity } from '../../src/ultramodern-build.ts'; + +test('injected build identity updates all surfaces without mutating generated metadata', () => { + const deliveryUnit = { + appId: 'build-identity-test', + build: 'generated-build', + buildMarker: 'generated-build', + sourceRevision: 'workspace', + } as const; + const artifact = { + deliveryUnit, + kind: 'ultramodern-build-artifact', + schemaVersion: 1, + surfaces: { + api: { ...deliveryUnit, surface: 'api' }, + ui: { ...deliveryUnit, surface: 'ui' }, + }, + } as const; + const result = withUltramodernBuildIdentity(artifact, 'injected-build', 'source-revision'); + const expectedIdentity = { + ...deliveryUnit, + build: 'injected-build', + buildMarker: 'injected-build', + sourceRevision: 'source-revision', + }; + assert.deepEqual(result, { + ...artifact, + deliveryUnit: expectedIdentity, + surfaces: { + api: { ...expectedIdentity, surface: 'api' }, + ui: { ...expectedIdentity, surface: 'ui' }, + }, + }); + assert.equal(artifact.deliveryUnit.build, 'generated-build'); + assert.equal(artifact.surfaces.api.sourceRevision, 'workspace'); + assert.equal(artifact.surfaces.ui.buildMarker, 'generated-build'); +}); diff --git a/app/packages/shared-contracts/tooling/modern-config.ts b/app/packages/shared-contracts/tooling/modern-config.ts new file mode 100644 index 000000000..f458368c5 --- /dev/null +++ b/app/packages/shared-contracts/tooling/modern-config.ts @@ -0,0 +1,120 @@ +import { builtinModules } from 'node:module'; +import path from 'node:path'; + +const nodeBuiltinRequests = new Set(builtinModules.flatMap((name) => [name, `node:${name}`])); + +interface ExternalRequest { + dependencyType?: string; + request?: string; +} +type ExternalResult = [ + error?: Error | undefined, + result?: string | string[], + type?: 'module-import', +]; + +export const resolveCloudflareExternal = ( + { dependencyType, request }: ExternalRequest, + includeNodeBuiltins = true, +): ExternalResult => { + if (request === undefined) { + return []; + } + const isNodeBuiltin = includeNodeBuiltins && nodeBuiltinRequests.has(request); + if (request !== 'cloudflare:sockets' && !isNodeBuiltin) { + return []; + } + const specifier = isNodeBuiltin && !request.startsWith('node:') ? `node:${request}` : request; + const nativeImport = + dependencyType?.startsWith('commonjs') === true ? [specifier, 'default'] : specifier; + return [undefined, nativeImport, 'module-import']; +}; + +export const createZephyrRspackPlugin = (options: { + configure: () => Configuration; + readToken: () => string | undefined; +}) => ({ + name: 'ultramodern-zephyr-rspack-plugin', + pre: ['@modern-js/plugin-module-federation-config'], + setup(api: { modifyRspackConfig: (configuration: Configuration) => void }) { + // Only authoritative CI deployments upload artifacts. Ordinary builds need + // no Zephyr account or network access; deployment upload failures stay fatal. + if (options.readToken() === undefined) { + return; + } + api.modifyRspackConfig(options.configure()); + }, +}); + +export const createCloudflareWorkerSecurity = () => ({ + contentSecurityPolicy: { + directives: { + 'base-uri': ["'self'"], + 'connect-src': ["'self'", 'https:', 'http:', 'wss:', 'ws:'], + 'default-src': ["'self'"], + 'font-src': ["'self'", 'data:', 'https:', 'http:'], + 'form-action': ["'self'"], + 'frame-ancestors': ["'self'"], + 'img-src': ["'self'", 'data:', 'blob:', 'https:', 'http:'], + 'manifest-src': ["'self'", 'https:', 'http:'], + 'object-src': ["'none'"], + 'script-src': ["'self'", "'unsafe-inline'", "'unsafe-eval'", 'https:', 'http:', 'blob:'], + 'style-src': ["'self'", "'unsafe-inline'", 'https:', 'http:'], + 'worker-src': ["'self'", 'blob:'], + }, + mode: 'report-only' as const, + reason: + 'Report-only by default so Cloudflare Module Federation SSR can prove remote script, style, and connect compatibility before enforcement.', + }, + enabled: true, + headers: { + contentTypeOptions: 'nosniff' as const, + permissionsPolicy: 'camera=(), geolocation=(), microphone=(), payment=(), usb=()', + referrerPolicy: 'strict-origin-when-cross-origin' as const, + }, + noindex: { + localhost: true, + previewHostnames: [], + workersDev: true, + }, +}); + +interface ReplacementResource { + context: string; + request: string; +} + +const retainWorkerLoader = (resource: ReplacementResource) => { + resource.request = resource.request.replace( + /(?[?&])retain=[^&]*/u, + '$retain=true', + ); +}; + +const markWorkerApiSource = (resource: ReplacementResource, sourceDirectory: string) => { + const [requestPath] = resource.request.split('?', 1); + if ( + requestPath !== undefined && + path.resolve(resource.context, requestPath).startsWith(sourceDirectory) && + !resource.request.includes('modern-bff-runtime-source') + ) { + resource.request = `${resource.request}?modern-bff-runtime-source`; + } +}; + +export const createWorkerSsrPlugins = ( + rspack: { + DefinePlugin: new (definitions: Record) => DefinitionPlugin; + NormalModuleReplacementPlugin: new ( + pattern: RegExp, + replace: (resource: ReplacementResource) => void, + ) => ReplacementPlugin; + }, + sourceDirectory: string, +) => [ + new rspack.DefinePlugin({ 'globalThis.FinalizationRegistry': 'undefined' }), + new rspack.NormalModuleReplacementPlugin(/[?&]loaderId=/u, retainWorkerLoader), + new rspack.NormalModuleReplacementPlugin(/^\.\.?[/\\]/u, (resource) => { + markWorkerApiSource(resource, sourceDirectory); + }), +]; diff --git a/app/packages/shared-design-tokens/package.json b/app/packages/shared-design-tokens/package.json index 8bd70082e..c6091d8e7 100644 --- a/app/packages/shared-design-tokens/package.json +++ b/app/packages/shared-design-tokens/package.json @@ -10,8 +10,5 @@ }, "scripts": { "typecheck": "node ../../scripts/ultramodern-typecheck.mts --project tsconfig.json" - }, - "devDependencies": { - "@effect/tsgo": "0.19.0" } } diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index 36018e457..4da263fda 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -33,18 +33,12 @@ importers: '@authzed/authzed-node': specifier: 1.6.1 version: 1.6.1 - '@better-auth/drizzle-adapter': - specifier: 1.7.2 - version: 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.5-ab785fc(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(pg@8.22.0)(zod@4.4.3)) '@effect/sql-pg': specifier: 4.0.0-beta.107 version: 4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)) better-auth: specifier: 1.7.2 version: 1.7.2(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(drizzle-kit@1.0.0-rc.5-ab785fc)(drizzle-orm@1.0.0-rc.5-ab785fc(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(pg@8.22.0)(zod@4.4.3))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - dotenv: - specifier: 17.4.2 - version: 17.4.2 drizzle-orm: specifier: 1.0.0-rc.5-ab785fc version: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(pg@8.22.0)(zod@4.4.3) @@ -109,9 +103,6 @@ importers: fallow: specifier: 3.22.0 version: 3.22.0 - gel: - specifier: 2.2.0 - version: 2.2.0(supports-color@10.2.2) jose: specifier: 6.2.5 version: 6.2.5 @@ -148,12 +139,6 @@ importers: ultracite: specifier: 7.10.7 version: 7.10.7(oxfmt@0.64.0)(oxlint@1.79.0(oxlint-tsgolint@7.0.2001)) - wrangler: - specifier: 4.110.0 - version: 4.110.0(@cloudflare/workers-types@5.20260810.1) - zephyr-agent: - specifier: 1.1.1 - version: 1.1.1(supports-color@10.2.2) apps/shell-super-app: dependencies: @@ -193,15 +178,9 @@ importers: '@modern-js/runtime': specifier: npm:@bleedingdev/modern-js-runtime@3.8.2-ultramodern.12 version: '@bleedingdev/modern-js-runtime@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)' - '@module-federation/bridge-react': - specifier: 2.8.0 - version: 2.8.0(patch_hash=54bfc79e097473222f83cbfa6d717792e3026bf16b5097c2ed91715b7da126be)(react-dom@19.2.8(react@19.2.8))(react-router@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) '@module-federation/modern-js-v3': specifier: 2.8.0 version: 2.8.0(patch_hash=56ff0f8c26c40b18be1de105abd019422ebaa648941607d1d30b94cd620b57f3)(@rsbuild/core@2.2.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@7.0.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) - '@module-federation/runtime': - specifier: 2.8.0 - version: 2.8.0 '@tanstack/react-router': specifier: 1.170.25 version: 1.170.25(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -211,9 +190,6 @@ importers: better-auth: specifier: 1.7.2 version: 1.7.2(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(drizzle-kit@1.0.0-rc.5-ab785fc)(drizzle-orm@1.0.0-rc.5-ab785fc(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(pg@8.22.0)(zod@4.4.3))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - dotenv: - specifier: 17.4.2 - version: 17.4.2 drizzle-orm: specifier: 1.0.0-rc.5-ab785fc version: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(pg@8.22.0)(zod@4.4.3) @@ -226,9 +202,6 @@ importers: jose: specifier: 6.2.5 version: 6.2.5 - node-fetch: - specifier: ^3.3.2 - version: 3.3.2 pg: specifier: 8.22.0 version: 8.22.0 @@ -245,9 +218,6 @@ importers: '@cloudflare/workers-types': specifier: 5.20260810.1 version: 5.20260810.1 - '@effect/tsgo': - specifier: 0.19.0 - version: 0.19.0 '@modern-js/adapter-rstest': specifier: npm:@bleedingdev/modern-js-adapter-rstest@3.8.2-ultramodern.12 version: '@bleedingdev/modern-js-adapter-rstest@3.8.2-ultramodern.12(914587a8ba3a134cde2c523e045244cd)' @@ -284,9 +254,6 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.17) - auth: - specifier: 1.7.2 - version: 1.7.2(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(chokidar@5.0.0)(drizzle-kit@1.0.0-rc.5-ab785fc)(drizzle-orm@1.0.0-rc.5-ab785fc(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(pg@8.22.0)(zod@4.4.3))(giget@3.3.1)(jose@6.2.5)(kysely@0.29.4)(magicast@0.5.4)(nanostores@1.4.2)(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2) bun-types: specifier: 1.4.0 version: 1.4.0 @@ -320,9 +287,6 @@ importers: '@effect/sql-pg': specifier: 4.0.0-beta.107 version: 4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)) - dotenv: - specifier: 17.4.2 - version: 17.4.2 drizzle-orm: specifier: 1.0.0-rc.5-ab785fc version: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(pg@8.22.0)(zod@4.4.3) @@ -358,9 +322,6 @@ importers: specifier: 6.2.5 version: 6.2.5 devDependencies: - '@effect/tsgo': - specifier: 0.19.0 - version: 0.19.0 '@types/node': specifier: 20.19.43 version: 20.19.43 @@ -377,18 +338,11 @@ importers: specifier: 4.0.0-beta.107 version: 4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f) devDependencies: - '@effect/tsgo': - specifier: 0.19.0 - version: 0.19.0 '@types/node': specifier: 20.19.43 version: 20.19.43 - packages/shared-design-tokens: - devDependencies: - '@effect/tsgo': - specifier: 0.19.0 - version: 0.19.0 + packages/shared-design-tokens: {} verticals/party-registry: dependencies: @@ -404,9 +358,6 @@ importers: '@app/shared-design-tokens': specifier: workspace:* version: link:../../packages/shared-design-tokens - '@authzed/authzed-node': - specifier: 1.6.1 - version: 1.6.1 '@effect/opentelemetry': specifier: 4.0.0-beta.107 version: 4.0.0-beta.107(@opentelemetry/api-logs@0.220.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)) @@ -425,27 +376,15 @@ importers: '@modern-js/runtime': specifier: npm:@bleedingdev/modern-js-runtime@3.8.2-ultramodern.12 version: '@bleedingdev/modern-js-runtime@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)' - '@module-federation/bridge-react': - specifier: 2.8.0 - version: 2.8.0(patch_hash=54bfc79e097473222f83cbfa6d717792e3026bf16b5097c2ed91715b7da126be)(react-dom@19.2.8(react@19.2.8))(react-router@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) '@module-federation/modern-js-v3': specifier: 2.8.0 version: 2.8.0(patch_hash=56ff0f8c26c40b18be1de105abd019422ebaa648941607d1d30b94cd620b57f3)(@rsbuild/core@2.2.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react-router@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(typescript@7.0.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)) '@module-federation/runtime': specifier: 2.8.0 version: 2.8.0 - '@tanstack/react-query': - specifier: 5.101.4 - version: 5.101.4(react@19.2.8) '@tanstack/react-router': specifier: 1.170.25 version: 1.170.25(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@techsio/ui-kit': - specifier: 0.25.1 - version: 0.25.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) - dotenv: - specifier: 17.4.2 - version: 17.4.2 drizzle-orm: specifier: 1.0.0-rc.5-ab785fc version: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(pg@8.22.0)(zod@4.4.3) @@ -455,9 +394,6 @@ importers: i18next: specifier: 26.3.6 version: 26.3.6(typescript@7.0.2) - node-fetch: - specifier: ^3.3.2 - version: 3.3.2 pg: specifier: 8.22.0 version: 8.22.0 @@ -492,9 +428,6 @@ importers: '@testing-library/react': specifier: 16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@testing-library/user-event': - specifier: 14.6.1 - version: 14.6.1(@testing-library/dom@10.4.1) '@types/node': specifier: ^20 version: 20.19.43 @@ -572,20 +505,10 @@ packages: resolution: {integrity: sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==} engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-annotate-as-pure@7.29.7': - resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} - engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.29.7': resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} - '@babel/helper-create-class-features-plugin@7.29.7': - resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/helper-globals@7.29.7': resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} @@ -594,10 +517,6 @@ packages: resolution: {integrity: sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==} engines: {node: ^22.18.0 || >=24.11.0} - '@babel/helper-member-expression-to-functions@7.29.7': - resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} - engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.29.7': resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} @@ -608,24 +527,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-optimise-call-expression@7.29.7': - resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} - engines: {node: '>=6.9.0'} - - '@babel/helper-plugin-utils@7.29.7': - resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-replace-supers@7.29.7': - resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-skip-transparent-expression-wrappers@7.29.7': - resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -660,66 +561,6 @@ packages: engines: {node: ^22.18.0 || >=24.11.0} hasBin: true - '@babel/plugin-syntax-jsx@7.29.7': - resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-typescript@7.29.7': - resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-modules-commonjs@7.29.7': - resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-display-name@7.29.7': - resolution: {integrity: sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx-development@7.29.7': - resolution: {integrity: sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-jsx@7.29.7': - resolution: {integrity: sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-react-pure-annotations@7.29.7': - resolution: {integrity: sha512-H5E+HBgDpr6Q5t+Aj11tL7XkIui1jhbIoArVQnqjgXo5/3YxkN7ZEBcWF4RQlB0T4rrxJQbXS6kiFV6B7XTqUA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-typescript@7.29.7': - resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/preset-react@7.29.7': - resolution: {integrity: sha512-C+PV1TFUPTmBQGoPBL8j2QmLpZ117YTCwxIZeJOM96GbYMFSc7/pOXU5lVykwnZxyTqQxRsvoRk6f2FktZgGHA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/preset-typescript@7.29.7': - resolution: {integrity: sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} @@ -1016,21 +857,6 @@ packages: '@bufbuild/protobuf@2.13.0': resolution: {integrity: sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g==} - '@chevrotain/cst-dts-gen@12.0.0': - resolution: {integrity: sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==} - - '@chevrotain/gast@12.0.0': - resolution: {integrity: sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==} - - '@chevrotain/regexp-to-ast@12.0.0': - resolution: {integrity: sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==} - - '@chevrotain/types@12.0.0': - resolution: {integrity: sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==} - - '@chevrotain/utils@12.0.0': - resolution: {integrity: sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==} - '@clack/core@1.4.3': resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==} engines: {node: '>= 20.12.0'} @@ -2202,10 +2028,6 @@ packages: '@module-federation/webpack-bundler-runtime@2.8.0': resolution: {integrity: sha512-82fDy9v+7qV5fiN8TKVhOdrxhmAZnUIX/IKivYX5ulCt8aoOzVFTiwm/P1GQUDD8z6dqR48xgJdZdf0548Mc9w==} - '@mrleebo/prisma-ast@0.16.0': - resolution: {integrity: sha512-a9ELYNIflEQP38tSu6gnUgSAWgXjuhMvC52868K5sWgyRmYsjiJMWTUmm8iy7hZT5EN2ZKVydMTFP2q3/+6ccg==} - engines: {node: '>=22.0.0'} - '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} cpu: [arm64] @@ -3290,9 +3112,6 @@ packages: resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} engines: {node: '>= 10.0.0'} - '@petamoriken/float16@3.9.3': - resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==} - '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -4104,14 +3923,6 @@ packages: resolution: {integrity: sha512-DR9t6lfLVdrjgCwpglrR9DR7Ok8/HlXjcOE+goWXF3zyuLUO/ug7vMbSFxTqrQTtbRghJfyhmIZ0S6LhPIy44w==} engines: {node: '>=20.19'} - '@tanstack/query-core@5.101.4': - resolution: {integrity: sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==} - - '@tanstack/react-query@5.101.4': - resolution: {integrity: sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==} - peerDependencies: - react: ^18 || ^19 - '@tanstack/react-router@1.170.25': resolution: {integrity: sha512-XiWYvkLAGhcZHhV2xUvicpF/VfTVSnewP6CqviDONAjmD50tBob5x/93u2W8QZAc/JgkPd+jijCmu6t4NCzmew==} engines: {node: '>=20.19'} @@ -4829,11 +4640,6 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - auth@1.7.2: - resolution: {integrity: sha512-1c/FD5L2FkWzZXpbIV72X8gxXW0FepmGUBv0l3bn50SGMNOxfNGfU4k9yWonhM0r5i+l/39rTYtNPIS8Q0anUA==} - engines: {node: '>=22.12.0'} - hasBin: true - autoprefixer@10.5.2: resolution: {integrity: sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==} engines: {node: ^10 || ^12 || >=14} @@ -5025,26 +4831,6 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} - c12@4.0.0-rc.1: - resolution: {integrity: sha512-08UYGAVLTLqgiCzWNMNdBHdiWKzhIVwcapySI11mFTBacL5bP0dj1Vr7IcwOH0wcUTgMzm1VCh+by922viKWig==} - peerDependencies: - chokidar: ^5 - dotenv: '*' - giget: '>=3.1.0' - jiti: '*' - magicast: '*' - peerDependenciesMeta: - chokidar: - optional: true - dotenv: - optional: true - giget: - optional: true - jiti: - optional: true - magicast: - optional: true - call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -5081,14 +4867,6 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} - chalk@5.6.2: - resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - - chevrotain@12.0.0: - resolution: {integrity: sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==} - engines: {node: '>=22.0.0'} - chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -5195,9 +4973,6 @@ packages: confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} - confbox@0.3.1: - resolution: {integrity: sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==} - connect-history-api-fallback@2.0.0: resolution: {integrity: sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==} engines: {node: '>=0.8'} @@ -5426,10 +5201,6 @@ packages: resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} engines: {node: '>=18'} - default-browser@5.5.1: - resolution: {integrity: sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==} - engines: {node: '>=18'} - defaults@1.0.4: resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} @@ -5460,9 +5231,6 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} - destr@2.0.5: - resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} - detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -5506,10 +5274,6 @@ packages: dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} - dotenv@17.4.2: - resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} - engines: {node: '>=12'} - drizzle-kit@1.0.0-rc.5-ab785fc: resolution: {integrity: sha512-TSg7sK1finOxdo48O9mDfyMY3lhi/aHCVIil5TFhthyq/H/89H/3nwX5V9Gdp7HYMUGFHyWJD5iowSn98l0elw==} hasBin: true @@ -5721,10 +5485,6 @@ packages: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} - env-paths@3.0.0: - resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - envinfo@7.21.0: resolution: {integrity: sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==} engines: {node: '>=4'} @@ -5999,9 +5759,6 @@ packages: exsolve@1.1.0: resolution: {integrity: sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==} - exsolve@1.1.1: - resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} - fallow-type-aware@3.22.0: resolution: {integrity: sha512-xw18u/0XJGz1DYK2atVjw8f8+TmMM0QgKEsa4Not/y2QioBcPYBScHZs4nr4wZ8V2kMCxWpeREDus6JbS4Ax2A==} engines: {node: '>=20'} @@ -6173,11 +5930,6 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - gel@2.2.0: - resolution: {integrity: sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ==} - engines: {node: '>= 18.0.0'} - hasBin: true - generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -6218,25 +5970,12 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} - get-tsconfig@4.14.0: - resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} - get-tsconfig@4.14.3: resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} - giget@3.3.1: - resolution: {integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==} - hasBin: true - - git-up@7.0.0: - resolution: {integrity: sha512-ONdIrbBCFusq1Oy0sC71F5azx8bVkvtZtMJAsv+a6lz5YAmbNnLD6HAB4gptHZVLPR8S2/kVN6Gab7lryq5+lQ==} - git-up@8.1.1: resolution: {integrity: sha512-FDenSF3fVqBYSaJoYy1KSc2wosx0gCvKP+c+PRBht7cAaiCeQlBtfBDX9vgnNOHmdePlSFITVcn4pFfcgNvx3g==} - git-url-parse@15.0.0: - resolution: {integrity: sha512-5reeBufLi+i4QD3ZFftcJs9jC26aULFLBU23FeKM/b1rI0K6ofIeAblmDVO7Ht22zTDE9+CkJ3ZVb0CgJmz3UQ==} - git-url-parse@16.1.0: resolution: {integrity: sha512-cPLz4HuK86wClEW7iDdeAKcCVlWXmrLpb2L+G9goW0Z1dtpNS6BXXSOckUTlJT/LDQViE1QZKstNORzHsLnobw==} @@ -6518,10 +6257,6 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - is-in-ssh@1.0.0: - resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} - engines: {node: '>=20'} - is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} engines: {node: '>=14.16'} @@ -6632,10 +6367,6 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - isexe@3.1.5: - resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} - engines: {node: '>=18'} - isomorphic-ws@5.0.0: resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} peerDependencies: @@ -6785,10 +6516,6 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - kleur@3.0.3: - resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} - engines: {node: '>=6'} - kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -7045,10 +6772,6 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} - lilconfig@2.1.0: - resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} - engines: {node: '>=10'} - lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -7484,10 +7207,6 @@ packages: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} - open@11.0.2: - resolution: {integrity: sha512-RWqF+pBSkqecEvCKOn8QYhaNdRMJDZRIrlS/7rTDdLHaPcfXGCZ/h8zb413NfvdeAV0MR7T1yJcA34/q+CSm1Q==} - engines: {node: '>=20'} - optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -7619,9 +7338,6 @@ packages: parse-path@7.1.0: resolution: {integrity: sha512-EuCycjZtfPcjWk7KTksnJ5xPMvWGA/6i4zrLYhRG0hGvC3GPU/jGUj3Cy+ZR0v30duV3e23R95T1lE2+lsndSw==} - parse-url@8.1.0: - resolution: {integrity: sha512-xDvOoLU5XRrcOZvnI6b8zA6n9O9ejNk/GExuz1yBuWUGn9KA97GI6HTs6u02wKara1CeVmZhH+0TZFdWScR89w==} - parse-url@9.2.0: resolution: {integrity: sha512-bCgsFI+GeGWPAvAiUv63ZorMeif3/U0zaXABGJbOWt5OH2KCaPHF6S+0ok4aqM9RuIPGyZdx9tR9l13PsW4AYQ==} engines: {node: '>=14.13.0'} @@ -7747,9 +7463,6 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} - pkg-types@2.3.2: - resolution: {integrity: sha512-v0sVXzj7oPGysr543YYZLYbcJNJsKikSsp/fFzoxQ12ewY3ZZr7oCPC8y7OlmxfYB3QPvriXmuPD8KZggE1vqg==} - pkg-up@3.1.0: resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} engines: {node: '>=8'} @@ -8170,14 +7883,6 @@ packages: postgres-range@1.1.4: resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} - powershell-utils@0.1.0: - resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} - engines: {node: '>=20'} - - powershell-utils@0.2.1: - resolution: {integrity: sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==} - engines: {node: '>=20'} - prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -8206,10 +7911,6 @@ packages: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} - prompts@2.4.2: - resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} - engines: {node: '>= 6'} - prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -8247,9 +7948,6 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - rc9@3.1.0: - resolution: {integrity: sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q==} - react-dom@19.2.8: resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} peerDependencies: @@ -9164,11 +8862,6 @@ packages: engines: {node: '>= 8'} hasBin: true - which@4.0.0: - resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} - engines: {node: ^16.13.0 || >=18.0.0} - hasBin: true - word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -9243,10 +8936,6 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} - wsl-utils@1.0.0: - resolution: {integrity: sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==} - engines: {node: '>=20'} - xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -9279,10 +8968,6 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - yocto-spinner@1.2.2: - resolution: {integrity: sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng==} - engines: {node: '>=18.19'} - yoctocolors@2.2.0: resolution: {integrity: sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==} engines: {node: '>=18'} @@ -9293,15 +8978,9 @@ packages: youch@4.1.0-beta.10: resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} - zephyr-agent@1.1.1: - resolution: {integrity: sha512-EnWAhkEB56T/c/A+aZbKC6fTOtLzr3i963kZ6rUFpAwGQpHr1dmQyY/uGRTThC+om8YRpoFaN5WV/z5dRpF+fg==} - zephyr-agent@1.2.4: resolution: {integrity: sha512-XUpQdQTooLpxr4fnjA1fDsA4kfWRm+x5T5Vchxmjfp/0GsoSJcwd3824VDGgPvwox1ZZLOfoqT3wxQKgwI81Vg==} - zephyr-edge-contract@1.1.1: - resolution: {integrity: sha512-1t3lHsNWnMCBw1f1V6pHquVYf9k4GxO76gdS9gH+HVPpQk46FK7PZ154vr7Ewa305BhFb2SZB2tkRspNq7EjNg==} - zephyr-edge-contract@1.2.4: resolution: {integrity: sha512-bEub/S+AowPi+7NiQ+isW9FrjrMWVyWk3DgXSDDygHx/jnruRYI2siZ8mRkBQG8Csnh5jLS3+/zr0GkoVzsT8Q==} @@ -9388,10 +9067,6 @@ snapshots: '@types/jsesc': 2.5.1 jsesc: 3.1.0 - '@babel/helper-annotate-as-pure@7.29.7': - dependencies: - '@babel/types': 7.29.7 - '@babel/helper-compilation-targets@7.29.7': dependencies: '@babel/compat-data': 7.29.7 @@ -9400,30 +9075,10 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': - dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@10.2.2) - '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.2.2) - '@babel/traverse': 7.29.7(supports-color@10.2.2) - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - '@babel/helper-globals@7.29.7': {} '@babel/helper-globals@8.0.0': {} - '@babel/helper-member-expression-to-functions@7.29.7(supports-color@10.2.2)': - dependencies: - '@babel/traverse': 7.29.7(supports-color@10.2.2) - '@babel/types': 7.29.7 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-imports@7.29.7(supports-color@10.2.2)': dependencies: '@babel/traverse': 7.29.7(supports-color@10.2.2) @@ -9440,28 +9095,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-optimise-call-expression@7.29.7': - dependencies: - '@babel/types': 7.29.7 - - '@babel/helper-plugin-utils@7.29.7': {} - - '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': - dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@10.2.2) - '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.7(supports-color@10.2.2) - transitivePeerDependencies: - - supports-color - - '@babel/helper-skip-transparent-expression-wrappers@7.29.7(supports-color@10.2.2)': - dependencies: - '@babel/traverse': 7.29.7(supports-color@10.2.2) - '@babel/types': 7.29.7 - transitivePeerDependencies: - - supports-color - '@babel/helper-string-parser@7.29.7': {} '@babel/helper-string-parser@8.0.0': {} @@ -9485,87 +9118,6 @@ snapshots: dependencies: '@babel/types': 8.0.4 - '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': - dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': - dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': - dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) - '@babel/helper-plugin-utils': 7.29.7 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-react-display-name@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': - dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/plugin-transform-react-jsx-development@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': - dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-react-jsx@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': - dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) - '@babel/types': 7.29.7 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-react-pure-annotations@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': - dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-plugin-utils': 7.29.7 - - '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': - dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.2.2) - '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) - transitivePeerDependencies: - - supports-color - - '@babel/preset-react@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': - dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-validator-option': 7.29.7 - '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) - '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) - '@babel/plugin-transform-react-jsx-development': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) - '@babel/plugin-transform-react-pure-annotations': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) - transitivePeerDependencies: - - supports-color - - '@babel/preset-typescript@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': - dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-validator-option': 7.29.7 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) - '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) - transitivePeerDependencies: - - supports-color - '@babel/runtime@7.29.7': {} '@babel/template@7.29.7': @@ -10276,21 +9828,6 @@ snapshots: '@bufbuild/protobuf@2.13.0': {} - '@chevrotain/cst-dts-gen@12.0.0': - dependencies: - '@chevrotain/gast': 12.0.0 - '@chevrotain/types': 12.0.0 - - '@chevrotain/gast@12.0.0': - dependencies: - '@chevrotain/types': 12.0.0 - - '@chevrotain/regexp-to-ast@12.0.0': {} - - '@chevrotain/types@12.0.0': {} - - '@chevrotain/utils@12.0.0': {} - '@clack/core@1.4.3': dependencies: fast-wrap-ansi: 0.2.2 @@ -11333,11 +10870,6 @@ snapshots: '@module-federation/runtime': 2.8.0 '@module-federation/sdk': 2.8.0 - '@mrleebo/prisma-ast@0.16.0': - dependencies: - chevrotain: 12.0.0 - lilconfig: 2.1.0 - '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': optional: true @@ -11939,8 +11471,6 @@ snapshots: '@parcel/watcher-win32-x64': 2.6.0 optional: true - '@petamoriken/float16@3.9.3': {} - '@pkgjs/parseargs@0.11.0': optional: true @@ -12703,13 +12233,6 @@ snapshots: '@tanstack/history@1.162.1': {} - '@tanstack/query-core@5.101.4': {} - - '@tanstack/react-query@5.101.4(react@19.2.8)': - dependencies: - '@tanstack/query-core': 5.101.4 - react: 19.2.8 - '@tanstack/react-router@1.170.25(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@tanstack/history': 1.162.1 @@ -13666,61 +13189,6 @@ snapshots: asynckit@0.4.0: {} - auth@1.7.2(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(chokidar@5.0.0)(drizzle-kit@1.0.0-rc.5-ab785fc)(drizzle-orm@1.0.0-rc.5-ab785fc(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(pg@8.22.0)(zod@4.4.3))(giget@3.3.1)(jose@6.2.5)(kysely@0.29.4)(magicast@0.5.4)(nanostores@1.4.2)(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2): - dependencies: - '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/preset-react': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) - '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) - '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) - '@better-auth/telemetry': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747)) - '@better-auth/utils': 0.4.2 - '@clack/prompts': 1.7.0 - '@mrleebo/prisma-ast': 0.16.0 - better-auth: 1.7.2(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(drizzle-kit@1.0.0-rc.5-ab785fc)(drizzle-orm@1.0.0-rc.5-ab785fc(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(pg@8.22.0)(zod@4.4.3))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - c12: 4.0.0-rc.1(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magicast@0.5.4) - chalk: 5.6.2 - commander: 15.0.0 - dotenv: 17.4.2 - get-tsconfig: 4.14.0 - jiti: 2.7.0 - open: 11.0.2 - prettier: 3.9.6 - prompts: 2.4.2 - semver: 7.8.5 - yocto-spinner: 1.2.2 - zod: 4.4.3 - transitivePeerDependencies: - - '@better-fetch/fetch' - - '@cloudflare/workers-types' - - '@lynx-js/react' - - '@opentelemetry/api' - - '@prisma/client' - - '@sveltejs/kit' - - '@tanstack/react-start' - - '@tanstack/solid-start' - - better-call - - better-sqlite3 - - chokidar - - drizzle-kit - - drizzle-orm - - giget - - jose - - kysely - - magicast - - mongodb - - mysql2 - - nanostores - - next - - pg - - prisma - - react - - react-dom - - solid-js - - supports-color - - svelte - - vitest - - vue - autoprefixer@10.5.2(postcss@8.5.26): dependencies: browserslist: 4.28.8 @@ -13889,21 +13357,6 @@ snapshots: bytes@3.1.2: {} - c12@4.0.0-rc.1(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.3.1)(jiti@2.7.0)(magicast@0.5.4): - dependencies: - confbox: 0.3.1 - defu: 6.1.7 - exsolve: 1.1.1 - pathe: 2.0.3 - pkg-types: 2.3.2 - rc9: 3.1.0 - optionalDependencies: - chokidar: 5.0.0 - dotenv: 17.4.2 - giget: 3.3.1 - jiti: 2.7.0 - magicast: 0.5.4 - call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -13949,16 +13402,6 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 - chalk@5.6.2: {} - - chevrotain@12.0.0: - dependencies: - '@chevrotain/cst-dts-gen': 12.0.0 - '@chevrotain/gast': 12.0.0 - '@chevrotain/regexp-to-ast': 12.0.0 - '@chevrotain/types': 12.0.0 - '@chevrotain/utils': 12.0.0 - chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -14051,8 +13494,6 @@ snapshots: confbox@0.2.4: {} - confbox@0.3.1: {} - connect-history-api-fallback@2.0.0: {} consola@3.4.2: {} @@ -14298,11 +13739,6 @@ snapshots: bundle-name: 4.1.0 default-browser-id: 5.0.1 - default-browser@5.5.1: - dependencies: - bundle-name: 4.1.0 - default-browser-id: 5.0.1 - defaults@1.0.4: dependencies: clone: 1.0.4 @@ -14329,8 +13765,6 @@ snapshots: dequal@2.0.3: {} - destr@2.0.5: {} - detect-libc@2.1.2: {} doctrine@2.1.0: @@ -14380,8 +13814,6 @@ snapshots: no-case: 3.0.4 tslib: 2.8.1 - dotenv@17.4.2: {} - drizzle-kit@1.0.0-rc.5-ab785fc: dependencies: '@drizzle-team/brocli': 0.12.0 @@ -14464,8 +13896,6 @@ snapshots: entities@8.0.0: {} - env-paths@3.0.0: {} - envinfo@7.21.0: {} environment@1.1.0: {} @@ -14930,8 +14360,6 @@ snapshots: exsolve@1.1.0: {} - exsolve@1.1.1: {} - fallow-type-aware@3.22.0: dependencies: typescript: 7.0.2 @@ -15120,17 +14548,6 @@ snapshots: functions-have-names@1.2.3: {} - gel@2.2.0(supports-color@10.2.2): - dependencies: - '@petamoriken/float16': 3.9.3 - debug: 4.4.3(supports-color@10.2.2) - env-paths: 3.0.0 - semver: 7.8.5 - shell-quote: 1.10.0 - which: 4.0.0 - transitivePeerDependencies: - - supports-color - generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} @@ -15172,31 +14589,15 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 - get-tsconfig@4.14.0: - dependencies: - resolve-pkg-maps: 1.0.0 - get-tsconfig@4.14.3: dependencies: resolve-pkg-maps: 1.0.0 - giget@3.3.1: - optional: true - - git-up@7.0.0: - dependencies: - is-ssh: 1.4.1 - parse-url: 8.1.0 - git-up@8.1.1: dependencies: is-ssh: 1.4.1 parse-url: 9.2.0 - git-url-parse@15.0.0: - dependencies: - git-up: 7.0.0 - git-url-parse@16.1.0: dependencies: git-up: 8.1.1 @@ -15496,8 +14897,6 @@ snapshots: dependencies: is-extglob: 2.1.1 - is-in-ssh@1.0.0: {} - is-inside-container@1.0.0: dependencies: is-docker: 3.0.0 @@ -15584,8 +14983,6 @@ snapshots: isexe@2.0.0: {} - isexe@3.1.5: {} - isomorphic-ws@5.0.0(ws@8.21.0): dependencies: ws: 8.21.0 @@ -15715,8 +15112,6 @@ snapshots: dependencies: json-buffer: 3.0.1 - kleur@3.0.3: {} - kleur@4.1.5: {} knip@6.34.0: @@ -15922,8 +15317,6 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 - lilconfig@2.1.0: {} - lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -16341,15 +15734,6 @@ snapshots: is-inside-container: 1.0.0 wsl-utils: 0.1.0 - open@11.0.2: - dependencies: - default-browser: 5.5.1 - define-lazy-prop: 3.0.0 - is-in-ssh: 1.0.0 - is-inside-container: 1.0.0 - powershell-utils: 0.2.1 - wsl-utils: 1.0.0 - optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -16606,10 +15990,6 @@ snapshots: dependencies: protocols: 2.0.2 - parse-url@8.1.0: - dependencies: - parse-path: 7.1.0 - parse-url@9.2.0: dependencies: '@types/parse-path': 7.1.0 @@ -16725,12 +16105,6 @@ snapshots: exsolve: 1.1.0 pathe: 2.0.3 - pkg-types@2.3.2: - dependencies: - confbox: 0.3.1 - exsolve: 1.1.1 - pathe: 2.0.3 - pkg-up@3.1.0: dependencies: find-up: 3.0.0 @@ -17112,10 +16486,6 @@ snapshots: postgres-range@1.1.4: {} - powershell-utils@0.1.0: {} - - powershell-utils@0.2.1: {} - prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.1: @@ -17145,11 +16515,6 @@ snapshots: process@0.11.10: {} - prompts@2.4.2: - dependencies: - kleur: 3.0.3 - sisteransi: 1.0.5 - prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -17196,11 +16561,6 @@ snapshots: queue-microtask@1.2.3: {} - rc9@3.1.0: - dependencies: - defu: 6.1.7 - destr: 2.0.5 - react-dom@19.2.8(react@19.2.8): dependencies: react: 19.2.8 @@ -18225,10 +17585,6 @@ snapshots: dependencies: isexe: 2.0.0 - which@4.0.0: - dependencies: - isexe: 3.1.5 - word-wrap@1.2.5: {} workerd@1.20260708.1: @@ -18285,11 +17641,6 @@ snapshots: dependencies: is-wsl: 3.1.1 - wsl-utils@1.0.0: - dependencies: - is-wsl: 3.1.1 - powershell-utils: 0.1.0 - xtend@4.0.2: {} y18n@5.0.8: {} @@ -18314,10 +17665,6 @@ snapshots: yocto-queue@0.1.0: {} - yocto-spinner@1.2.2: - dependencies: - yoctocolors: 2.2.0 - yoctocolors@2.2.0: {} youch-core@0.3.3: @@ -18333,25 +17680,6 @@ snapshots: cookie: 1.1.1 youch-core: 0.3.3 - zephyr-agent@1.1.1(supports-color@10.2.2): - dependencies: - '@toon-format/toon': 0.9.0 - axios: 1.18.1(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2) - axios-retry: 4.5.0(axios@1.18.1(debug@4.4.3(supports-color@10.2.2))(supports-color@10.2.2)) - debug: 4.4.3(supports-color@10.2.2) - eventsource: 4.1.0 - git-url-parse: 15.0.0 - https-proxy-agent: 7.0.6(supports-color@10.2.2) - is-ci: 4.1.0 - jose: 5.10.0 - node-persist: 4.0.4 - open: 10.2.0 - proper-lockfile: 4.1.2 - tslib: 2.8.1 - zephyr-edge-contract: 1.1.1 - transitivePeerDependencies: - - supports-color - zephyr-agent@1.2.4(supports-color@10.2.2): dependencies: '@toon-format/toon': 0.9.0 @@ -18371,10 +17699,6 @@ snapshots: transitivePeerDependencies: - supports-color - zephyr-edge-contract@1.1.1: - dependencies: - tslib: 2.8.1 - zephyr-edge-contract@1.2.4: {} zephyr-rspack-plugin@1.2.4(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(supports-color@10.2.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26)): diff --git a/app/quality-audit/knip-runtime-model.mts b/app/quality-audit/knip-runtime-model.mts index dc98cabdc..5c2bebd18 100644 --- a/app/quality-audit/knip-runtime-model.mts +++ b/app/quality-audit/knip-runtime-model.mts @@ -1,3 +1,4 @@ +import { hasUltramodernDispatch } from '../scripts/shared/ultramodern-wrapper-source.mts'; import { Effect, FileSystem, Path, Schema } from 'effect'; import { parse as parseJsonc } from 'jsonc-parser'; import type { ParseError } from 'jsonc-parser'; @@ -325,7 +326,11 @@ export const buildKnipRuntimeEvidence = Effect.fn('QualityAudit.buildKnipRuntime 'node_modules/@modern-js/create/templates/workspace-scripts/ultramodern-typecheck.mjs', ); const usesTsgo = - typecheck?.includes("['ultramodern', 'typecheck', ...forwardedArgs]") === true && + hasUltramodernDispatch( + typecheck, + 'typecheck', + yield* read('scripts/shared/ultramodern-command.mts'), + ) && vendorTypecheck?.includes('resolveEffectTsgoCompiler({') === true && vendorTypecheck.includes("from: pathToFileURL(join(workspaceRoot, 'package.json'))"); if (usesTsgo && typecheck !== undefined) { @@ -378,8 +383,11 @@ export const buildKnipRuntimeEvidence = Effect.fn('QualityAudit.buildKnipRuntime 'node_modules/@modern-js/create/templates/workspace-scripts/ultramodern-performance-readiness.mjs'; const vendor = yield* read(vendorFile); if ( - readiness?.includes("['ultramodern', 'performance-readiness', ...forwardedArgs]") === - true && + hasUltramodernDispatch( + readiness, + 'performance-readiness', + yield* read('scripts/shared/ultramodern-command.mts'), + ) && vendor?.includes('pathToFileURL(path.join(root, configPath)).href') === true && vendor.includes('import(moduleUrl)') ) { diff --git a/app/scripts/assert-mf-types.mts b/app/scripts/assert-mf-types.mts index b4fea220b..030cce687 100644 --- a/app/scripts/assert-mf-types.mts +++ b/app/scripts/assert-mf-types.mts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { NodeServices } from '@effect/platform-node'; -import { Config, Console, Effect, Exit, Option, Path, Schema, Stdio } from 'effect'; -import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; +import { Effect, Schema } from 'effect'; +import { runUltramodernScript, ultramodernExitCode } from './shared/ultramodern-command.mts'; class MfTypesAssertionError extends Schema.TaggedError()( 'MfTypesAssertionError', @@ -10,65 +10,13 @@ class MfTypesAssertionError extends Schema.TaggedError()( const failure = (reason: string): MfTypesAssertionError => new MfTypesAssertionError({ reason }); -const program = Effect.gen(function* assertMfTypesEffect() { - const path = yield* Path.Path; - const stdio = yield* Stdio.Stdio; - const processSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const moduleDirectory = yield* path - .fromFileUrl(new URL('.', import.meta.url)) - .pipe(Effect.mapError(() => failure('Unable to resolve the MF types wrapper directory'))); - const defaultWorkspaceRoot = path.resolve(moduleDirectory, '..'); - const workspaceRoot = yield* Config.string('ULTRAMODERN_WORKSPACE_ROOT').pipe( - Config.withDefault(defaultWorkspaceRoot), - Effect.mapError(() => failure('ULTRAMODERN_WORKSPACE_ROOT is invalid')), - ); - const createBin = yield* Config.string('ULTRAMODERN_CREATE_BIN').pipe( - Config.option, - Effect.map(Option.filter((value) => value.length > 0)), - Effect.mapError(() => failure('ULTRAMODERN_CREATE_BIN is invalid')), - ); - const forwardedArgs = yield* stdio.args; - const ultramodernArgs = ['ultramodern', 'mf-types', ...forwardedArgs]; - const executable = Option.isSome(createBin) ? process.execPath : 'modern-js-create'; - const executableArgs = Option.isSome(createBin) - ? [createBin.value, ...ultramodernArgs] - : ultramodernArgs; - const launchTarget = Option.isSome(createBin) - ? `${process.execPath} with ULTRAMODERN_CREATE_BIN=${createBin.value}` - : 'modern-js-create from PATH'; - - return Number( - yield* processSpawner - .exitCode( - ChildProcess.make(executable, executableArgs, { - env: { ULTRAMODERN_WORKSPACE_ROOT: workspaceRoot }, - extendEnv: true, - shell: Option.isNone(createBin) && path.sep === '\\', - stderr: 'inherit', - stdin: 'inherit', - stdout: 'inherit', - }), - ) - .pipe( - Effect.mapError((error) => - failure( - `Failed to launch ${launchTarget} for UltraModern command "${ultramodernArgs - .slice(1) - .join(' ')}": ${String(error)}`, - ), - ), - ), - ); -}); - const exit = await Effect.runPromiseExit( - program.pipe( - Effect.tapError((error) => Console.error(error.reason)), - Effect.provide(NodeServices.layer), - Effect.scoped, - ), + runUltramodernScript({ + command: 'mf-types', + directoryFailure: 'Unable to resolve the MF types wrapper directory', + failure, + moduleUrl: import.meta.url, + nodeExecutable: process.execPath, + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); -process.exitCode = Exit.match(exit, { - onFailure: () => 1, - onSuccess: (status) => status, -}); +process.exitCode = ultramodernExitCode(exit); diff --git a/app/scripts/authorization/protected-entrypoint-inventory.mts b/app/scripts/authorization/protected-entrypoint-inventory.mts index 9d2d1399b..26bf09d2b 100644 --- a/app/scripts/authorization/protected-entrypoint-inventory.mts +++ b/app/scripts/authorization/protected-entrypoint-inventory.mts @@ -35,7 +35,7 @@ const ProtectedEntrypointSurfaceSchema = Schema.Literals([ 'worker', ]); -export type ProtectedEntrypointSurface = typeof ProtectedEntrypointSurfaceSchema.Type; +type ProtectedEntrypointSurface = typeof ProtectedEntrypointSurfaceSchema.Type; const StableIdentifierSchema = Schema.String.check( Schema.isPattern(/^[a-z][a-z0-9]*(?:[./_-][a-z0-9]+)*$/u), diff --git a/app/scripts/authorization/rollout-contract.mts b/app/scripts/authorization/rollout-contract.mts index e0622c994..0d76d3c67 100644 --- a/app/scripts/authorization/rollout-contract.mts +++ b/app/scripts/authorization/rollout-contract.mts @@ -1,8 +1,8 @@ -import { DateTime, Effect, FileSystem, Result, Schema } from 'effect'; +import { DateTime, Result, Schema } from 'effect'; import { dedupe as dedupeArray, sort as sortArray } from 'effect/Array'; import { String as StringOrder } from 'effect/Order'; -export const AUTHORIZATION_ROLLOUT_SCHEMA_VERSION = 1 as const; +const AUTHORIZATION_ROLLOUT_SCHEMA_VERSION = 1 as const; const AuthorizationRolloutContractSchema = Schema.Struct({ activatedAt: Schema.DateTimeUtcFromString, @@ -149,14 +149,3 @@ export const validateAuthorizationRolloutContract = ( Result.getOrThrow( Result.flatMap(decodeContract(raw), (contract) => validateDecodedContract(contract, context)), ); - -export const loadAuthorizationRolloutContract = (file: string, context: RolloutValidationContext) => - Effect.gen(function* loadAuthorizationRolloutContractEffect() { - const fileSystem = yield* FileSystem.FileSystem; - const source = yield* fileSystem.readFileString(file); - const contract = yield* Schema.decodeUnknownEffect( - Schema.fromJsonString(AuthorizationRolloutContractSchema), - { onExcessProperty: 'error' }, - )(source).pipe(Effect.mapError(malformedContract)); - return yield* Effect.fromResult(validateDecodedContract(contract, context)); - }); diff --git a/app/scripts/bootstrap-agent-skills.mts b/app/scripts/bootstrap-agent-skills.mts index e68e13beb..8b2127738 100644 --- a/app/scripts/bootstrap-agent-skills.mts +++ b/app/scripts/bootstrap-agent-skills.mts @@ -1,7 +1,20 @@ #!/usr/bin/env node import { NodeServices } from '@effect/platform-node'; -import { Config, ConfigProvider, Console, Effect, Exit, Option, Path, Schema, Stdio } from 'effect'; -import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; +import { + Config, + ConfigProvider, + Console, + Effect, + Exit, + Option, + Path, + Predicate, + Schema, + Stdio, +} from 'effect'; +import { ChildProcessSpawner } from 'effect/unstable/process'; + +import { ultramodernLaunch } from './shared/ultramodern-launch.mts'; class AgentSkillsBootstrapError extends Schema.TaggedError()( 'AgentSkillsBootstrapError', @@ -30,54 +43,28 @@ const program = Effect.gen(function* bootstrapAgentSkills() { ? ['skills', 'check', ...forwardedArgs.filter((arg) => arg !== '--check')] : ['skills', 'install', ...forwardedArgs]; const ultramodernArgs = ['ultramodern', ...skillArgs]; - const launch = Option.match(createBin, { - onNone: () => ({ - args: ultramodernArgs, - executable: 'modern-js-create', - shell: path.sep === '\\', - target: 'modern-js-create from PATH', - }), - onSome: (bin) => ({ - args: [bin, ...ultramodernArgs], - executable: process.execPath, - shell: false, - target: `${process.execPath} with ULTRAMODERN_CREATE_BIN=${bin}`, + const launch = ultramodernLaunch(createBin, ultramodernArgs, workspaceRoot, path.sep); + return yield* processSpawner.exitCode(launch.command).pipe( + Effect.matchEffect({ + onFailure: (error) => { + if (error.reason.method === 'exitCode') { + return Effect.succeed(1); + } + const launchCause = error.reason.cause; + const causeMessage = Predicate.isError(launchCause) + ? launchCause.message.replace(/^spawn /u, 'spawnSync ') + : error.message; + return Effect.fail( + failure( + `Failed to launch ${launch.target} for UltraModern command "${ultramodernArgs + .slice(1) + .join(' ')}": ${causeMessage}`, + ), + ); + }, + onSuccess: (status) => Effect.succeed(Number(status)), }), - }); - - return yield* processSpawner - .exitCode( - ChildProcess.make(launch.executable, launch.args, { - env: { ULTRAMODERN_WORKSPACE_ROOT: workspaceRoot }, - extendEnv: true, - shell: launch.shell, - stderr: 'inherit', - stdin: 'inherit', - stdout: 'inherit', - }), - ) - .pipe( - Effect.matchEffect({ - onFailure: (error) => { - if (error.reason.method === 'exitCode') { - return Effect.succeed(1); - } - const launchCause = error.reason.cause; - const causeMessage = - launchCause instanceof Error - ? launchCause.message.replace(/^spawn /u, 'spawnSync ') - : error.message; - return Effect.fail( - failure( - `Failed to launch ${launch.target} for UltraModern command "${ultramodernArgs - .slice(1) - .join(' ')}": ${causeMessage}`, - ), - ); - }, - onSuccess: (status) => Effect.succeed(Number(status)), - }), - ); + ); }); const exit = await Effect.runPromiseExit( diff --git a/app/scripts/check-authorization-readiness.mts b/app/scripts/check-authorization-readiness.mts index a9dc0b687..4217178b4 100644 --- a/app/scripts/check-authorization-readiness.mts +++ b/app/scripts/check-authorization-readiness.mts @@ -1,7 +1,7 @@ #!/usr/bin/env node /// import { createHash } from 'node:crypto'; -import { createRequire } from 'node:module'; +import { loadCoreNodeServices } from './shared/core-node-services.mts'; import { Clock, Config, @@ -10,7 +10,6 @@ import { Duration, Effect, FileSystem, - Layer, Option, Path, Result, @@ -209,8 +208,24 @@ const validateFixedContext = (input: AuthorizationReadinessInput): void => { } }; -const validateEvidenceIdentity = (input: AuthorizationReadinessInput): void => { +const validateEvidenceFreshness = (input: AuthorizationReadinessInput): void => { const { impact, inventory, negativeSmoke, observation } = input; + if ( + impact.schemaVersion !== 1 || + impact.inventoryHash !== inventory.inventoryHash || + impact.sourceRevision !== inventory.sourceRevision || + impact.totalWouldDeny !== 0 || + observation.inventoryHash !== inventory.inventoryHash || + observation.sourceRevision !== inventory.sourceRevision || + negativeSmoke.inventoryHash !== inventory.inventoryHash || + negativeSmoke.sourceRevision !== inventory.sourceRevision + ) { + fail('inventory, impact, observation, or smoke evidence is stale or unresolved'); + } +}; + +const validateEvidenceIdentity = (input: AuthorizationReadinessInput): void => { + const { inventory } = input; if ( !validRevision(inventory.sourceRevision) || !validHash(inventory.inventoryHash) || @@ -229,18 +244,7 @@ const validateEvidenceIdentity = (input: AuthorizationReadinessInput): void => { inventoryHash: inventory.inventoryHash, nowEpochMs: input.nowEpochMs, }); - if ( - impact.schemaVersion !== 1 || - impact.inventoryHash !== inventory.inventoryHash || - impact.sourceRevision !== inventory.sourceRevision || - impact.totalWouldDeny !== 0 || - observation.inventoryHash !== inventory.inventoryHash || - observation.sourceRevision !== inventory.sourceRevision || - negativeSmoke.inventoryHash !== inventory.inventoryHash || - negativeSmoke.sourceRevision !== inventory.sourceRevision - ) { - fail('inventory, impact, observation, or smoke evidence is stale or unresolved'); - } + validateEvidenceFreshness(input); }; const timestampMillis = (value: string): number => @@ -577,7 +581,7 @@ const authorizationReadinessCommand = Command.make( const nowEpochMs = yield* Clock.currentTimeMillis; const evidence = yield* Effect.try({ catch: (error) => - error instanceof AuthorizationReadinessError + Schema.is(AuthorizationReadinessError)(error) ? error : new AuthorizationReadinessError({ reason: 'authorization evidence is invalid' }), try: () => @@ -609,20 +613,7 @@ const authorizationReadinessCommand = Command.make( const [, invokedModule] = process.argv; const isMain = invokedModule !== undefined && import.meta.url.endsWith(invokedModule); if (isMain) { - const loadFromCoreRuntime = createRequire( - new URL('../packages/core-runtime/package.json', import.meta.url), - ); - const nodePlatform: unknown = loadFromCoreRuntime('@effect/platform-node'); - const AnyLayerSchema = Schema.declare(Layer.isLayer); - const NodeServicesLayerSchema = Schema.declare>( - (value): value is Layer.Layer => Schema.is(AnyLayerSchema)(value), - ); - const NodePlatformSchema = Schema.Struct({ - NodeServices: Schema.Struct({ layer: NodeServicesLayerSchema }), - }); - const { NodeServices } = Result.getOrThrow( - Schema.decodeUnknownResult(NodePlatformSchema)(nodePlatform), - ); + const NodeServices = loadCoreNodeServices(); await Effect.runPromise( Command.run(authorizationReadinessCommand, { version: '1.0.0' }).pipe( Effect.provide(NodeServices.layer), diff --git a/app/scripts/check-database-access-boundaries.mts b/app/scripts/check-database-access-boundaries.mts index f357b4e8f..a019935fd 100644 --- a/app/scripts/check-database-access-boundaries.mts +++ b/app/scripts/check-database-access-boundaries.mts @@ -146,6 +146,9 @@ const resolveLocalSource = ( return candidates.find((candidate) => sourceFiles.has(candidate)); }; +const containsGlobalDatabaseCapability = (source: string): boolean => + globalDatabaseImplementationImport.test(source) || hiddenCapability.test(source); + const importsGlobalDatabaseCapability = ( file: string, sources: ReadonlyMap, @@ -169,7 +172,7 @@ const importsGlobalDatabaseCapability = ( if (relative.startsWith(coreRuntimeSourcePrefix)) { return false; } - if (globalDatabaseImplementationImport.test(source) || hiddenCapability.test(source)) { + if (containsGlobalDatabaseCapability(source)) { return true; } const nextVisiting = new Set(visiting).add(file); diff --git a/app/scripts/check-module-entrypoint-boundaries.mts b/app/scripts/check-module-entrypoint-boundaries.mts index c27bd5773..4cce3fa88 100644 --- a/app/scripts/check-module-entrypoint-boundaries.mts +++ b/app/scripts/check-module-entrypoint-boundaries.mts @@ -1,7 +1,18 @@ #!/usr/bin/env node import { NodeRuntime, NodeServices } from '@effect/platform-node'; import { LanguageVariant, SyntaxKind, createScanner } from '@typescript/native/unstable/ast'; -import { Config, Console, Effect, FileSystem, Layer, Option, Path, Schema } from 'effect'; +import { + Config, + Console, + Effect, + FileSystem, + Function as EffectFunction, + Layer, + Match, + Option, + Path, + Schema, +} from 'effect'; import type { PlatformError } from 'effect/PlatformError'; import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; import { @@ -123,36 +134,69 @@ interface ObjectProperties { readonly values: ReadonlyMap; } +const tokenKindAt = (tokens: readonly SourceToken[], index: number): SyntaxKind | undefined => + tokens[index]?.kind; + +const isPropertyValue = ( + tokens: readonly SourceToken[], + index: number, + valueKind: SyntaxKind, +): boolean => + tokenKindAt(tokens, index) === SyntaxKind.Identifier && + tokenKindAt(tokens, index + 1) === SyntaxKind.ColonToken && + tokenKindAt(tokens, index + 2) === valueKind; + const readObjectStringProperties = ( tokens: readonly SourceToken[], openBraceIndex: number, ): ObjectProperties => { const values = new Map(); let depth = 0; - let closeBraceIndex = tokens.length; - for ( - let cursor = openBraceIndex; - cursor < tokens.length && closeBraceIndex === tokens.length; - cursor += 1 - ) { + for (let cursor = openBraceIndex; cursor < tokens.length; cursor += 1) { const current = tokens[cursor]; - if (current?.kind === SyntaxKind.OpenBraceToken) { + if (current === undefined) { + continue; + } + if (current.kind === SyntaxKind.OpenBraceToken) { depth += 1; - } else if (current?.kind === SyntaxKind.CloseBraceToken) { + } else if (current.kind === SyntaxKind.CloseBraceToken) { depth -= 1; if (depth === 0) { - closeBraceIndex = cursor; + return { closeBraceIndex: cursor, values }; } - } else if ( - depth === 1 && - current?.kind === SyntaxKind.Identifier && - tokens[cursor + 1]?.kind === SyntaxKind.ColonToken && - tokens[cursor + 2]?.kind === SyntaxKind.StringLiteral - ) { + } else if (depth === 1 && isPropertyValue(tokens, cursor, SyntaxKind.StringLiteral)) { values.set(current.value, tokens[cursor + 2]?.value ?? ''); } } - return { closeBraceIndex, values }; + return { closeBraceIndex: tokens.length, values }; +}; + +const readContextPermission = ( + properties: ReadonlyMap, +): InventoryAuthorization | undefined => { + const permission = properties.get('permission'); + return properties.size === 2 && permission !== undefined + ? { kind: 'context_permission', permission } + : undefined; +}; + +const readActionExecution = ( + properties: ReadonlyMap, +): InventoryAuthorization | undefined => { + const provisioning = properties.get('provisioning'); + return properties.size === 2 && + (provisioning === 'explicit' || provisioning === 'tenant_membership_default') + ? { kind: 'action_execution', provisioning } + : undefined; +}; + +const readCapabilityIssuance = ( + properties: ReadonlyMap, +): InventoryAuthorization | undefined => { + const credential = properties.get('credential'); + return properties.size === 2 && (credential === 'api_key' || credential === 'session') + ? { credential, kind: 'capability_issuance' } + : undefined; }; const readAuthorization = ( @@ -160,32 +204,50 @@ const readAuthorization = ( openBraceIndex: number, ): InventoryAuthorization | undefined => { const properties = readObjectStringProperties(tokens, openBraceIndex).values; - const kind = properties.get('kind'); - if ( - kind === 'public' || - kind === 'authenticated_principal' || - kind === 'owner_local_background' - ) { - return properties.size === 1 ? { kind } : undefined; - } - if (kind === 'context_permission') { - const permission = properties.get('permission'); - return properties.size === 2 && permission !== undefined ? { kind, permission } : undefined; + return Match.value(properties.get('kind')).pipe( + Match.when('public', (kind) => (properties.size === 1 ? { kind } : undefined)), + Match.when('authenticated_principal', (kind) => (properties.size === 1 ? { kind } : undefined)), + Match.when('owner_local_background', (kind) => (properties.size === 1 ? { kind } : undefined)), + Match.when('context_permission', () => readContextPermission(properties)), + Match.when('action_execution', () => readActionExecution(properties)), + Match.when('capability_issuance', () => readCapabilityIssuance(properties)), + Match.orElse(EffectFunction.constUndefined), + ); +}; + +const rescanTemplateClose = ( + scanner: ReturnType, + depths: number[], +): SyntaxKind => { + const index = depths.length - 1; + const depth = depths[index] ?? 0; + if (depth !== 0) { + depths[index] = depth - 1; + return SyntaxKind.CloseBraceToken; } - if (kind === 'action_execution') { - const provisioning = properties.get('provisioning'); - return properties.size === 2 && - (provisioning === 'explicit' || provisioning === 'tenant_membership_default') - ? { kind, provisioning } - : undefined; + const kind = scanner.reScanTemplateToken(false); + if (kind === SyntaxKind.TemplateTail) { + depths.pop(); } - if (kind === 'capability_issuance') { - const credential = properties.get('credential'); - return properties.size === 2 && (credential === 'api_key' || credential === 'session') - ? { credential, kind } - : undefined; + return kind; +}; + +const updateTemplateToken = ( + scanner: ReturnType, + kind: SyntaxKind, + depths: number[], +): SyntaxKind => { + if (kind === SyntaxKind.TemplateHead) { + depths.push(0); + } else if (depths.length > 0) { + const index = depths.length - 1; + if (kind === SyntaxKind.OpenBraceToken) { + depths[index] = (depths[index] ?? 0) + 1; + } else if (kind === SyntaxKind.CloseBraceToken) { + return rescanTemplateClose(scanner, depths); + } } - return undefined; + return kind; }; const tokenize = (source: string): readonly SourceToken[] => { @@ -194,64 +256,55 @@ const tokenize = (source: string): readonly SourceToken[] => { const templateExpressionBraceDepths: number[] = []; let scannedKind = scanner.scan(); while (scannedKind !== SyntaxKind.EndOfFile) { - let tokenKind: SyntaxKind = scannedKind; - const templateDepthIndex = templateExpressionBraceDepths.length - 1; - if (tokenKind === SyntaxKind.TemplateHead) { - templateExpressionBraceDepths.push(0); - } else if (tokenKind === SyntaxKind.OpenBraceToken && templateDepthIndex >= 0) { - templateExpressionBraceDepths[templateDepthIndex] = - (templateExpressionBraceDepths[templateDepthIndex] ?? 0) + 1; - } else if (tokenKind === SyntaxKind.CloseBraceToken && templateDepthIndex >= 0) { - const braceDepth = templateExpressionBraceDepths[templateDepthIndex] ?? 0; - if (braceDepth === 0) { - tokenKind = scanner.reScanTemplateToken(false); - if (tokenKind === SyntaxKind.TemplateTail) { - templateExpressionBraceDepths.pop(); - } - } else { - templateExpressionBraceDepths[templateDepthIndex] = braceDepth - 1; - } - } - tokens.push({ kind: tokenKind, value: scanner.getTokenValue() }); + const kind = updateTemplateToken(scanner, scannedKind, templateExpressionBraceDepths); + tokens.push({ kind, value: scanner.getTokenValue() }); scannedKind = scanner.scan(); } return tokens; }; +const entrypointScope = (token: SourceToken): ParsedEntrypoint['scope'] | undefined => { + if (token.kind !== SyntaxKind.Identifier) { + return undefined; + } + return Match.value(token.value).pipe( + Match.when('defineSystemModuleEntrypoint', () => 'System' as const), + Match.when('defineTenantModuleEntrypoint', () => 'Tenant' as const), + Match.orElse(EffectFunction.constUndefined), + ); +}; + +const readEntrypointAuthorization = ( + tokens: readonly SourceToken[], + start: number, + end: number, +): InventoryAuthorization | undefined => { + let authorization: InventoryAuthorization | undefined; + for (let cursor = start; cursor < end; cursor += 1) { + if ( + tokens[cursor]?.value === 'authorization' && + isPropertyValue(tokens, cursor, SyntaxKind.OpenBraceToken) + ) { + authorization = readAuthorization(tokens, cursor + 2); + } + } + return authorization; +}; + const readEntrypoints = (source: string): readonly ParsedEntrypoint[] => { const tokens = tokenize(source); const entrypoints: ParsedEntrypoint[] = []; for (const [index, token] of tokens.entries()) { - let scope: ParsedEntrypoint['scope'] | undefined; - if (token.kind === SyntaxKind.Identifier && token.value === 'defineSystemModuleEntrypoint') { - scope = 'System'; - } else if ( - token.kind === SyntaxKind.Identifier && - token.value === 'defineTenantModuleEntrypoint' - ) { - scope = 'Tenant'; - } + const scope = entrypointScope(token); if ( scope !== undefined && - tokens[index + 1]?.kind === SyntaxKind.OpenParenToken && - tokens[index + 2]?.kind === SyntaxKind.OpenBraceToken + tokenKindAt(tokens, index + 1) === SyntaxKind.OpenParenToken && + tokenKindAt(tokens, index + 2) === SyntaxKind.OpenBraceToken ) { const properties = readObjectStringProperties(tokens, index + 2); - let authorization: InventoryAuthorization | undefined; - for (let cursor = index + 3; cursor < properties.closeBraceIndex; cursor += 1) { - const current = tokens[cursor]; - if ( - current?.kind === SyntaxKind.Identifier && - current.value === 'authorization' && - tokens[cursor + 1]?.kind === SyntaxKind.ColonToken && - tokens[cursor + 2]?.kind === SyntaxKind.OpenBraceToken - ) { - authorization = readAuthorization(tokens, cursor + 2); - } - } entrypoints.push({ access: properties.values.get('access'), - authorization, + authorization: readEntrypointAuthorization(tokens, index + 3, properties.closeBraceIndex), entrypointKey: properties.values.get('entrypointKey'), moduleKey: properties.values.get('moduleKey'), role: properties.values.get('role'), @@ -296,6 +349,16 @@ const containsIdentifier = (source: string, identifiers: ReadonlySet): b (token) => token.kind === SyntaxKind.Identifier && identifiers.has(token.value), ); +const isModuleSpecifierPosition = (tokens: readonly SourceToken[], index: number): boolean => { + const previous = tokenKindAt(tokens, index - 1); + return ( + previous === SyntaxKind.FromKeyword || + previous === SyntaxKind.ImportKeyword || + (previous === SyntaxKind.OpenParenToken && + tokenKindAt(tokens, index - 2) === SyntaxKind.ImportKeyword) + ); +}; + const readImportedModuleSpecifiers = (source: string): readonly string[] => { const tokens = tokenize(source); const specifiers: string[] = []; @@ -303,14 +366,7 @@ const readImportedModuleSpecifiers = (source: string): readonly string[] => { if (token.kind !== SyntaxKind.StringLiteral) { continue; } - const previous = tokens[index - 1]; - const previousPrevious = tokens[index - 2]; - if ( - previous?.kind === SyntaxKind.FromKeyword || - previous?.kind === SyntaxKind.ImportKeyword || - (previous?.kind === SyntaxKind.OpenParenToken && - previousPrevious?.kind === SyntaxKind.ImportKeyword) - ) { + if (isModuleSpecifierPosition(tokens, index)) { specifiers.push(token.value); } } @@ -561,16 +617,28 @@ const validateRouteSource = (state: BoundaryCheckState, file: string, source: st }); }); -const validateVerticalSource = ( +const validateGovernedSource = (file: string, source: string) => + Effect.gen(function* validateGovernedSourceEffect() { + const category = /\/src\/(?components|search|reports)\//u.exec(`/${file}`)?.groups + ?.category; + const expectedHeader = governedSourceHeader(category); + const invalidGovernedSource = + /\/src\/public-components\//u.test(`/${file}`) || + (expectedHeader !== undefined && !source.startsWith(expectedHeader)); + if (invalidGovernedSource) { + yield* fail( + file, + 'public components, search, and reports require an approved Codesmith generator and reserved runtime registration first', + ); + } + }); + +const validateVerticalApiSource = ( sourceMap: ReadonlyMap, file: string, source: string, - importedModuleSpecifiers: readonly string[], ) => - Effect.gen(function* validateVerticalSourceEffect() { - if (!file.includes('/verticals/') && !file.startsWith('verticals/')) { - return; - } + Effect.gen(function* validateVerticalApiSourceEffect() { if ( file.endsWith('/shared/api.ts') && source.includes('HttpApiEndpoint') && @@ -591,18 +659,20 @@ const validateVerticalSource = ( ) { yield* fail(file, 'module APIs must be created with scaffold:module-api'); } - const category = /\/src\/(?components|search|reports)\//u.exec(`/${file}`)?.groups - ?.category; - const expectedHeader = governedSourceHeader(category); - const invalidGovernedSource = - /\/src\/public-components\//u.test(`/${file}`) || - (expectedHeader !== undefined && !source.startsWith(expectedHeader)); - if (invalidGovernedSource) { - yield* fail( - file, - 'public components, search, and reports require an approved Codesmith generator and reserved runtime registration first', - ); + }); + +const validateVerticalSource = ( + sourceMap: ReadonlyMap, + file: string, + source: string, + importedModuleSpecifiers: readonly string[], +) => + Effect.gen(function* validateVerticalSourceEffect() { + if (!file.includes('/verticals/') && !file.startsWith('verticals/')) { + return; } + yield* validateVerticalApiSource(sourceMap, file, source); + yield* validateGovernedSource(file, source); const privateImport = importedModuleSpecifiers.some((specifier) => /(?:verticals\/|@app\/).*\/(?:src|vertical\.registration|workers|search|reports|db)(?:\/|$)/u.test( specifier, @@ -616,24 +686,8 @@ const validateVerticalSource = ( } }); -const validateGeneralSource = ( - file: string, - source: string, - importedModuleSpecifiers: readonly string[], -) => - Effect.gen(function* validateGeneralSourceEffect() { - if (callsIdentifier(source, 'loadRemote') && file !== APPROVED_REMOTE_LOADER) { - yield* fail( - file, - 'raw loadRemote(...) is forbidden outside the approved Shell module-entrypoint loader', - ); - } - if (importedModuleSpecifiers.some((specifier) => /\/(?:remote|exposes)\//u.test(specifier))) { - yield* fail( - file, - 'eager remote implementation imports are forbidden; pass a lazy thunk to the gateway', - ); - } +const validatePrivateHandlerAccess = (file: string, source: string) => + Effect.gen(function* validatePrivateHandlerAccessEffect() { const unauthorizedActionHandler = callsIdentifier(source, 'getActionHandler') && file !== 'packages/core-runtime/src/actions/runtime.ts' && @@ -648,6 +702,10 @@ const validateGeneralSource = ( 'private handler accessors may only be called by their Core runtime after the module-state gate', ); } + }); + +const validatePackageExports = (file: string, source: string) => + Effect.gen(function* validatePackageExportsEffect() { if (file.startsWith('verticals/') && file.endsWith('package.json')) { const packageJson = yield* decodeVerticalPackageJson(source); const privateExport = Object.values(packageJson.exports ?? {}).some((target) => @@ -662,6 +720,10 @@ const validateGeneralSource = ( ); } } + }); + +const validateRegistrationSlots = (file: string, source: string) => + Effect.gen(function* validateRegistrationSlotsEffect() { if (file.endsWith('/vertical.registration.ts')) { for (const marker of [ 'generated-public-component-registrations', @@ -676,6 +738,10 @@ const validateGeneralSource = ( } } } + }); + +const validateCoreExports = (file: string, source: string) => + Effect.gen(function* validateCoreExportsEffect() { if (file === 'packages/core-runtime/src/index.ts') { const forbiddenGateExports = new Set([ 'checkModuleEntrypoint', @@ -693,16 +759,41 @@ const validateGeneralSource = ( } }); +const validateGeneralSource = ( + file: string, + source: string, + importedModuleSpecifiers: readonly string[], +) => + Effect.gen(function* validateGeneralSourceEffect() { + if (callsIdentifier(source, 'loadRemote') && file !== APPROVED_REMOTE_LOADER) { + yield* fail( + file, + 'raw loadRemote(...) is forbidden outside the approved Shell module-entrypoint loader', + ); + } + if (importedModuleSpecifiers.some((specifier) => /\/(?:remote|exposes)\//u.test(specifier))) { + yield* fail( + file, + 'eager remote implementation imports are forbidden; pass a lazy thunk to the gateway', + ); + } + yield* validatePrivateHandlerAccess(file, source); + yield* validatePackageExports(file, source); + yield* validateRegistrationSlots(file, source); + yield* validateCoreExports(file, source); + }); + +const isInventoryDescriptorSource = (file: string): boolean => + file.endsWith('.action.ts') || + (file.endsWith('.worker.ts') && file.includes('/src/workers/')) || + file.endsWith('/route.meta.ts') || + (file.endsWith('.read.ts') && file.includes('/src/api/')) || + file === 'apps/shell-super-app/api/modules/shell-governed-reads.ts' || + file === 'packages/core-runtime/src/auth/principal-administration-reads.ts'; + const appendInventoryEntries = (state: BoundaryCheckState, file: string, source: string) => Effect.gen(function* appendInventoryEntriesEffect() { - const isInventoryDescriptorSource = - file.endsWith('.action.ts') || - (file.endsWith('.worker.ts') && file.includes('/src/workers/')) || - file.endsWith('/route.meta.ts') || - (file.endsWith('.read.ts') && file.includes('/src/api/')) || - file === 'apps/shell-super-app/api/modules/shell-governed-reads.ts' || - file === 'packages/core-runtime/src/auth/principal-administration-reads.ts'; - if (!isInventoryDescriptorSource) { + if (!isInventoryDescriptorSource(file)) { return; } const deployment = @@ -748,13 +839,8 @@ const validateProductionSource = (state: BoundaryCheckState, file: string, sourc yield* appendInventoryEntries(state, file, source); }); -const validateRouteManifests = ( - path: Path.Path, - files: readonly string[], - root: string, - state: BoundaryCheckState, -) => - Effect.gen(function* validateRouteManifestsEffect() { +const collectRouteSourceKeys = (state: BoundaryCheckState) => + Effect.gen(function* collectRouteSourceKeysEffect() { const routeSourceKeysByDeployment = new Map>(); for (const route of state.routeEntrypoints) { const sourceKeys = routeSourceKeysByDeployment.get(route.deployment) ?? new Set(); @@ -764,6 +850,37 @@ const validateRouteManifests = ( sourceKeys.add(route.entrypointKey); routeSourceKeysByDeployment.set(route.deployment, sourceKeys); } + return routeSourceKeysByDeployment; + }); + +const validateManifestKeys = ( + state: BoundaryCheckState, + normalizedFile: string, + sourceKeys: ReadonlySet, +) => + Effect.gen(function* validateManifestKeysEffect() { + const manifestSource = state.sourceMap.get(normalizedFile) ?? ''; + const manifestKeys = readStringProperties(manifestSource, 'entrypointKey'); + const missing = [...sourceKeys].filter((entrypointKey) => !manifestKeys.has(entrypointKey)); + const stale = [...manifestKeys].filter((entrypointKey) => !sourceKeys.has(entrypointKey)); + if (missing.length > 0 || stale.length > 0) { + const missingLabel = missing.length === 0 ? 'none' : sortStrings(missing).join(', '); + const staleLabel = stale.length === 0 ? 'none' : sortStrings(stale).join(', '); + yield* fail( + normalizedFile, + `generated route manifest is stale (missing: ${missingLabel}; orphaned: ${staleLabel}); rerun the route generator`, + ); + } + }); + +const validateRouteManifests = ( + path: Path.Path, + files: readonly string[], + root: string, + state: BoundaryCheckState, +) => + Effect.gen(function* validateRouteManifestsEffect() { + const routeSourceKeysByDeployment = yield* collectRouteSourceKeys(state); const routeManifests = files.filter((file) => file.endsWith('/ultramodern-route-metadata.ts')); const seenManifestDeployments = new Set(); for (const manifestFile of routeManifests) { @@ -783,19 +900,11 @@ const validateRouteManifests = ( ); } seenManifestDeployments.add(deployment); - const manifestSource = state.sourceMap.get(normalizedFile) ?? ''; - const manifestKeys = readStringProperties(manifestSource, 'entrypointKey'); - const sourceKeys = routeSourceKeysByDeployment.get(deployment) ?? new Set(); - const missing = [...sourceKeys].filter((entrypointKey) => !manifestKeys.has(entrypointKey)); - const stale = [...manifestKeys].filter((entrypointKey) => !sourceKeys.has(entrypointKey)); - if (missing.length > 0 || stale.length > 0) { - const missingLabel = missing.length === 0 ? 'none' : sortStrings(missing).join(', '); - const staleLabel = stale.length === 0 ? 'none' : sortStrings(stale).join(', '); - yield* fail( - normalizedFile, - `generated route manifest is stale (missing: ${missingLabel}; orphaned: ${staleLabel}); rerun the route generator`, - ); - } + yield* validateManifestKeys( + state, + normalizedFile, + routeSourceKeysByDeployment.get(deployment) ?? new Set(), + ); } for (const deployment of routeSourceKeysByDeployment.keys()) { if (!seenManifestDeployments.has(deployment)) { @@ -807,12 +916,8 @@ const validateRouteManifests = ( } }); -const validateGatewayContract = (state: BoundaryCheckState) => - Effect.gen(function* validateGatewayContractEffect() { - const gatewayContract = - state.sourceMap.get('packages/shared-contracts/src/gateway-context.ts') ?? ''; - const shellApiContract = state.sourceMap.get('apps/shell-super-app/shared/api.ts') ?? ''; - const shellApiRuntime = state.sourceMap.get('apps/shell-super-app/api/index.ts') ?? ''; +const validateIssuerCredentials = () => + Effect.gen(function* validateIssuerCredentialsEffect() { const issuerCredentials = new Set( gatewayContextAuthorizationEntrypoints.map(({ authorization }) => authorization.credential), ); @@ -826,24 +931,15 @@ const validateGatewayContract = (state: BoundaryCheckState) => 'gateway authorization contract must classify exactly the session and API-key issuers', ); } - for (const issuer of gatewayContextAuthorizationEntrypoints) { - if ( - !gatewayContract.includes(`'${issuer.path}'`) || - !shellApiContract.includes(`'${issuer.path}'`) - ) { - yield* fail( - 'apps/shell-super-app/shared/api.ts', - `capability issuer ${issuer.path} is missing from the mounted gateway contract`, - ); - } - state.inventoryEntries.push(issuer); - } - const missingApiKeyHandler = - !shellApiRuntime.includes(".handle('issueApiKeyGatewayContext'") && - !containsIdentifier(shellApiRuntime, new Set(['issueApiKeyGatewayContext'])); - const missingSessionHandler = - !shellApiRuntime.includes(".handle('issueGatewayContext'") && - !containsIdentifier(shellApiRuntime, new Set(['issueGatewayContext'])); + }); + +const hasIssuerHandler = (source: string, name: string): boolean => + source.includes(`.handle('${name}'`) || containsIdentifier(source, new Set([name])); + +const validateGatewayRuntime = (shellApiContract: string, shellApiRuntime: string) => + Effect.gen(function* validateGatewayRuntimeEffect() { + const missingApiKeyHandler = !hasIssuerHandler(shellApiRuntime, 'issueApiKeyGatewayContext'); + const missingSessionHandler = !hasIssuerHandler(shellApiRuntime, 'issueGatewayContext'); if ( !shellApiContract.includes('.add(GatewayContextApiGroup)') || shellGatewayContextContract.issueGatewayContextPath !== @@ -860,6 +956,50 @@ const validateGatewayContract = (state: BoundaryCheckState) => } }); +const hasMountedIssuerPath = ( + source: string, + issuer: (typeof gatewayContextAuthorizationEntrypoints)[number], +): boolean => { + if (source.includes(`'${issuer.path}'`)) { + return true; + } + const name = + issuer.authorization.credential === 'session' + ? 'issueGatewayContext' + : 'issueApiKeyGatewayContext'; + return ( + source.includes("import { GatewayContextApiGroup } from '@app/shared-contracts'") && + source.includes('.add(GatewayContextApiGroup)') && + source.includes(`\`/shell-super-app-api\${endpoint.path}\``) && + new RegExp( + `${name}Path:\\s*authenticationEndpointPath\\(\\s*ShellAuthenticationApi\\.groups\\.gatewayContext\\.endpoints\\.${name}\\s*,?\\s*\\)`, + 'u', + ).test(source) + ); +}; + +const validateGatewayContract = (state: BoundaryCheckState) => + Effect.gen(function* validateGatewayContractEffect() { + const gatewayContract = + state.sourceMap.get('packages/shared-contracts/src/gateway-context.ts') ?? ''; + const shellApiContract = state.sourceMap.get('apps/shell-super-app/shared/api.ts') ?? ''; + const shellApiRuntime = state.sourceMap.get('apps/shell-super-app/api/index.ts') ?? ''; + yield* validateIssuerCredentials(); + for (const issuer of gatewayContextAuthorizationEntrypoints) { + if ( + !gatewayContract.includes(`'${issuer.path}'`) || + !hasMountedIssuerPath(shellApiContract, issuer) + ) { + yield* fail( + 'apps/shell-super-app/shared/api.ts', + `capability issuer ${issuer.path} is missing from the mounted gateway contract`, + ); + } + state.inventoryEntries.push(issuer); + } + yield* validateGatewayRuntime(shellApiContract, shellApiRuntime); + }); + const checkModuleEntrypointBoundariesEffect = (root: string) => Effect.gen(function* checkModuleEntrypointBoundariesProgram() { const fileSystem = yield* FileSystem.FileSystem; diff --git a/app/scripts/check-ontos-module-contracts.mts b/app/scripts/check-ontos-module-contracts.mts index 0cd3ce1f0..6b67a1dbd 100644 --- a/app/scripts/check-ontos-module-contracts.mts +++ b/app/scripts/check-ontos-module-contracts.mts @@ -267,15 +267,18 @@ const validateEmittedContract = ( ) { yield* failure(`${contractPath} contains forbidden private path metadata`); } - const headersPath = path.join(publicDirectory, '_headers'); - const headers = yield* fileSystem.readFileString(headersPath); - if ( - !headers.includes('Cache-Control: no-cache') || - !headers.includes('Content-Type: application/json') || - !/^ {2}ETag: "[a-f0-9]{64}"$/mu.test(headers) - ) { - yield* failure(`${headersPath} is missing the immutable module-contract response headers`); - } + const validateResponseHeaders = Effect.gen(function* validateResponseHeadersEffect() { + const headersPath = path.join(publicDirectory, '_headers'); + const headers = yield* fileSystem.readFileString(headersPath); + if ( + !headers.includes('Cache-Control: no-cache') || + !headers.includes('Content-Type: application/json') || + !/^ {2}ETag: "[a-f0-9]{64}"$/mu.test(headers) + ) { + yield* failure(`${headersPath} is missing the immutable module-contract response headers`); + } + }); + yield* validateResponseHeaders; }); const manifestMarkers = [ diff --git a/app/scripts/check-ultramodern-api-boundaries.mts b/app/scripts/check-ultramodern-api-boundaries.mts index 2bb761146..3a66e7b17 100644 --- a/app/scripts/check-ultramodern-api-boundaries.mts +++ b/app/scripts/check-ultramodern-api-boundaries.mts @@ -456,31 +456,34 @@ const checkApiBoundaries = Effect.gen(function* checkApiBoundariesEffect() { ); } - if (yield* exists(packageJsonPath)) { - const packageJson = yield* readText(packageJsonPath).pipe( - Effect.flatMap(decodePackageJson), - ); - const isPrivateVerticalInfrastructureApi = - appPath.startsWith('verticals/') && - (yield* exists(sharedApi)) && - isGeneratedInfrastructureReadinessApi(appPath, yield* readText(sharedApi)); - if (isPrivateVerticalInfrastructureApi) { - assert( - packageJson.exports?.['./api'] === undefined && - packageJson.exports?.['./api/client'] === undefined, - `${packageJsonPath}: infrastructure-only vertical APIs must remain private deployment surfaces.`, - ); - } else { - assert( - packageJson.exports?.['./api'] === './shared/api.ts', - `${packageJsonPath}: package must export ./api from shared/api.ts.`, - ); - assert( - packageJson.exports?.['./api/client']?.startsWith('./src/api/') ?? false, - `${packageJsonPath}: package must export ./api/client from src/api/*.`, + const validateApiPackage = Effect.gen(function* validateApiPackageEffect() { + if (yield* exists(packageJsonPath)) { + const packageJson = yield* readText(packageJsonPath).pipe( + Effect.flatMap(decodePackageJson), ); + const isPrivateVerticalInfrastructureApi = + appPath.startsWith('verticals/') && + (yield* exists(sharedApi)) && + isGeneratedInfrastructureReadinessApi(appPath, yield* readText(sharedApi)); + if (isPrivateVerticalInfrastructureApi) { + assert( + packageJson.exports?.['./api'] === undefined && + packageJson.exports?.['./api/client'] === undefined, + `${packageJsonPath}: infrastructure-only vertical APIs must remain private deployment surfaces.`, + ); + } else { + assert( + packageJson.exports?.['./api'] === './shared/api.ts', + `${packageJsonPath}: package must export ./api from shared/api.ts.`, + ); + assert( + packageJson.exports?.['./api/client']?.startsWith('./src/api/') ?? false, + `${packageJsonPath}: package must export ./api/client from src/api/*.`, + ); + } } - } + }); + yield* validateApiPackage; }); const inspectApiSurfaces = Effect.gen(function* inspectApiSurfacesEffect() { @@ -540,27 +543,30 @@ const checkApiBoundaries = Effect.gen(function* checkApiBoundariesEffect() { ); } - if (yield* exists('topology/reference-topology.json')) { - const topology = yield* readText('topology/reference-topology.json').pipe( - Effect.flatMap(decodeTopology), - ); - for (const vertical of topology.verticals ?? []) { - if (vertical.api?.runtime === 'effect') { - assert( - vertical.api.bff?.strictEffectApproach === true, - `${vertical.id} topology must mark strictEffectApproach as true.`, - ); + const validateTopologyContracts = Effect.gen(function* validateTopologyContractsEffect() { + if (yield* exists('topology/reference-topology.json')) { + const topology = yield* readText('topology/reference-topology.json').pipe( + Effect.flatMap(decodeTopology), + ); + for (const vertical of topology.verticals ?? []) { + if (vertical.api?.runtime === 'effect') { + assert( + vertical.api.bff?.strictEffectApproach === true, + `${vertical.id} topology must mark strictEffectApproach as true.`, + ); + assert( + vertical.api.serverEntry?.endsWith('/api/index.ts') ?? false, + `${vertical.id} topology must use api/index.ts as the server entry.`, + ); + } assert( - vertical.api.serverEntry?.endsWith('/api/index.ts') ?? false, - `${vertical.id} topology must use api/index.ts as the server entry.`, + isFalsyJson(vertical.api?.effect), + `${vertical.id} topology must describe the API directly, not under api.effect.`, ); } - assert( - isFalsyJson(vertical.api?.effect), - `${vertical.id} topology must describe the API directly, not under api.effect.`, - ); } - } + }); + yield* validateTopologyContracts; }); yield* inspectWorkspaceContracts; diff --git a/app/scripts/database-trust-audit/report.mts b/app/scripts/database-trust-audit/report.mts index eb6dd37f0..a1473b3ba 100644 --- a/app/scripts/database-trust-audit/report.mts +++ b/app/scripts/database-trust-audit/report.mts @@ -214,7 +214,7 @@ export const getDatabaseTrustBoundaryFailureMessage = ( cause: Cause.Cause, ): string => { const failure = Cause.findErrorOption(cause); - return Option.isSome(failure) && failure.value instanceof DatabaseTrustBoundaryAuditError + return Option.isSome(failure) && Schema.is(DatabaseTrustBoundaryAuditError)(failure.value) ? failure.value.reason : genericAuditFailureMessage; }; @@ -326,6 +326,69 @@ export const assertDatabaseSessionIdentities = ( } }; +const hasMembershipObjectAuthority = (membership: RoleMembership): boolean => + [ + membership.createSchemas, + membership.ownedRelations, + membership.ownedRoutines, + membership.ownedSchemas, + membership.ownedTypes, + membership.parameterPrivileges ?? [], + membership.relationPrivilegeSchemas, + membership.securityDefinerRoutines, + ].some((objects) => objects.length > 0); + +const hasPrivilegedMembership = (membership: RoleMembership): boolean => + ((membership.canSetRole || membership.canAdministerRole) && + hasClusterPrivilege(membership.attributes)) || + membership.predefinedRole === true || + membership.databaseCreate || + hasMembershipObjectAuthority(membership); + +const hasUsableViewPrivileges = (table: TablePrivilege): boolean => + table.privileges.select || + (table.privileges.insert && table.insertable !== false) || + (table.privileges.update && table.updatable !== false) || + (table.privileges.delete && table.deletable !== false); + +const hasPrivilegedViewOwner = (table: TablePrivilege, administrativeRole: string): boolean => + (table.securityInvoker !== true && + (table.owner === administrativeRole || + table.ownerBypassRls === true || + table.ownerSuperuser === true)) || + table.ownerContextPrivileged === true || + table.ownerContextRlsBypass === true; + +const isPrivilegedOwnerView = (table: TablePrivilege, administrativeRole: string): boolean => + table.kind === 'view' && + hasUsableViewPrivileges(table) && + hasPrivilegedViewOwner(table, administrativeRole); + +const hasDdlAuthority = (snapshot: DatabaseTrustBoundarySnapshot): boolean => { + const { memberships, routines, schemas, sequences, tables, types } = snapshot; + const ownsRelation = + tables.some(({ owner }) => owner === snapshot.runtimeRole) || + sequences.some(({ owner }) => owner === snapshot.runtimeRole); + const ownsRoutine = routines.some(({ owner }) => owner === snapshot.runtimeRole); + const ownsType = types.some(({ owner }) => owner === snapshot.runtimeRole); + const inheritsOwnership = memberships.some( + ({ canInheritRole, ownedRelations, ownedRoutines, ownedSchemas, ownedTypes }) => + canInheritRole && + (ownedRelations.length > 0 || + ownedRoutines.length > 0 || + ownedSchemas.length > 0 || + ownedTypes.length > 0), + ); + return ( + snapshot.databasePrivileges.create || + schemas.some(({ create }) => create) || + ownsRelation || + ownsRoutine || + ownsType || + inheritsOwnership + ); +}; + export const buildDatabaseTrustBoundaryReport = ( snapshot: DatabaseTrustBoundarySnapshot, ): DatabaseTrustBoundaryReport => { @@ -396,34 +459,7 @@ export const buildDatabaseTrustBoundaryReport = ( ({ canAdministerRole, canInheritRole, canSetRole, role }) => (canSetRole || canAdministerRole || canInheritRole) && role !== snapshot.administrativeRole, ); - const privilegedMemberships = nonAdministrativeMemberships.filter( - ({ - attributes, - canAdministerRole, - canSetRole, - createSchemas, - databaseCreate, - ownedRelations, - ownedRoutines, - ownedSchemas, - ownedTypes, - parameterPrivileges: membershipParameterPrivileges = [], - predefinedRole, - relationPrivilegeSchemas, - securityDefinerRoutines, - }) => - ((canSetRole || canAdministerRole) && hasClusterPrivilege(attributes)) || - predefinedRole === true || - databaseCreate || - createSchemas.length > 0 || - ownedRelations.length > 0 || - ownedRoutines.length > 0 || - ownedSchemas.length > 0 || - ownedTypes.length > 0 || - membershipParameterPrivileges.length > 0 || - relationPrivilegeSchemas.length > 0 || - securityDefinerRoutines.length > 0, - ); + const privilegedMemberships = nonAdministrativeMemberships.filter(hasPrivilegedMembership); addFinding(findings, privilegedMemberships.length > 0, { code: 'runtime_role_can_assume_privileged_role', evidence: @@ -436,34 +472,12 @@ export const buildDatabaseTrustBoundaryReport = ( 'The runtime role can inherit, SET ROLE to, or administer at least one additional identity.', severity: 'high', }); - const ownsRelation = - tables.some(({ owner }) => owner === snapshot.runtimeRole) || - sequences.some(({ owner }) => owner === snapshot.runtimeRole); - const ownsRoutine = routines.some(({ owner }) => owner === snapshot.runtimeRole); - const ownsType = types.some(({ owner }) => owner === snapshot.runtimeRole); - const inheritsOwnership = memberships.some( - ({ canInheritRole, ownedRelations, ownedRoutines, ownedSchemas, ownedTypes }) => - canInheritRole && - (ownedRelations.length > 0 || - ownedRoutines.length > 0 || - ownedSchemas.length > 0 || - ownedTypes.length > 0), - ); - addFinding( - findings, - snapshot.databasePrivileges.create || - schemas.some(({ create }) => create) || - ownsRelation || - ownsRoutine || - ownsType || - inheritsOwnership, - { - code: 'runtime_role_has_ddl_authority', - evidence: - 'The runtime role has database/schema CREATE or direct/inherited ownership of an audited schema, relation, routine, or application type.', - severity: 'high', - }, - ); + addFinding(findings, hasDdlAuthority(snapshot), { + code: 'runtime_role_has_ddl_authority', + evidence: + 'The runtime role has database/schema CREATE or direct/inherited ownership of an audited schema, relation, routine, or application type.', + severity: 'high', + }); const relationControlTables = tables.filter( ({ privileges }) => privileges.maintain || privileges.references || privileges.trigger || privileges.truncate, @@ -478,100 +492,69 @@ export const buildDatabaseTrustBoundaryReport = ( ({ executable, owner, securityDefiner }) => executable && securityDefiner && owner !== snapshot.runtimeRole, ); - if (executableSecurityDefiners.length > 0) { - findings.push({ - code: 'runtime_role_can_execute_security_definer', - evidence: - 'The runtime role can execute a SECURITY DEFINER routine owned by another role in an audited schema.', - severity: 'high', - }); - } - const privilegedOwnerViews = tables.filter( - ({ - deletable, - insertable, - kind, - owner, - ownerBypassRls, - ownerContextPrivileged, - ownerContextRlsBypass, - ownerSuperuser, - privileges, - securityInvoker, - updatable, - }) => - kind === 'view' && - (privileges.select || - (privileges.insert && insertable !== false) || - (privileges.update && updatable !== false) || - (privileges.delete && deletable !== false)) && - ((securityInvoker !== true && - (owner === snapshot.administrativeRole || - ownerBypassRls === true || - ownerSuperuser === true)) || - ownerContextPrivileged === true || - ownerContextRlsBypass === true), + addFinding(findings, executableSecurityDefiners.length > 0, { + code: 'runtime_role_can_execute_security_definer', + evidence: + 'The runtime role can execute a SECURITY DEFINER routine owned by another role in an audited schema.', + severity: 'high', + }); + const privilegedOwnerViews = tables.filter((table) => + isPrivilegedOwnerView(table, snapshot.administrativeRole), ); - if (privilegedOwnerViews.length > 0) { - findings.push({ - code: 'runtime_role_can_use_privileged_owner_view', - evidence: - 'The runtime role can read or write through an owner-context view with an administrative, BYPASSRLS, superuser, or RLS-bypassing owner in its dependency chain.', - severity: 'high', - }); - } - if (parameterPrivileges.length > 0) { - findings.push({ - code: 'runtime_role_has_parameter_authority', - evidence: - 'The runtime role has an explicit effective SET or ALTER SYSTEM privilege on a PostgreSQL configuration parameter.', - severity: 'critical', - }); - } - if (grantOptions.length > 0 || grantableDefaultPrivileges.length > 0) { - findings.push({ - code: 'runtime_role_has_grant_authority', - evidence: - 'The runtime role has a grant option on at least one existing or creator-default database object privilege.', - severity: 'high', - }); - } - if (sequences.some(({ privileges }) => privileges.update)) { - findings.push({ + addFinding(findings, privilegedOwnerViews.length > 0, { + code: 'runtime_role_can_use_privileged_owner_view', + evidence: + 'The runtime role can read or write through an owner-context view with an administrative, BYPASSRLS, superuser, or RLS-bypassing owner in its dependency chain.', + severity: 'high', + }); + addFinding(findings, parameterPrivileges.length > 0, { + code: 'runtime_role_has_parameter_authority', + evidence: + 'The runtime role has an explicit effective SET or ALTER SYSTEM privilege on a PostgreSQL configuration parameter.', + severity: 'critical', + }); + addFinding(findings, grantOptions.length > 0 || grantableDefaultPrivileges.length > 0, { + code: 'runtime_role_has_grant_authority', + evidence: + 'The runtime role has a grant option on at least one existing or creator-default database object privilege.', + severity: 'high', + }); + addFinding( + findings, + sequences.some(({ privileges }) => privileges.update), + { code: 'runtime_role_has_sequence_mutation_authority', evidence: 'The runtime role has UPDATE on an audited sequence.', severity: 'high', - }); - } - if ( + }, + ); + addFinding( + findings, snapshot.trustedContext.tenantSettingSettable || - snapshot.trustedContext.legalEntitySettingSettable - ) { - findings.push({ + snapshot.trustedContext.legalEntitySettingSettable, + { code: 'runtime_role_can_forge_trusted_context', evidence: 'The ordinary runtime role can set and read at least one custom GUC used by tenant RLS.', severity: 'high', - }); - } - if ( + }, + ); + addFinding( + findings, snapshot.trustedContext.tenantSettingRetainedAfterRollback || - snapshot.trustedContext.legalEntitySettingRetainedAfterRollback - ) { - findings.push({ + snapshot.trustedContext.legalEntitySettingRetainedAfterRollback, + { code: 'trusted_context_survives_transaction', evidence: 'A probed transaction-local trusted context value remained visible after rollback.', severity: 'critical', - }); - } + }, + ); const dmlSchemas = new Set(dmlTables.map(({ schema }) => schema)); - if (dmlSchemas.size > 1) { - findings.push({ - code: 'runtime_role_has_cross_schema_dml', - evidence: 'One runtime role has DML privileges in more than one audited application schema.', - severity: 'high', - }); - } + addFinding(findings, dmlSchemas.size > 1, { + code: 'runtime_role_has_cross_schema_dml', + evidence: 'One runtime role has DML privileges in more than one audited application schema.', + severity: 'high', + }); return { ...snapshot, diff --git a/app/scripts/generate-node-backend-federation.mts b/app/scripts/generate-node-backend-federation.mts index 547c02b03..bf3a8fac0 100644 --- a/app/scripts/generate-node-backend-federation.mts +++ b/app/scripts/generate-node-backend-federation.mts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { NodeServices } from '@effect/platform-node'; -import { Config, Console, Effect, Exit, Option, Path, Schema, Stdio } from 'effect'; -import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; +import { Effect, Schema } from 'effect'; +import { runUltramodernScript, ultramodernExitCode } from './shared/ultramodern-command.mts'; class BackendFederationGenerationError extends Schema.TaggedError()( 'BackendFederationGenerationError', @@ -11,69 +11,14 @@ class BackendFederationGenerationError extends Schema.TaggedError new BackendFederationGenerationError({ reason }); -const program = Effect.gen(function* generateNodeBackendFederationEffect() { - const path = yield* Path.Path; - const stdio = yield* Stdio.Stdio; - const processSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const moduleDirectory = yield* path - .fromFileUrl(new URL('.', import.meta.url)) - .pipe( - Effect.mapError(() => - failure('Unable to resolve the backend-federation generator directory'), - ), - ); - const defaultWorkspaceRoot = path.resolve(moduleDirectory, '..'); - const workspaceRoot = yield* Config.string('ULTRAMODERN_WORKSPACE_ROOT').pipe( - Config.withDefault(defaultWorkspaceRoot), - Effect.mapError(() => failure('ULTRAMODERN_WORKSPACE_ROOT is invalid')), - ); - const createBin = yield* Config.string('ULTRAMODERN_CREATE_BIN').pipe( - Config.option, - Effect.map(Option.filter((value) => value.length > 0)), - Effect.mapError(() => failure('ULTRAMODERN_CREATE_BIN is invalid')), - ); - const forwardedArgs = yield* stdio.args; - const ultramodernArgs = ['ultramodern', 'backend-federation-generate', ...forwardedArgs]; - const executable = Option.isSome(createBin) ? process.execPath : 'modern-js-create'; - const executableArgs = Option.isSome(createBin) - ? [createBin.value, ...ultramodernArgs] - : ultramodernArgs; - const launchTarget = Option.isSome(createBin) - ? `${process.execPath} with ULTRAMODERN_CREATE_BIN=${createBin.value}` - : 'modern-js-create from PATH'; - - return Number( - yield* processSpawner - .exitCode( - ChildProcess.make(executable, executableArgs, { - env: { ULTRAMODERN_WORKSPACE_ROOT: workspaceRoot }, - extendEnv: true, - shell: Option.isNone(createBin) && path.sep === '\\', - stderr: 'inherit', - stdin: 'inherit', - stdout: 'inherit', - }), - ) - .pipe( - Effect.mapError((error) => - failure( - `Failed to launch ${launchTarget} for UltraModern command "${ultramodernArgs - .slice(1) - .join(' ')}": ${error.message}`, - ), - ), - ), - ); -}); - const exit = await Effect.runPromiseExit( - program.pipe( - Effect.tapError((error) => Console.error(error.reason)), - Effect.provide(NodeServices.layer), - Effect.scoped, - ), + runUltramodernScript({ + command: 'backend-federation-generate', + directoryFailure: 'Unable to resolve the backend-federation generator directory', + failure, + launchErrorDetail: (error) => `: ${error.message}`, + moduleUrl: import.meta.url, + nodeExecutable: process.execPath, + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); -process.exitCode = Exit.match(exit, { - onFailure: () => 1, - onSuccess: (status) => status, -}); +process.exitCode = ultramodernExitCode(exit); diff --git a/app/scripts/generate-ontos-module-contract.mts b/app/scripts/generate-ontos-module-contract.mts index 164955305..2ada412f6 100644 --- a/app/scripts/generate-ontos-module-contract.mts +++ b/app/scripts/generate-ontos-module-contract.mts @@ -351,49 +351,63 @@ const deriveContract = (workspaceRoot: string, vertical: string, owner: LoadedOw const topology = yield* Schema.decodeUnknownEffect(ReferenceTopologyTextSchema)( topologySource, ).pipe(Effect.mapError((cause) => failure('reference topology is invalid', cause))); - const appId = packageJson.modernjs?.appId; - const topologyEntries = topology.verticals?.filter( - (entry) => - entry.id === appId && - entry.package === packageJson.name && - entry.path === `verticals/${vertical}`, - ); - if (appId === undefined || topologyEntries?.length !== 1) { - return yield* failure( - 'vertical package and topology deployment identity do not match exactly', + const matchesOwnerModule = () => + packageJson.modernjs?.ontosModule?.moduleId === owner.manifest.module.id && + packageJson.modernjs.ontosModule.schemaVersion === + ONTOS_MODULE_CONTRACT_PACKAGE_SCHEMA_VERSION; + const validateDeploymentIdentity = Effect.gen(function* validateDeploymentIdentityEffect() { + const appId = packageJson.modernjs?.appId; + const topologyEntries = topology.verticals?.filter( + (entry) => + entry.id === appId && + entry.package === packageJson.name && + entry.path === `verticals/${vertical}`, ); - } - if ( - packageJson.modernjs?.ontosModule?.moduleId !== owner.manifest.module.id || - packageJson.modernjs.ontosModule.schemaVersion !== - ONTOS_MODULE_CONTRACT_PACKAGE_SCHEMA_VERSION - ) { - return yield* failure('generated package module marker does not match the owner manifest'); - } - const [topologyEntry] = topologyEntries; - if (topologyEntry === undefined || topologyEntry.moduleFederation?.name === undefined) { - return yield* failure('vertical topology Module Federation boundary is missing'); - } - const moduleFederationName = topologyEntry.moduleFederation.name; + if (appId === undefined || topologyEntries?.length !== 1) { + return yield* failure( + 'vertical package and topology deployment identity do not match exactly', + ); + } + if (!matchesOwnerModule()) { + return yield* failure('generated package module marker does not match the owner manifest'); + } + const [topologyEntry] = topologyEntries; + if (topologyEntry === undefined || topologyEntry.moduleFederation?.name === undefined) { + return yield* failure('vertical topology Module Federation boundary is missing'); + } + const moduleFederationName = topologyEntry.moduleFederation.name; + + return { appId, moduleFederationName, topologyEntry }; + }); + const { appId, moduleFederationName, topologyEntry } = yield* validateDeploymentIdentity; const exposes = yield* componentExposes(verticalDirectory); - const componentKeys = Object.keys(owner.manifest.publicSurface.components); - for (const key of componentKeys) { - if (!exposes.has(`./${toPascalCase(key)}`)) { - return yield* failure(`public component ${key} has no matching Module Federation exposure`); + const validatePublicDescriptors = Effect.gen(function* validatePublicDescriptorsEffect() { + const componentKeys = Object.keys(owner.manifest.publicSurface.components); + for (const key of componentKeys) { + if (!exposes.has(`./${toPascalCase(key)}`)) { + return yield* failure( + `public component ${key} has no matching Module Federation exposure`, + ); + } } - } - const safeRuntime = extractVerticalRuntimeSafeDescriptors(owner.registration); - const manifestActionKeys = sorted( - owner.manifest.publicSurface.actions.map(({ descriptor }) => descriptor.actionKey), - (left, right) => left.localeCompare(right), - ); - const runtimeActionKeys = safeRuntime.actions.map(({ actionKey }) => actionKey); - if ( - manifestActionKeys.length !== runtimeActionKeys.length || - manifestActionKeys.some((actionKey, index) => actionKey !== runtimeActionKeys[index]) - ) { - return yield* failure('manifest Actions and private runtime Action descriptors do not match'); - } + const safeRuntime = extractVerticalRuntimeSafeDescriptors(owner.registration); + const manifestActionKeys = sorted( + owner.manifest.publicSurface.actions.map(({ descriptor }) => descriptor.actionKey), + (left, right) => left.localeCompare(right), + ); + const runtimeActionKeys = safeRuntime.actions.map(({ actionKey }) => actionKey); + if ( + manifestActionKeys.length !== runtimeActionKeys.length || + manifestActionKeys.some((actionKey, index) => actionKey !== runtimeActionKeys[index]) + ) { + return yield* failure( + 'manifest Actions and private runtime Action descriptors do not match', + ); + } + + return { componentKeys, safeRuntime }; + }); + const { componentKeys, safeRuntime } = yield* validatePublicDescriptors; const events = yield* Effect.forEach( owner.manifest.publicSurface.events, (event) => diff --git a/app/scripts/generate-public-surface-assets.mts b/app/scripts/generate-public-surface-assets.mts index 0fdb1ff38..d10399f34 100644 --- a/app/scripts/generate-public-surface-assets.mts +++ b/app/scripts/generate-public-surface-assets.mts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { NodeServices } from '@effect/platform-node'; -import { Config, Console, Effect, Exit, Option, Path, Schema, Stdio } from 'effect'; -import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; +import { Effect, Schema } from 'effect'; +import { runUltramodernScript, ultramodernExitCode } from './shared/ultramodern-command.mts'; class PublicSurfaceGenerationError extends Schema.TaggedError()( 'PublicSurfaceGenerationError', @@ -11,67 +11,13 @@ class PublicSurfaceGenerationError extends Schema.TaggedError new PublicSurfaceGenerationError({ reason }); -const program = Effect.gen(function* generatePublicSurfaceAssetsEffect() { - const path = yield* Path.Path; - const stdio = yield* Stdio.Stdio; - const processSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const moduleDirectory = yield* path - .fromFileUrl(new URL('.', import.meta.url)) - .pipe( - Effect.mapError(() => failure('Unable to resolve the public-surface generator directory')), - ); - const defaultWorkspaceRoot = path.resolve(moduleDirectory, '..'); - const workspaceRoot = yield* Config.string('ULTRAMODERN_WORKSPACE_ROOT').pipe( - Config.withDefault(defaultWorkspaceRoot), - Effect.mapError(() => failure('ULTRAMODERN_WORKSPACE_ROOT is invalid')), - ); - const createBin = yield* Config.string('ULTRAMODERN_CREATE_BIN').pipe( - Config.option, - Effect.map(Option.filter((value) => value.length > 0)), - Effect.mapError(() => failure('ULTRAMODERN_CREATE_BIN is invalid')), - ); - const forwardedArgs = yield* stdio.args; - const ultramodernArgs = ['ultramodern', 'public-surface', ...forwardedArgs]; - const executable = Option.isSome(createBin) ? 'node' : 'modern-js-create'; - const executableArgs = Option.isSome(createBin) - ? [createBin.value, ...ultramodernArgs] - : ultramodernArgs; - const launchTarget = Option.isSome(createBin) - ? `node with ULTRAMODERN_CREATE_BIN=${createBin.value}` - : 'modern-js-create from PATH'; - - return Number( - yield* processSpawner - .exitCode( - ChildProcess.make(executable, executableArgs, { - env: { ULTRAMODERN_WORKSPACE_ROOT: workspaceRoot }, - extendEnv: true, - shell: Option.isNone(createBin) && path.sep === '\\', - stderr: 'inherit', - stdin: 'inherit', - stdout: 'inherit', - }), - ) - .pipe( - Effect.mapError((error) => - failure( - `Failed to launch ${launchTarget} for UltraModern command "${ultramodernArgs - .slice(1) - .join(' ')}": ${error.message}`, - ), - ), - ), - ); -}); - const exit = await Effect.runPromiseExit( - program.pipe( - Effect.tapError((error) => Console.error(error.reason)), - Effect.provide(NodeServices.layer), - Effect.scoped, - ), + runUltramodernScript({ + command: 'public-surface', + directoryFailure: 'Unable to resolve the public-surface generator directory', + failure, + launchErrorDetail: (error) => `: ${error.message}`, + moduleUrl: import.meta.url, + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); -process.exitCode = Exit.match(exit, { - onFailure: () => 1, - onSuccess: (status) => status, -}); +process.exitCode = ultramodernExitCode(exit); diff --git a/app/scripts/generate-tanstack-routes.mts b/app/scripts/generate-tanstack-routes.mts index 32e75d1f9..3388c9fed 100644 --- a/app/scripts/generate-tanstack-routes.mts +++ b/app/scripts/generate-tanstack-routes.mts @@ -2,19 +2,17 @@ import { NodeRuntime, NodeServices } from '@effect/platform-node'; import { Array as EffectArray, - Config, Console, Effect, FileSystem, Layer, - Option, Order, Path, Random, Schema, - Stdio, } from 'effect'; import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; +import { launchUltramodern, resolveUltramodernInvocation } from './shared/ultramodern-command.mts'; import { ModuleEntrypointSchema } from '../packages/core-runtime/src/modules/module-entrypoint.ts'; const RouteMetadataIdentifierSchema = Schema.String.pipe(Schema.brand('RouteMetadataIdentifier')); @@ -125,6 +123,19 @@ const findRouteMetadataFiles = ( return EffectArray.sort(routeFiles, Order.String); }).pipe(Effect.mapError(() => failure(`Unable to discover route metadata beneath ${directory}`))); +const isGovernedPageEntrypoint = ( + route: RouteMetadata, + appId: string, + moduleId: string, + expectedScope: 'system' | 'tenant', +): boolean => + route.ownerAppId === appId && + route.entrypoint.moduleKey === moduleId && + route.entrypoint.role === 'page' && + (route.entrypoint.access === 'read' || route.entrypoint.access === 'historical_read') && + route.entrypoint.scope === expectedScope && + route.entrypoint.entrypointKey.startsWith(`${moduleId}.`); + const loadRouteMetadataFile = ( metadataFile: string, appId: string, @@ -149,14 +160,7 @@ const loadRouteMetadataFile = ( ); } const expectedScope = appId.startsWith('shell-') ? 'system' : 'tenant'; - if ( - route.ownerAppId !== appId || - route.entrypoint.moduleKey !== moduleId || - route.entrypoint.role !== 'page' || - (route.entrypoint.access !== 'read' && route.entrypoint.access !== 'historical_read') || - route.entrypoint.scope !== expectedScope || - !route.entrypoint.entrypointKey.startsWith(`${moduleId}.`) - ) { + if (!isGovernedPageEntrypoint(route, appId, moduleId, expectedScope)) { return yield* Effect.fail( failure( `${metadataFile} must declare one governed ${expectedScope} page entrypoint owned by ${appId}`, @@ -194,22 +198,6 @@ const createLocalisedUrls = ( }), ); -const createPublicRoutes = (routes: readonly RouteMetadata[]) => - routes - .filter((route) => route.public && route.indexable) - .map((route) => { - const baseRoute = { - canonicalPath: route.canonicalPath, - descriptionKey: route.descriptionKey, - id: route.id, - localisedPaths: route.localisedPaths, - namespace: route.namespace, - ownerAppId: route.ownerAppId, - titleKey: route.titleKey, - }; - return route.jsonLd === undefined ? baseRoute : { ...baseRoute, jsonLd: route.jsonLd }; - }); - const runCommand = ( executable: string, args: readonly string[], @@ -236,7 +224,6 @@ const generateRouteMetadataManifest = ( return; } const localisedUrls = createLocalisedUrls(routes); - const publicRoutes = createPublicRoutes(routes); const encodedNamespace = yield* encodeJsonString(namespace).pipe( Effect.mapError(() => failure(`Unable to encode the route namespace for ${appId}`)), ); @@ -246,9 +233,6 @@ const generateRouteMetadataManifest = ( const encodedLocalisedUrls = yield* encodeJson(sortJsonValue(localisedUrls)).pipe( Effect.mapError(() => failure(`Unable to encode localised URLs for ${appId}`)), ); - const encodedPublicRoutes = yield* encodeJson(sortJsonValue(publicRoutes)).pipe( - Effect.mapError(() => failure(`Unable to encode public routes for ${appId}`)), - ); const content = `// @generated by @modern-js/create. // Author route metadata in colocated src/routes/**/route.meta.ts files. // This compatibility manifest is regenerated from route-owned metadata. @@ -259,17 +243,6 @@ export const ultramodernRouteMetadata = ${encodedRoutes} as const; export const ultramodernLocalisedUrls = ${encodedLocalisedUrls} as const; -export const ultramodernPublicRoutes = ${encodedPublicRoutes} as const; - -export const ultramodernRouteConfig = { - authoring: 'colocated-route-meta', - generatedManifest: true, - localisedUrls: ultramodernLocalisedUrls, - namespace: ultramodernRouteNamespace, - publicRoutes: ultramodernPublicRoutes, - routes: ultramodernRouteMetadata, - source: 'route-owned', -} as const; `; const manifestPath = path.join(appDirectory, 'src/routes/ultramodern-route-metadata.ts'); yield* fileSystem @@ -294,43 +267,15 @@ export const ultramodernRouteConfig = { const program = Effect.gen(function* generateTanstackRoutesEffect() { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const stdio = yield* Stdio.Stdio; - const moduleDirectory = yield* path - .fromFileUrl(new URL('.', import.meta.url)) - .pipe(Effect.mapError(() => failure('Unable to resolve the route generator directory'))); - const defaultWorkspaceRoot = path.resolve(moduleDirectory, '..'); - const workspaceRoot = yield* Config.string('ULTRAMODERN_WORKSPACE_ROOT').pipe( - Config.withDefault(defaultWorkspaceRoot), - Effect.mapError(() => failure('ULTRAMODERN_WORKSPACE_ROOT is invalid')), - ); - const createBin = yield* Config.string('ULTRAMODERN_CREATE_BIN').pipe( - Config.option, - Effect.map(Option.filter((value) => value.length > 0)), - Effect.mapError(() => failure('ULTRAMODERN_CREATE_BIN is invalid')), - ); - const forwardedArgs = yield* stdio.args; - const ultramodernArgs = ['ultramodern', 'routes-generate', ...forwardedArgs]; - const executable = Option.isSome(createBin) ? 'node' : 'modern-js-create'; - const executableArgs = Option.isSome(createBin) - ? [createBin.value, ...ultramodernArgs] - : ultramodernArgs; - const launchTarget = Option.isSome(createBin) - ? `node with ULTRAMODERN_CREATE_BIN=${createBin.value}` - : 'modern-js-create from PATH'; - const generationStatus = yield* runCommand(executable, executableArgs, { - env: { ULTRAMODERN_WORKSPACE_ROOT: workspaceRoot }, - extendEnv: true, - shell: Option.isNone(createBin) && path.sep === '\\', - stderr: 'inherit', - stdin: 'inherit', - stdout: 'inherit', - }).pipe( - Effect.mapError(() => - failure( - `Failed to launch ${launchTarget} for UltraModern command "${ultramodernArgs.slice(1).join(' ')}"`, - ), - ), - ); + const invocation = yield* resolveUltramodernInvocation({ + command: 'routes-generate', + directoryFailure: 'Unable to resolve the route generator directory', + failure, + launchErrorDetail: () => '', + moduleUrl: import.meta.url, + }); + const { forwardedArgs, workspaceRoot } = invocation; + const generationStatus = yield* launchUltramodern(invocation); if (generationStatus !== 0) { yield* Console.warn( '[ultramodern] Framework route-artifact generation failed; continuing with the repository compatibility manifest. The application build remains the authoritative route-artifact gate.', diff --git a/app/scripts/generated-module-api-boundary.mts b/app/scripts/generated-module-api-boundary.mts index a4841b537..f8acbd990 100644 --- a/app/scripts/generated-module-api-boundary.mts +++ b/app/scripts/generated-module-api-boundary.mts @@ -19,12 +19,21 @@ const toCamelCase = (value: string): string => { const matches = (source: string | undefined, expression: RegExp): boolean => source !== undefined && expression.test(source); -const hasGeneratedHeader = (source: string): boolean => source.startsWith(GENERATED_HEADER); +const hasGeneratedHeader = (source: string | undefined): boolean => + source?.startsWith(GENERATED_HEADER) === true; + +const hasActionBoundary = (source: string | undefined, capability: string): boolean => + source !== undefined && + source.startsWith('// @generated by OntOS Codesmith MicroVertical Action Boundary v1\n') && + source.includes(capability); const hasReadContract = (source: string, escapedCamel: string): boolean => matches( source, - new RegExp(`export const ${escapedCamel}Entrypoint\\s*=\\s*defineTenantModuleEntrypoint`, 'u'), + new RegExp( + `(?:export )?const ${escapedCamel}Entrypoint\\s*=\\s*defineTenantModuleEntrypoint`, + 'u', + ), ) && (source.includes("access: 'read'") || source.includes("access: 'historical_read'")) && source.includes("role: 'api'") && @@ -47,6 +56,47 @@ const hasServerContract = (source: string, readServerSupport: string | undefined source.includes('ReadRuntime') && source.includes('.runRead({'); +const hasGovernedReadRuntime = (source: string): boolean => + source.includes("from './auth/action-principal.ts'") && + source.includes('yield* authenticateOperationPrincipal(') && + source.includes('yield* ReadRuntime') && + source.includes('.runRead({') && + source.includes('registration: options.registration') && + source.includes('ReadPolicyDenied:') && + source.includes('Match.exhaustive') && + source.includes('Effect.catch('); + +const hasSharedServerContract = ( + source: string, + handler: string | undefined, + camel: string, + module = 'governed-read-handler', + operation = 'governedReadHandler', +): boolean => + source.includes(`from './${module}.ts'`) && + source.includes('HttpApiBuilder.group') && + source.includes(`${operation}({`) && + source.includes(`registration: ${camel}Read,`) && + handler !== undefined && + hasGovernedReadRuntime(handler); + +const hasGovernedServerContract = ( + source: string, + readServerSupport: string | undefined, + readHandler: string | undefined, + detailHandler: string | undefined, + camel: string, +): boolean => + (hasGeneratedHeader(source) && hasServerContract(source, readServerSupport)) || + hasSharedServerContract(source, readHandler, camel) || + hasSharedServerContract( + source, + detailHandler, + camel, + 'governed-detail-read-execution', + 'executeGovernedRead', + ); + const hasPublishedContract = ( sharedApi: string, manifest: string, @@ -88,16 +138,14 @@ export const hasCompleteGeneratedModuleApiSeam = ( const principal = sources.get(`${verticalPath}/api/auth/action-principal.ts`); const gateway = sources.get(`${verticalPath}/src/api/action-gateway.ts`); const readServerSupport = sources.get(`${verticalPath}/api/read-server-support.ts`); + const readHandler = sources.get(`${verticalPath}/api/governed-read-handler.ts`); + const detailHandler = sources.get(`${verticalPath}/api/governed-detail-read-execution.ts`); if ( sharedApi === undefined || manifest === undefined || registration === undefined || - principal === undefined || - gateway === undefined || - !principal.startsWith('// @generated by OntOS Codesmith MicroVertical Action Boundary v1\n') || - !principal.includes('authenticateOperationPrincipal') || - !gateway.startsWith('// @generated by OntOS Codesmith MicroVertical Action Boundary v1\n') || - !gateway.includes('operationGateway') + !hasActionBoundary(principal, 'authenticateOperationPrincipal') || + !hasActionBoundary(gateway, 'operationGateway') ) { return false; } @@ -127,10 +175,7 @@ export const hasCompleteGeneratedModuleApiSeam = ( readSource === undefined || clientSource === undefined || serverSource === undefined || - !hasGeneratedHeader(contractSource) || - !hasGeneratedHeader(readSource) || - !hasGeneratedHeader(clientSource) || - !hasGeneratedHeader(serverSource) + ![contractSource, readSource, clientSource].every(hasGeneratedHeader) ) { return false; } @@ -145,7 +190,13 @@ export const hasCompleteGeneratedModuleApiSeam = ( ) && hasReadContract(readSource, escapedCamel) && hasClientContract(clientSource) && - hasServerContract(serverSource, readServerSupport) && + hasGovernedServerContract( + serverSource, + readServerSupport, + readHandler, + detailHandler, + camel, + ) && hasPublishedContract(sharedApi, manifest, registration, escapedStem, escapedPascal) ); }); diff --git a/app/scripts/initialize-local-development.mts b/app/scripts/initialize-local-development.mts index 297332c84..dc26abe3d 100644 --- a/app/scripts/initialize-local-development.mts +++ b/app/scripts/initialize-local-development.mts @@ -1,3 +1,10 @@ +import { + bootstrapPrincipalRecord, + bootstrapRelationshipRequest, + selectBootstrapLegalEntities, + selectBootstrapPrincipals, + selectBootstrapAuthBindings, +} from '../packages/core-runtime/src/install/context-bootstrap-shared.ts'; import { createHash } from 'node:crypto'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -22,7 +29,10 @@ import { isSqlError } from 'effect/unstable/sql/SqlError'; import { AuthConfig } from '../apps/shell-super-app/api/auth/config.ts'; import { AuthDatabase, AuthDatabaseLive } from '../apps/shell-super-app/api/auth/db/client.ts'; import { CoreDatabase, CoreDatabaseLive } from '../packages/core-runtime/src/db/client.ts'; -import type { CoreDatabaseExecutor } from '../packages/core-runtime/src/db/types.ts'; +import type { + CoreDatabaseExecutor, + CoreTransaction, +} from '../packages/core-runtime/src/db/types.ts'; import { account, user } from '../apps/shell-super-app/api/auth/db/schema.ts'; import { DatabaseConfig, @@ -526,6 +536,52 @@ const ensureAuthUser = Effect.fn('LocalDevelopment.ensureAuthUser')(function* en return { status: 'created' as const, userId: created.user.id }; }); +const reconcileLocalModules = Effect.fn('LocalDevelopment.reconcileLocalModules')( + function* reconcileModuleStates(transaction: CoreTransaction, moduleIds: readonly string[]) { + const context = LOCAL_DEVELOPMENT_CONTEXT; + for (const moduleId of moduleIds) { + const moduleStateId = moduleStateIdFor(moduleId); + const moduleCandidates = yield* transaction + .select({ + moduleKey: tenantModuleStates.moduleKey, + state: tenantModuleStates.state, + tenantId: tenantModuleStates.tenantId, + tenantModuleStateId: tenantModuleStates.tenantModuleStateId, + }) + .from(tenantModuleStates) + .where( + or( + eq(tenantModuleStates.tenantModuleStateId, moduleStateId), + and( + eq(tenantModuleStates.tenantId, context.tenantId), + eq(tenantModuleStates.moduleKey, moduleId), + ), + ), + ) + .limit(2); + if (moduleCandidates.length > 1) { + return yield* failure('local_conflict', `The ${moduleId} module-state identity conflicts`); + } + const expectedModuleState = { + moduleKey: moduleId, + state: 'active', + tenantId: context.tenantId, + tenantModuleStateId: moduleStateId, + } as const; + if ( + (yield* classifyLocalModuleState( + `${moduleId} module state`, + moduleCandidates[0], + expectedModuleState, + )) === 'create' + ) { + yield* transaction.insert(tenantModuleStates).values(expectedModuleState); + } + } + return yield* Effect.void; + }, +); + export const reconcileCoreContext = ( database: CoreDatabaseExecutor, authUserId: string, @@ -563,27 +619,7 @@ export const reconcileCoreContext = ( yield* transaction.insert(tenants).values(expectedTenant); } - const legalCandidates = yield* transaction - .select({ - legalEntityId: legalEntities.legalEntityId, - legalName: legalEntities.legalName, - registrationCountry: legalEntities.registrationCountry, - registrationNumber: legalEntities.registrationNumber, - status: legalEntities.status, - tenantId: legalEntities.tenantId, - }) - .from(legalEntities) - .where( - or( - eq(legalEntities.legalEntityId, context.legalEntityId), - and( - eq(legalEntities.tenantId, context.tenantId), - eq(legalEntities.registrationCountry, context.registrationCountry), - eq(legalEntities.registrationNumber, context.registrationNumber), - ), - ), - ) - .limit(2); + const legalCandidates = yield* selectBootstrapLegalEntities(transaction, context); if (legalCandidates.length > 1) { return yield* failure('local_conflict', 'The local Legal Entity identity conflicts'); } @@ -605,24 +641,8 @@ export const reconcileCoreContext = ( yield* transaction.insert(legalEntities).values(expectedLegalEntity); } - const expectedPrincipal = { - displayName: context.principalDisplayName, - kind: 'human', - principalId: context.principalId, - status: 'active', - tenantId: context.tenantId, - } as const; - const principalCandidates = yield* transaction - .select({ - displayName: principals.displayName, - kind: principals.kind, - principalId: principals.principalId, - status: principals.status, - tenantId: principals.tenantId, - }) - .from(principals) - .where(eq(principals.principalId, context.principalId)) - .limit(1); + const expectedPrincipal = bootstrapPrincipalRecord(context); + const principalCandidates = yield* selectBootstrapPrincipals(transaction, context); if ( (yield* classifyExactLocalRecord( 'principal', @@ -633,29 +653,11 @@ export const reconcileCoreContext = ( yield* transaction.insert(principals).values(expectedPrincipal); } - const bindingCandidates = yield* transaction - .select({ - principalAuthBindingId: principalAuthBindings.principalAuthBindingId, - principalId: principalAuthBindings.principalId, - provider: principalAuthBindings.provider, - providerSubjectId: principalAuthBindings.providerSubjectId, - status: principalAuthBindings.status, - subjectType: principalAuthBindings.subjectType, - tenantId: principalAuthBindings.tenantId, - }) - .from(principalAuthBindings) - .where( - or( - eq(principalAuthBindings.principalAuthBindingId, context.authBindingId), - and( - eq(principalAuthBindings.tenantId, context.tenantId), - eq(principalAuthBindings.provider, 'better_auth'), - eq(principalAuthBindings.subjectType, 'user'), - eq(principalAuthBindings.providerSubjectId, authUserId), - ), - ), - ) - .limit(2); + const bindingCandidates = yield* selectBootstrapAuthBindings( + transaction, + context, + authUserId, + ); if (bindingCandidates.length > 1) { return yield* failure('local_conflict', 'The local authentication binding conflicts'); } @@ -678,48 +680,7 @@ export const reconcileCoreContext = ( yield* transaction.insert(principalAuthBindings).values(expectedBinding); } - for (const moduleId of moduleIds) { - const moduleStateId = moduleStateIdFor(moduleId); - const moduleCandidates = yield* transaction - .select({ - moduleKey: tenantModuleStates.moduleKey, - state: tenantModuleStates.state, - tenantId: tenantModuleStates.tenantId, - tenantModuleStateId: tenantModuleStates.tenantModuleStateId, - }) - .from(tenantModuleStates) - .where( - or( - eq(tenantModuleStates.tenantModuleStateId, moduleStateId), - and( - eq(tenantModuleStates.tenantId, context.tenantId), - eq(tenantModuleStates.moduleKey, moduleId), - ), - ), - ) - .limit(2); - if (moduleCandidates.length > 1) { - return yield* failure( - 'local_conflict', - `The ${moduleId} module-state identity conflicts`, - ); - } - const expectedModuleState = { - moduleKey: moduleId, - state: 'active', - tenantId: context.tenantId, - tenantModuleStateId: moduleStateId, - } as const; - if ( - (yield* classifyLocalModuleState( - `${moduleId} module state`, - moduleCandidates[0], - expectedModuleState, - )) === 'create' - ) { - yield* transaction.insert(tenantModuleStates).values(expectedModuleState); - } - } + yield* reconcileLocalModules(transaction, moduleIds); return yield* Effect.void; }), ) @@ -770,28 +731,7 @@ const touchRelationships = ( 'The local authorization relationships could not be reconciled', ), try: async () => { - await client.promises.writeRelationships( - v1.WriteRelationshipsRequest.create({ - updates: relationships.map((item) => - v1.RelationshipUpdate.create({ - operation: v1.RelationshipUpdate_Operation.TOUCH, - relationship: v1.Relationship.create({ - relation: item.relation, - resource: v1.ObjectReference.create({ - objectId: item.resourceId, - objectType: item.resourceType, - }), - subject: v1.SubjectReference.create({ - object: v1.ObjectReference.create({ - objectId: item.subjectId, - objectType: item.subjectType, - }), - }), - }), - }), - ), - }), - ); + await client.promises.writeRelationships(bootstrapRelationshipRequest(relationships)); }, }); }), diff --git a/app/scripts/local-environment-values.mts b/app/scripts/local-environment-values.mts index 43828e263..4237a01e2 100644 --- a/app/scripts/local-environment-values.mts +++ b/app/scripts/local-environment-values.mts @@ -45,10 +45,11 @@ export const localSpiceDbValues = ( const existing = existingValues(lines); const grpcPort = overrides.grpcPort ?? existing.SPICEDB_GRPC_PORT ?? '50051'; const httpPort = overrides.httpPort ?? existing.SPICEDB_HTTP_PORT ?? '8443'; - const preSharedKey = + const resolvePreSharedKey = () => (overrides.preSharedKey === undefined ? undefined : Redacted.value(overrides.preSharedKey)) ?? existing.SPICEDB_PRESHARED_KEY ?? 'ontos-local-development-key'; + const preSharedKey = resolvePreSharedKey(); return { SPICEDB_ENDPOINT: diff --git a/app/scripts/materialize-outbox-worker.mjs b/app/scripts/materialize-outbox-worker.mjs index d51409168..83b147851 100644 --- a/app/scripts/materialize-outbox-worker.mjs +++ b/app/scripts/materialize-outbox-worker.mjs @@ -118,6 +118,40 @@ const makeProductionDependenciesPlugin = ({ packages, path, workspaceRoot }) => }, }); +/** + * @param {string} importedPath External dependency specifier. + * @param {Map }>} packages Workspace manifests. + * @param {Record} dependencies Collected production versions. + */ +const collectProductionDependency = (importedPath, packages, dependencies) => + Effect.gen(function* collectProductionDependencyEffect() { + if (isBuiltin(importedPath)) { + return null; + } + const name = importedPath.startsWith('@') + ? importedPath.split('/').slice(0, 2).join('/') + : importedPath.split('/').at(0); + if (name === undefined) { + return yield* Effect.fail(failure(`Invalid worker dependency ${importedPath}`)); + } + const versions = [...packages.values()].flatMap(({ manifest }) => { + const version = manifest.dependencies?.[name]; + return version !== undefined && !version.startsWith('workspace:') ? [version] : []; + }); + const uniqueVersions = [...new Set(versions)]; + if (uniqueVersions.length !== 1) { + return yield* Effect.fail( + failure(`Worker dependency ${name} must have one declared production version`), + ); + } + const [version] = uniqueVersions; + if (version === undefined) { + return yield* Effect.fail(failure(`Worker dependency ${name} has no version`)); + } + dependencies[name] = version; + return null; + }); + /** * @typedef {{ * appId: string, @@ -164,21 +198,24 @@ const materializeOutboxWorkerEffect = ({ const dependencies = {}; /** @type {Map }>} */ const packages = new Map(); - for (const directory of ['packages', 'apps', 'verticals']) { - const parent = path.join(workspaceRoot, directory); - if (!(yield* fs.exists(parent))) { - continue; - } - for (const entry of yield* fs.readDirectory(parent)) { - const manifestPath = path.join(parent, entry, 'package.json'); - if (!(yield* fs.exists(manifestPath))) { + const collectWorkspacePackages = Effect.gen(function* collectWorkspacePackagesEffect() { + for (const directory of ['packages', 'apps', 'verticals']) { + const parent = path.join(workspaceRoot, directory); + if (!(yield* fs.exists(parent))) { continue; } - const manifestSource = yield* fs.readFileString(manifestPath); - const manifest = yield* Schema.decodeUnknownEffect(PackageManifestSchema)(manifestSource); - packages.set(manifest.name, { manifest }); + for (const entry of yield* fs.readDirectory(parent)) { + const manifestPath = path.join(parent, entry, 'package.json'); + if (!(yield* fs.exists(manifestPath))) { + continue; + } + const manifestSource = yield* fs.readFileString(manifestPath); + const manifest = yield* Schema.decodeUnknownEffect(PackageManifestSchema)(manifestSource); + packages.set(manifest.name, { manifest }); + } } - } + }); + yield* collectWorkspacePackages; const result = yield* Effect.tryPromise({ catch: () => failure(`Unable to bundle the ${appId} Outbox Worker`), try: () => @@ -201,33 +238,11 @@ const materializeOutboxWorkerEffect = ({ ), }); const { metafile } = result; - for (const output of Object.values(metafile.outputs)) { - for (const imported of output.imports.filter((item) => item.external === true)) { - if (isBuiltin(imported.path)) { - continue; - } - const name = imported.path.startsWith('@') - ? imported.path.split('/').slice(0, 2).join('/') - : imported.path.split('/').at(0); - if (name === undefined) { - return yield* Effect.fail(failure(`Invalid worker dependency ${imported.path}`)); - } - const versions = [...packages.values()].flatMap(({ manifest }) => { - const version = manifest.dependencies?.[name]; - return version !== undefined && !version.startsWith('workspace:') ? [version] : []; - }); - const uniqueVersions = [...new Set(versions)]; - if (uniqueVersions.length !== 1) { - return yield* Effect.fail( - failure(`Worker dependency ${name} must have one declared production version`), - ); - } - const [version] = uniqueVersions; - if (version === undefined) { - return yield* Effect.fail(failure(`Worker dependency ${name} has no version`)); - } - dependencies[name] = version; - } + const externalImports = Object.values(metafile.outputs).flatMap((output) => + output.imports.filter((item) => item.external === true), + ); + for (const imported of externalImports) { + yield* collectProductionDependency(imported.path, packages, dependencies); } yield* fs.copyFile( path.join(workspaceRoot, 'topology/reference-topology.json'), diff --git a/app/scripts/materialize-zerops-runtime.mjs b/app/scripts/materialize-zerops-runtime.mjs index 3344b43b3..980c660c9 100644 --- a/app/scripts/materialize-zerops-runtime.mjs +++ b/app/scripts/materialize-zerops-runtime.mjs @@ -666,27 +666,29 @@ const materializeCommand = Command.make( ); const appPackage = yield* readRuntimePackage(pathService.join(appRoot, packageJsonFile)); - if (appPackage.name !== packageName) { - yield* fail(`--package must match ${packageDir}/package.json name`); - } - if (!worker && !(yield* fileSystem.exists(appOutputDir))) { - yield* fail( - `Modern.js package build must produce ${pathService.relative(workspaceRoot, appOutputDir)} before runtime materialization`, - ); - } - - yield* fileSystem.remove(runtimeDir, { force: true, recursive: true }); - yield* fileSystem.makeDirectory(pathService.dirname(runtimeDir), { recursive: true }); - yield* worker - ? fileSystem.makeDirectory(runtimeDir, { recursive: true }) - : fileSystem.copy(appOutputDir, runtimeDir); - const entryPath = pathService.join(runtimeDir, 'index.js'); - if (!worker && !(yield* fileSystem.exists(entryPath))) { - yield* fail( - `Modern.js Node deploy output is missing ${pathService.relative(workspaceRoot, entryPath)}`, - ); - } + const prepareRuntimeDirectory = Effect.gen(function* prepareRuntimeDirectoryEffect() { + if (appPackage.name !== packageName) { + yield* fail(`--package must match ${packageDir}/package.json name`); + } + if (!worker && !(yield* fileSystem.exists(appOutputDir))) { + yield* fail( + `Modern.js package build must produce ${pathService.relative(workspaceRoot, appOutputDir)} before runtime materialization`, + ); + } + yield* fileSystem.remove(runtimeDir, { force: true, recursive: true }); + yield* fileSystem.makeDirectory(pathService.dirname(runtimeDir), { recursive: true }); + yield* worker + ? fileSystem.makeDirectory(runtimeDir, { recursive: true }) + : fileSystem.copy(appOutputDir, runtimeDir); + const entryPath = pathService.join(runtimeDir, 'index.js'); + if (!worker && !(yield* fileSystem.exists(entryPath))) { + yield* fail( + `Modern.js Node deploy output is missing ${pathService.relative(workspaceRoot, entryPath)}`, + ); + } + }); + yield* prepareRuntimeDirectory; const packageJsonPath = pathService.join(runtimeDir, packageJsonFile); /** @type {RuntimePackage} */ let runtimePackage = (yield* readOptionalRuntimePackage(packageJsonPath)) ?? {}; diff --git a/app/scripts/migrate-contacts-authorization.mts b/app/scripts/migrate-contacts-authorization.mts index a366f4e69..3f173e6b0 100644 --- a/app/scripts/migrate-contacts-authorization.mts +++ b/app/scripts/migrate-contacts-authorization.mts @@ -6,7 +6,6 @@ import { Console, Effect, Exit, - flow, ManagedRuntime, Number as EffectNumber, Redacted, @@ -36,11 +35,7 @@ const DENIED_PROBE_PRINCIPAL_ID = 'contacts-identity-migration-denied-probe'; const OUTSIDE_AUTHORITATIVE_CONTEXT_MESSAGE = 'A module-access relationship is outside the authoritative context'; -export const ContactsAuthorizationMigrationModeSchema = Schema.Literals([ - 'finalize', - 'prepare', - 'verify', -]); +const ContactsAuthorizationMigrationModeSchema = Schema.Literals(['finalize', 'prepare', 'verify']); export type ContactsAuthorizationMigrationMode = typeof ContactsAuthorizationMigrationModeSchema.Type; @@ -240,6 +235,14 @@ const hasExpectedRelationshipEnvelope = ( relationship.optionalCaveat === undefined && relationship.optionalExpiresAt === undefined; +const matchesRelationshipSubject = ( + relation: string, + subjectType: string | undefined, + expectedRelation: ContactsAuthorizationRelationship['relation'], +): boolean => + relation === expectedRelation && + subjectType === (expectedRelation === 'accessor' ? 'principal' : 'legal_entity'); + const decodeRelationship = ( relationship: v1.Relationship | undefined, resourceId: string, @@ -256,11 +259,11 @@ const decodeRelationship = ( const subjectType = relationship.subject?.object?.objectType; const { relation } = relationship; const isLegalEntity = - relation === 'legal_entity' && - subjectType === 'legal_entity' && + matchesRelationshipSubject(relation, subjectType, 'legal_entity') && subjectId === legalEntityObjectId; const isAccessor = - relation === 'accessor' && subjectType === 'principal' && activePrincipalIds.has(subjectId); + matchesRelationshipSubject(relation, subjectType, 'accessor') && + activePrincipalIds.has(subjectId); if (isLegalEntity) { return Result.succeed({ relation: 'legal_entity', subjectId, subjectType: 'legal_entity' }); } @@ -416,6 +419,25 @@ const assertContactsPermissions = ( } }); +const deleteLegacyRelationships = ( + client: SpiceDbClient, + resourceId: string, + relationships: readonly ContactsAuthorizationRelationship[], + context: AuthoritativeContext, +) => + Effect.gen(function* deleteLegacyRelationshipsEffect() { + yield* writeRelationships( + client, + v1.RelationshipUpdate_Operation.DELETE, + resourceId, + relationships, + ); + const remaining = yield* readRelationships(client, resourceId, context); + if (remaining.length > 0) { + yield* migrationFailure('Legacy relationship cleanup was incomplete'); + } + }); + const migrateContext = ( client: SpiceDbClient, mode: ContactsAuthorizationMigrationMode, @@ -461,16 +483,7 @@ const migrateContext = ( yield* assertContactsPermissions(client, contactsResourceId, contactsAfter); } if (plan.deleteLegacy) { - yield* writeRelationships( - client, - v1.RelationshipUpdate_Operation.DELETE, - legacyResourceId, - legacy, - ); - const remainingLegacy = yield* readRelationships(client, legacyResourceId, context); - if (remainingLegacy.length > 0) { - return yield* migrationFailure('Legacy relationship cleanup was incomplete'); - } + yield* deleteLegacyRelationships(client, legacyResourceId, legacy, context); } return { deleted: plan.deleteLegacy ? legacy.length : 0, @@ -523,13 +536,6 @@ const migrateContactsAuthorizationEffect = ( const migrationRuntime = ManagedRuntime.make(NodeServices.layer); -export const migrateContactsAuthorization: ( - mode: ContactsAuthorizationMigrationMode, -) => Promise = flow( - migrateContactsAuthorizationEffect, - migrationRuntime.runPromise, -); - const command = Command.make( 'migrate-contacts-authorization', { mode: Argument.choice('mode', ['prepare', 'verify', 'finalize']) }, diff --git a/app/scripts/migrate-strict-effect.mts b/app/scripts/migrate-strict-effect.mts index 10bf4db2a..b46329f17 100644 --- a/app/scripts/migrate-strict-effect.mts +++ b/app/scripts/migrate-strict-effect.mts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { NodeServices } from '@effect/platform-node'; -import { Config, Console, Effect, Exit, Option, Path, Schema, Stdio } from 'effect'; -import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; +import { Effect, Schema } from 'effect'; +import { runUltramodernScript, ultramodernExitCode } from './shared/ultramodern-command.mts'; class StrictEffectMigrationError extends Schema.TaggedError()( 'StrictEffectMigrationError', @@ -11,67 +11,12 @@ class StrictEffectMigrationError extends Schema.TaggedError new StrictEffectMigrationError({ reason }); -const program = Effect.gen(function* migrateStrictEffect() { - const path = yield* Path.Path; - const stdio = yield* Stdio.Stdio; - const processSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const moduleDirectory = yield* path - .fromFileUrl(new URL('.', import.meta.url)) - .pipe( - Effect.mapError(() => failure('Unable to resolve the strict-Effect migration directory')), - ); - const defaultWorkspaceRoot = path.resolve(moduleDirectory, '..'); - const workspaceRoot = yield* Config.string('ULTRAMODERN_WORKSPACE_ROOT').pipe( - Config.withDefault(defaultWorkspaceRoot), - Effect.mapError(() => failure('ULTRAMODERN_WORKSPACE_ROOT is invalid')), - ); - const createBin = yield* Config.string('ULTRAMODERN_CREATE_BIN').pipe( - Config.option, - Effect.map(Option.filter((value) => value.length > 0)), - Effect.mapError(() => failure('ULTRAMODERN_CREATE_BIN is invalid')), - ); - const forwardedArgs = yield* stdio.args; - const ultramodernArgs = ['ultramodern', 'migrate-strict-effect', ...forwardedArgs]; - const executable = Option.isSome(createBin) ? 'node' : 'modern-js-create'; - const executableArgs = Option.isSome(createBin) - ? [createBin.value, ...ultramodernArgs] - : ultramodernArgs; - const launchTarget = Option.isSome(createBin) - ? `node with ULTRAMODERN_CREATE_BIN=${createBin.value}` - : 'modern-js-create from PATH'; - - return Number( - yield* processSpawner - .exitCode( - ChildProcess.make(executable, executableArgs, { - env: { ULTRAMODERN_WORKSPACE_ROOT: workspaceRoot }, - extendEnv: true, - shell: Option.isNone(createBin) && path.sep === '\\', - stderr: 'inherit', - stdin: 'inherit', - stdout: 'inherit', - }), - ) - .pipe( - Effect.mapError((error) => - failure( - `Failed to launch ${launchTarget} for UltraModern command "${ultramodernArgs - .slice(1) - .join(' ')}": ${String(error)}`, - ), - ), - ), - ); -}); - const exit = await Effect.runPromiseExit( - program.pipe( - Effect.tapError((error) => Console.error(error.reason)), - Effect.provide(NodeServices.layer), - Effect.scoped, - ), + runUltramodernScript({ + command: 'migrate-strict-effect', + directoryFailure: 'Unable to resolve the strict-Effect migration directory', + failure, + moduleUrl: import.meta.url, + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); -process.exitCode = Exit.match(exit, { - onFailure: () => 1, - onSuccess: (status) => status, -}); +process.exitCode = ultramodernExitCode(exit); diff --git a/app/scripts/outbox-worker-delivery.mjs b/app/scripts/outbox-worker-delivery.mjs index 115099c4f..7eb5644da 100644 --- a/app/scripts/outbox-worker-delivery.mjs +++ b/app/scripts/outbox-worker-delivery.mjs @@ -16,12 +16,9 @@ const OwnerPackageSchema = Schema.Struct({ /** @typedef {{ readonly _tag: 'OutboxWorkerDeliveryInvalid', readonly reason: string }} OutboxWorkerDeliveryInvalidValue */ /** @typedef {{ readonly entry: string, readonly id: string, readonly ownerId: string, readonly packageName: string, readonly path: string, readonly serviceIdEnv: string, readonly stageSetup: string }} OutboxWorkerDelivery */ -export class OutboxWorkerDeliveryInvalid extends Schema.TaggedError()( - 'OutboxWorkerDeliveryInvalid', - { - reason: Schema.String, - }, -) {} +class OutboxWorkerDeliveryInvalid extends Schema.TaggedError()('OutboxWorkerDeliveryInvalid', { + reason: Schema.String, +}) {} /** * A generated worker host is the deployment capability; topology owns its identity. diff --git a/app/scripts/plan-deployment-impact.mts b/app/scripts/plan-deployment-impact.mts index 61124937a..c6b270f1c 100644 --- a/app/scripts/plan-deployment-impact.mts +++ b/app/scripts/plan-deployment-impact.mts @@ -333,36 +333,47 @@ const requireAuthorizationEvidence = ( return { impact, negativeSmoke, readiness }; }; -const authorizationEvidenceMatches = ( +type PromotionEvidence = Required< + Pick +>; + +const evidenceHasInventoryIdentity = ( + inventory: ProtectedEntrypointInventory, + evidence: { + readonly inventoryHash: string; + readonly schemaVersion: number; + readonly sourceRevision: string; + }, +): boolean => + evidence.schemaVersion === 1 && + evidence.sourceRevision === inventory.sourceRevision && + evidence.inventoryHash === inventory.inventoryHash; + +const readinessMatchesPromotion = ( input: AuthorizationPromotionGateInput, - evidence: Required< - Pick - >, + evidence: PromotionEvidence, ): boolean => { - const { inventory, rollout } = input; const { impact, negativeSmoke, readiness } = evidence; - const impactHash = hashAuthorizationEvidence(impact); - const negativeSmokeHash = hashAuthorizationEvidence(negativeSmoke); return ( - impact.schemaVersion === 1 && - impact.sourceRevision === inventory.sourceRevision && - impact.inventoryHash === inventory.inventoryHash && - impact.totalWouldDeny === 0 && - negativeSmoke.schemaVersion === 1 && - negativeSmoke.sourceRevision === inventory.sourceRevision && - negativeSmoke.inventoryHash === inventory.inventoryHash && - negativeSmoke.environment === input.environment && - readiness.schemaVersion === 1 && readiness.status === 'ready' && readiness.environment === input.environment && - readiness.sourceRevision === inventory.sourceRevision && - readiness.inventoryHash === inventory.inventoryHash && - readiness.impactReportHash === impactHash && - readiness.negativeSmokeHash === negativeSmokeHash && - readiness.approvalReference === rollout.decisionReference + readiness.impactReportHash === hashAuthorizationEvidence(impact) && + readiness.negativeSmokeHash === hashAuthorizationEvidence(negativeSmoke) && + readiness.approvalReference === input.rollout.decisionReference ); }; +const authorizationEvidenceMatches = ( + input: AuthorizationPromotionGateInput, + evidence: PromotionEvidence, +): boolean => + [evidence.impact, evidence.negativeSmoke, evidence.readiness].every((item) => + evidenceHasInventoryIdentity(input.inventory, item), + ) && + evidence.impact.totalWouldDeny === 0 && + evidence.negativeSmoke.environment === input.environment && + readinessMatchesPromotion(input, evidence); + export const validateAuthorizationPromotionGate = ( input: AuthorizationPromotionGateInput, ): NonNullable => { @@ -544,6 +555,25 @@ const validateDistinctTopologyIds = ( } }; +const verticalDependencies = ( + vertical: ReferenceVertical, + id: string, + verticalIds: ReadonlySet, +): readonly string[] => { + const dependencies = [ + ...(vertical.moduleFederation?.verticalRefs ?? []), + ...(vertical.moduleFederation?.remotes ?? []).flatMap((remote) => + remote.id === undefined ? [] : [remote.id], + ), + ]; + for (const dependency of dependencies) { + if (!verticalIds.has(dependency)) { + fail(`topology delivery unit "${id}" references unknown provider "${dependency}"`); + } + } + return dependencies; +}; + const buildVerticalUnits = ( verticals: readonly ReferenceVertical[], verticalIds: ReadonlySet, @@ -566,17 +596,7 @@ const buildVerticalUnits = ( `topology and ownership disagree for "${id}": expected package "${packageName}" at "${ownerPath}", found package "${String(owner.package)}" at "${String(owner.path)}"`, ); } - const dependencies = [ - ...(vertical.moduleFederation?.verticalRefs ?? []), - ...(vertical.moduleFederation?.remotes ?? []).flatMap((remote) => - remote.id === undefined ? [] : [remote.id], - ), - ]; - for (const dependency of dependencies) { - if (!verticalIds.has(dependency)) { - fail(`topology delivery unit "${id}" references unknown provider "${dependency}"`); - } - } + const dependencies = verticalDependencies(vertical, id, verticalIds); units.push({ dependencies: EffectArray.sort([...new Set(dependencies)], Order.String), id, @@ -782,18 +802,20 @@ const isAuthorizationRolloutChange = (changedPath: string): boolean => changedPath === 'scripts/report-fail-closed-authorization-impact.mts' || changedPath === 'topology/authorization-rollout.json'; +const CONSERVATIVE_FULL_DEPLOY_PATHS = new Set([ + '.mise.toml', + 'package.json', + 'pnpm-lock.yaml', + 'pnpm-workspace.yaml', + 'scripts/install-zerops-node.sh', + 'scripts/generate-outbox-worker-deployment.mjs', + 'scripts/materialize-outbox-worker.mjs', + 'scripts/materialize-zerops-runtime.mjs', + 'scripts/outbox-worker-delivery.mjs', + 'zerops.yaml', +]); const isConservativeFullDeployChange = (changedPath: string): boolean => - changedPath === '.mise.toml' || - changedPath === 'package.json' || - changedPath === 'pnpm-lock.yaml' || - changedPath === 'pnpm-workspace.yaml' || - changedPath === 'scripts/install-zerops-node.sh' || - changedPath === 'scripts/generate-outbox-worker-deployment.mjs' || - changedPath === 'scripts/materialize-outbox-worker.mjs' || - changedPath === 'scripts/materialize-zerops-runtime.mjs' || - changedPath === 'scripts/outbox-worker-delivery.mjs' || - changedPath === 'zerops.yaml' || - changedPath.startsWith('topology/'); + CONSERVATIVE_FULL_DEPLOY_PATHS.has(changedPath) || changedPath.startsWith('topology/'); const toPhase = (unit: TopologyUnit): DeploymentPhase => ({ id: unit.id, @@ -933,6 +955,41 @@ const deriveDeploymentImpact = ( return state; }; +const deploymentComparison = (options: PlanDeploymentImpactOptions, rootDirectory: string) => + Effect.gen(function* deploymentComparisonEffect() { + const headRevision = options.headRevision ?? 'HEAD'; + const fallbackReason = + options.changedPaths === undefined + ? yield* invalidBaseReason(rootDirectory, options.baseRevision, headRevision) + : undefined; + const fullDeploy = fallbackReason !== undefined; + const comparedPaths = + options.changedPaths ?? + (fullDeploy + ? [] + : yield* changedPathsFromGit( + rootDirectory, + requireString(options.baseRevision, 'base revision'), + headRevision, + )); + const changedPaths = EffectArray.sort( + [...new Set(comparedPaths.map(normalizeChangedPath))], + Order.String, + ); + return { changedPaths, fallbackReason, fullDeploy, headRevision }; + }); + +const validateWorkerStageSetups = ( + workers: readonly { readonly stageSetup: string }[], + stageSetups: ReadonlySet, +): void => { + for (const delivery of workers) { + if (!stageSetups.has(delivery.stageSetup)) { + fail(`Missing generated worker setup ${delivery.stageSetup}`); + } + } +}; + export const planDeploymentImpact = (options: PlanDeploymentImpactOptions = {}) => Effect.gen(function* planDeploymentImpactEffect() { const authorization = @@ -965,34 +1022,15 @@ export const planDeploymentImpact = (options: PlanDeploymentImpactOptions = {}) ), ); const workers = workerDeliveries.filter((delivery) => delivery !== undefined); - for (const delivery of workers) { - if (!stageSetups.has(delivery.stageSetup)) { - fail(`Missing generated worker setup ${delivery.stageSetup}`); - } - } + validateWorkerStageSetups(workers, stageSetups); const shell = orderedUnits.find((unit) => unit.kind === 'shell'); if (shell === undefined) { return fail('reference topology has no Shell delivery unit'); } - const headRevision = options.headRevision ?? 'HEAD'; - const fallbackReason = - options.changedPaths === undefined - ? yield* invalidBaseReason(rootDirectory, options.baseRevision, headRevision) - : undefined; - const fullDeploy = fallbackReason !== undefined; - const comparedPaths = - options.changedPaths ?? - (fullDeploy - ? [] - : yield* changedPathsFromGit( - rootDirectory, - requireString(options.baseRevision, 'base revision'), - headRevision, - )); - const changedPaths = EffectArray.sort( - [...new Set(comparedPaths.map(normalizeChangedPath))], - Order.String, + const { changedPaths, fallbackReason, fullDeploy, headRevision } = yield* deploymentComparison( + options, + rootDirectory, ); const { impacted, migrator, spicedb } = deriveDeploymentImpact( diff --git a/app/scripts/postgres/spicedb-database-config.d.mts b/app/scripts/postgres/spicedb-database-config.d.mts deleted file mode 100644 index 9db0adb2c..000000000 --- a/app/scripts/postgres/spicedb-database-config.d.mts +++ /dev/null @@ -1,5 +0,0 @@ -export { parseSpiceDbDatabaseBootstrapConfig } from '../../packages/core-runtime/src/install/spicedb-database-config.ts'; -export type { - SpiceDbDatabaseBootstrapConfig, - SpiceDbDatabaseBootstrapEnvironment, -} from '../../packages/core-runtime/src/install/spicedb-database-config.ts'; diff --git a/app/scripts/prepare-dev-module-contract.mts b/app/scripts/prepare-dev-module-contract.mts index a665fecc3..a4c37049a 100644 --- a/app/scripts/prepare-dev-module-contract.mts +++ b/app/scripts/prepare-dev-module-contract.mts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { createRequire } from 'node:module'; -import { Console, Effect, Exit, FileSystem, Layer, Path, Result, Schema } from 'effect'; +import { loadCoreNodeServices } from './shared/core-node-services.mts'; +import { Console, Effect, Exit, FileSystem, Path, Schema } from 'effect'; import { Argument, Command } from 'effect/unstable/cli'; import { generateOntosModuleContract } from './generate-ontos-module-contract.mts'; @@ -60,7 +60,7 @@ const prepareDevModuleContractCommand = Command.make( ); }).pipe( Effect.mapError((cause) => - cause instanceof ModuleContractPreparationError + Schema.is(ModuleContractPreparationError)(cause) ? cause : new ModuleContractPreparationError({ cause, @@ -70,25 +70,12 @@ const prepareDevModuleContractCommand = Command.make( ), ); -const loadFromCoreRuntime = createRequire( - new URL('../packages/core-runtime/package.json', import.meta.url), -); -const nodePlatform: unknown = loadFromCoreRuntime('@effect/platform-node'); -const AnyLayerSchema = Schema.declare(Layer.isLayer); -const NodeServicesLayerSchema = Schema.declare>( - (value): value is Layer.Layer => Schema.is(AnyLayerSchema)(value), -); -const NodePlatformSchema = Schema.Struct({ - NodeServices: Schema.Struct({ layer: NodeServicesLayerSchema }), -}); -const { NodeServices } = Result.getOrThrow( - Schema.decodeUnknownResult(NodePlatformSchema)(nodePlatform), -); +const NodeServices = loadCoreNodeServices(); const exit = await Effect.runPromiseExit( Command.run(prepareDevModuleContractCommand, { version: '1.0.0' }).pipe( Effect.tapError((failure) => - failure instanceof ModuleContractPreparationError + Schema.is(ModuleContractPreparationError)(failure) ? Console.error(failure.message) : Effect.void, ), diff --git a/app/scripts/proof-cloudflare-version.mts b/app/scripts/proof-cloudflare-version.mts index 1e7947b50..2865c803b 100644 --- a/app/scripts/proof-cloudflare-version.mts +++ b/app/scripts/proof-cloudflare-version.mts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { NodeServices } from '@effect/platform-node'; -import { Config, Console, Effect, Exit, Option, Path, Schema, Stdio } from 'effect'; -import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; +import { Effect, Schema } from 'effect'; +import { runUltramodernScript, ultramodernExitCode } from './shared/ultramodern-command.mts'; class CloudflareProofLaunchError extends Schema.TaggedError()( 'CloudflareProofLaunchError', @@ -11,65 +11,13 @@ class CloudflareProofLaunchError extends Schema.TaggedError new CloudflareProofLaunchError({ reason }); -const program = Effect.gen(function* proofCloudflareVersionEffect() { - const path = yield* Path.Path; - const stdio = yield* Stdio.Stdio; - const processSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const moduleDirectory = yield* path - .fromFileUrl(new URL('.', import.meta.url)) - .pipe(Effect.mapError(() => failure('Unable to resolve the Cloudflare proof directory'))); - const defaultWorkspaceRoot = path.resolve(moduleDirectory, '..'); - const workspaceRoot = yield* Config.string('ULTRAMODERN_WORKSPACE_ROOT').pipe( - Config.withDefault(defaultWorkspaceRoot), - Effect.mapError(() => failure('ULTRAMODERN_WORKSPACE_ROOT is invalid')), - ); - const createBin = yield* Config.string('ULTRAMODERN_CREATE_BIN').pipe( - Config.option, - Effect.map(Option.filter((value) => value.length > 0)), - Effect.mapError(() => failure('ULTRAMODERN_CREATE_BIN is invalid')), - ); - const forwardedArgs = yield* stdio.args; - const ultramodernArgs = ['ultramodern', 'cloudflare-proof', ...forwardedArgs]; - const executable = Option.isSome(createBin) ? process.execPath : 'modern-js-create'; - const executableArgs = Option.isSome(createBin) - ? [createBin.value, ...ultramodernArgs] - : ultramodernArgs; - const launchTarget = Option.isSome(createBin) - ? `${process.execPath} with ULTRAMODERN_CREATE_BIN=${createBin.value}` - : 'modern-js-create from PATH'; - - return Number( - yield* processSpawner - .exitCode( - ChildProcess.make(executable, executableArgs, { - env: { ULTRAMODERN_WORKSPACE_ROOT: workspaceRoot }, - extendEnv: true, - shell: Option.isNone(createBin) && path.sep === '\\', - stderr: 'inherit', - stdin: 'inherit', - stdout: 'inherit', - }), - ) - .pipe( - Effect.mapError((error) => - failure( - `Failed to launch ${launchTarget} for UltraModern command "${ultramodernArgs - .slice(1) - .join(' ')}": ${String(error)}`, - ), - ), - ), - ); -}); - const exit = await Effect.runPromiseExit( - program.pipe( - Effect.tapError((error) => Console.error(error.reason)), - Effect.provide(NodeServices.layer), - Effect.scoped, - ), + runUltramodernScript({ + command: 'cloudflare-proof', + directoryFailure: 'Unable to resolve the Cloudflare proof directory', + failure, + moduleUrl: import.meta.url, + nodeExecutable: process.execPath, + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); -process.exitCode = Exit.match(exit, { - onFailure: () => 1, - onSuccess: (status) => status, -}); +process.exitCode = ultramodernExitCode(exit); diff --git a/app/scripts/proof-node-backend-federation.mts b/app/scripts/proof-node-backend-federation.mts index 47bc3ca28..1f3d1741a 100644 --- a/app/scripts/proof-node-backend-federation.mts +++ b/app/scripts/proof-node-backend-federation.mts @@ -1,7 +1,9 @@ #!/usr/bin/env node import { NodeServices } from '@effect/platform-node'; -import { Config, Console, Effect, Exit, Option, Path, Schema, Stdio } from 'effect'; -import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; +import { Config, Console, Effect, Exit, Option, Path, Predicate, Schema, Stdio } from 'effect'; +import { ChildProcessSpawner } from 'effect/unstable/process'; + +import { ultramodernLaunch } from './shared/ultramodern-launch.mts'; class BackendFederationProofLaunchError extends Schema.TaggedError()( 'BackendFederationProofLaunchError', @@ -21,52 +23,27 @@ const program = Effect.gen(function* backendFederationProofProgram() { Config.withDefault(path.resolve(import.meta.dirname, '..')), ); const ultramodernArgs = ['ultramodern', 'backend-federation-proof', ...forwardedArgs]; - const launch = Option.match(createBin, { - onNone: () => ({ - args: ultramodernArgs, - executable: 'modern-js-create', - shell: path.sep === '\\', - target: 'modern-js-create from PATH', - }), - onSome: (bin) => ({ - args: [bin, ...ultramodernArgs], - executable: process.execPath, - shell: false, - target: `${process.execPath} with ULTRAMODERN_CREATE_BIN=${bin}`, + const launch = ultramodernLaunch(createBin, ultramodernArgs, workspaceRoot, path.sep); + return yield* processSpawner.exitCode(launch.command).pipe( + Effect.matchEffect({ + onFailure: (cause) => { + if (cause.reason.method === 'exitCode') { + return Effect.succeed(1); + } + const launchCause = cause.reason.cause; + const causeMessage = Predicate.isError(launchCause) ? launchCause.message : cause.message; + return Effect.fail( + new BackendFederationProofLaunchError({ + cause, + message: `Failed to launch ${launch.target} for UltraModern command "${ultramodernArgs + .slice(1) + .join(' ')}": ${causeMessage}`, + }), + ); + }, + onSuccess: (status) => Effect.succeed(Number(status)), }), - }); - - return yield* processSpawner - .exitCode( - ChildProcess.make(launch.executable, launch.args, { - env: { ULTRAMODERN_WORKSPACE_ROOT: workspaceRoot }, - extendEnv: true, - shell: launch.shell, - stderr: 'inherit', - stdin: 'inherit', - stdout: 'inherit', - }), - ) - .pipe( - Effect.matchEffect({ - onFailure: (cause) => { - if (cause.reason.method === 'exitCode') { - return Effect.succeed(1); - } - const launchCause = cause.reason.cause; - const causeMessage = launchCause instanceof Error ? launchCause.message : cause.message; - return Effect.fail( - new BackendFederationProofLaunchError({ - cause, - message: `Failed to launch ${launch.target} for UltraModern command "${ultramodernArgs - .slice(1) - .join(' ')}": ${causeMessage}`, - }), - ); - }, - onSuccess: (status) => Effect.succeed(Number(status)), - }), - ); + ); }); const exit = await Effect.runPromiseExit( diff --git a/app/scripts/proof-workerd-ssr.mts b/app/scripts/proof-workerd-ssr.mts index d303fd9b8..c2d247088 100644 --- a/app/scripts/proof-workerd-ssr.mts +++ b/app/scripts/proof-workerd-ssr.mts @@ -480,6 +480,21 @@ const resolveProofRoutes = ( return [configuredSsrRoute?.startsWith('/') === true ? configuredSsrRoute : '/']; }; +const deriveAppConfiguration = (rawApp: typeof RawAppSchema.Type) => { + const cloudflare = rawApp.deploy?.cloudflare; + return { + apiPrefix: rawApp.api?.prefix?.replace(/\/+$/u, ''), + id: rawApp.id, + jsonSmokeChecks: cloudflare?.jsonSmokeChecks ?? [], + port: rawApp.port, + proofRoutes: resolveProofRoutes( + cloudflare?.distributedSsrProofRoutes ?? [], + cloudflare?.routes?.ssr, + ), + verticalRefs: rawApp.moduleFederation?.verticalRefs ?? [], + }; +}; + const loadApps = (workspaceRoot: string): ProofEffect => Effect.gen(function* loadAppsEffect() { const fileSystem = yield* FileSystem.FileSystem; @@ -504,21 +519,12 @@ const loadApps = (workspaceRoot: string): ProofEffect => kind === 'vertical' ? yield* readExecutionEnvelope(rawApp.id, outputRoot, rawApp.deliveryUnit?.unitId) : undefined; - const cloudflare = rawApp.deploy?.cloudflare; return { - apiPrefix: rawApp.api?.prefix?.replace(/\/+$/u, ''), + ...deriveAppConfiguration(rawApp), envelope: executedEnvelope?.envelope, envelopePath: executedEnvelope?.envelopePath, - id: rawApp.id, - jsonSmokeChecks: cloudflare?.jsonSmokeChecks ?? [], kind, outputRoot, - port: rawApp.port, - proofRoutes: resolveProofRoutes( - cloudflare?.distributedSsrProofRoutes ?? [], - cloudflare?.routes?.ssr, - ), - verticalRefs: rawApp.moduleFederation?.verticalRefs ?? [], wrangler, }; }), @@ -566,22 +572,26 @@ const createWorkerConfiguration = ( boundModules.some((module) => module.logicalPath === mainLogicalPath), `${app.id} Miniflare main ${mainLogicalPath} is not in the selected module set`, ); - const apiBackend = app.envelope?.surfaces.apiBackend ?? []; - const ssr = app.envelope?.surfaces.ssr ?? []; - yield* ensure( - app.kind !== 'vertical' || - (apiBackend.length > 0 && - apiBackend.every((logicalPath) => - boundModules.some((module) => module.logicalPath === logicalPath), - )), - `${app.id} BFF worker surface is not selected by Miniflare`, - ); - yield* ensure( - app.kind !== 'vertical' || - (ssr.includes(mainLogicalPath) && - boundModules.every((module) => [...ssr, ...apiBackend].includes(module.logicalPath))), - `${app.id} Miniflare main/SSR modules are not envelope-bound SSR surfaces`, - ); + const validateSelectedSurfaces = Effect.gen(function* validateSelectedSurfacesEffect() { + const apiBackend = app.envelope?.surfaces.apiBackend ?? []; + const ssr = app.envelope?.surfaces.ssr ?? []; + const selectedPaths = new Set(boundModules.map((module) => module.logicalPath)); + yield* ensure( + app.kind !== 'vertical' || + (apiBackend.length > 0 && + apiBackend.every((logicalPath) => selectedPaths.has(logicalPath))), + `${app.id} BFF worker surface is not selected by Miniflare`, + ); + yield* ensure( + app.kind !== 'vertical' || + (ssr.includes(mainLogicalPath) && + boundModules.every((module) => [...ssr, ...apiBackend].includes(module.logicalPath))), + `${app.id} Miniflare main/SSR modules are not envelope-bound SSR surfaces`, + ); + + return { apiBackend }; + }); + const { apiBackend } = yield* validateSelectedSurfaces; const name = yield* workerName(app); const options: ProofWorkerOptions = { assets: { diff --git a/app/scripts/provision-current-action-authorization.mts b/app/scripts/provision-current-action-authorization.mts index cc749e277..3ffae2a37 100644 --- a/app/scripts/provision-current-action-authorization.mts +++ b/app/scripts/provision-current-action-authorization.mts @@ -198,21 +198,26 @@ const discoverCurrentActionsEffect = ( }).pipe(Effect.map((contract) => ({ contract, id }))), { concurrency: 'unbounded' }, ); - const verticalActions: ActionAuthorizationProvisioningAction[] = []; - for (const { contract, id } of contracts) { - if ( - contract.deployment.appId !== id || - contract.manifest.publicSurface.actions.length === 0 - ) { - return yield* discoveryFailure(); - } - for (const { actionKey, entrypoint } of contract.manifest.publicSurface.actions) { - if (entrypoint?.authorization.kind !== 'action_execution') { + const collectVerticalActions = Effect.gen(function* collectVerticalActionsEffect() { + const verticalActions: ActionAuthorizationProvisioningAction[] = []; + for (const { contract, id } of contracts) { + if ( + contract.deployment.appId !== id || + contract.manifest.publicSurface.actions.length === 0 + ) { return yield* discoveryFailure(); } - verticalActions.push({ actionKey, provisioning: entrypoint.authorization.provisioning }); + for (const { actionKey, entrypoint } of contract.manifest.publicSurface.actions) { + if (entrypoint?.authorization.kind !== 'action_execution') { + return yield* discoveryFailure(); + } + verticalActions.push({ actionKey, provisioning: entrypoint.authorization.provisioning }); + } } - } + + return { verticalActions }; + }); + const { verticalActions } = yield* collectVerticalActions; const coreActions: ActionAuthorizationProvisioningAction[] = []; for (const { actionKey, entrypoint } of coreActionCatalog) { if (entrypoint.authorization.kind !== 'action_execution') { diff --git a/app/scripts/published-outbox-contracts.mts b/app/scripts/published-outbox-contracts.mts index 0796eea38..9303cddd7 100644 --- a/app/scripts/published-outbox-contracts.mts +++ b/app/scripts/published-outbox-contracts.mts @@ -36,6 +36,17 @@ const sortLexically = (values: readonly string[]): readonly string[] => { return sorted; }; +const packageMatchesManifest = ( + packageJson: PublishedOutboxPackage, + packageName: string, + appId: string, + moduleId: string, +): boolean => + packageJson.name === packageName && + packageJson.modernjs?.appId === appId && + packageJson.modernjs?.ontosModule?.manifest === './vertical.manifest.ts' && + packageJson.modernjs?.ontosModule?.moduleId === moduleId; + export const resolvePublishedContractModuleId = (input: { readonly dependencyPackageJson: PublishedOutboxPackage; readonly dependencyPackageName: string; @@ -55,10 +66,12 @@ export const resolvePublishedContractModuleId = (input: { moduleId !== undefined && appIds.length === 1 && appIds[0] === input.expectedAppId && - input.dependencyPackageJson.name === input.dependencyPackageName && - input.dependencyPackageJson.modernjs?.appId === input.expectedAppId && - input.dependencyPackageJson.modernjs?.ontosModule?.manifest === './vertical.manifest.ts' && - input.dependencyPackageJson.modernjs?.ontosModule?.moduleId === moduleId, + packageMatchesManifest( + input.dependencyPackageJson, + input.dependencyPackageName, + input.expectedAppId, + moduleId, + ), `${input.dependencyPackageName} package and generated manifest ownership disagree`, ); return moduleId; @@ -108,7 +121,7 @@ export const publishedResourceRefContractExports = ( .map(([exportKey]) => exportKey), ); -export const publishedEffectClientContractExports = ( +const publishedEffectClientContractExports = ( packageJson: PublishedOutboxPackage, ): readonly string[] => { const appId = packageJson.modernjs?.appId; @@ -132,12 +145,31 @@ const importedModuleSpecifiers = (source: string): readonly string[] => .map((match) => match.groups?.specifier) .filter((specifier): specifier is string => specifier !== undefined); -const isGeneratedSchemaOnlyResourceRef = (input: { +const hasResourceRefDeclarations = (input: { readonly moduleId: string; readonly slug: string; readonly source: string; }): boolean => { const resourceType = `${input.moduleId}.${input.slug}`; + return ( + /export const [A-Z][A-Za-z0-9]*RefSchema = Schema\.Struct\(/u.test(input.source) && + /export type [A-Z][A-Za-z0-9]*Ref = typeof [A-Z][A-Za-z0-9]*RefSchema\.Type;/u.test( + input.source, + ) && + input.source.includes(`moduleId: Schema.Literal('${input.moduleId}')`) && + input.source.includes(`resourceType: Schema.Literal('${resourceType}')`) && + /export const [a-z][A-Za-z0-9]*ResourceDescriptor = \{/u.test(input.source) && + input.source.includes(`key: '${resourceType}'`) && + input.source.includes(`owningModuleId: '${input.moduleId}'`) && + input.source.includes('satisfies OntosResourceType') + ); +}; + +const isGeneratedSchemaOnlyResourceRef = (input: { + readonly moduleId: string; + readonly slug: string; + readonly source: string; +}): boolean => { const expectedHeader = `${RESOURCE_HEADER}\n// @ontos-resource-owner ${input.moduleId}\n// @ontos-resource-slug ${input.slug}\n`; const imports = importedModuleSpecifiers(input.source); return ( @@ -150,16 +182,7 @@ const isGeneratedSchemaOnlyResourceRef = (input: { input.source, ) && !input.source.includes('=>') && - /export const [A-Z][A-Za-z0-9]*RefSchema = Schema\.Struct\(/u.test(input.source) && - /export type [A-Z][A-Za-z0-9]*Ref = typeof [A-Z][A-Za-z0-9]*RefSchema\.Type;/u.test( - input.source, - ) && - input.source.includes(`moduleId: Schema.Literal('${input.moduleId}')`) && - input.source.includes(`resourceType: Schema.Literal('${resourceType}')`) && - /export const [a-z][A-Za-z0-9]*ResourceDescriptor = \{/u.test(input.source) && - input.source.includes(`key: '${resourceType}'`) && - input.source.includes(`owningModuleId: '${input.moduleId}'`) && - input.source.includes('satisfies OntosResourceType') + hasResourceRefDeclarations(input) ); }; @@ -181,6 +204,23 @@ const isGeneratedEffectClientLeaf = (source: string, appId: string): boolean => GENERATED_CLIENT_HEADERS.some((header) => source.startsWith(header)) || source.startsWith(`${COMMAND_CLIENT_HEADER}// @ontos-command-client-owner ${appId}\n`); +const hasValidActionGateway = ( + imports: readonly string[], + input: { readonly appId: string; readonly readOwnerSource: (path: string) => string }, +): boolean => { + if (imports.includes('./action-gateway.ts')) { + const gateway = input.readOwnerSource('./src/api/action-gateway.ts'); + if ( + !gateway.startsWith(ACTION_GATEWAY_HEADER) || + !gateway.includes(`// @ontos-action-boundary-owner ${input.appId}\n`) || + !gateway.includes(`// @ontos-action-boundary-audience ${input.appId}\n`) + ) { + return false; + } + } + return true; +}; + const isGeneratedPublicEffectClient = (input: { readonly appId: string; readonly dependencyPackageName: string; @@ -219,61 +259,26 @@ const isGeneratedPublicEffectClient = (input: { return false; } } - if (imports.includes('./action-gateway.ts')) { - const gateway = input.readOwnerSource('./src/api/action-gateway.ts'); - if ( - !gateway.startsWith(ACTION_GATEWAY_HEADER) || - !gateway.includes(`// @ontos-action-boundary-owner ${input.appId}\n`) || - !gateway.includes(`// @ontos-action-boundary-audience ${input.appId}\n`) - ) { - return false; - } - } - return true; + return hasValidActionGateway(imports, input); }; -export const assertPublishedCrossMicroVerticalContractUsage = (input: { +interface PublishedContractUsageInput { readonly dependencyDeclared: boolean; readonly dependencyPackageJson: PublishedOutboxPackage; readonly dependencyPackageName: string; readonly moduleSpecifiers: readonly string[]; readonly projectReferenceDeclared: boolean; readonly readExportSource: (exportTarget: string) => string; -}): void => { - const dependencySpecifiers = input.moduleSpecifiers.filter( - (specifier) => - specifier === input.dependencyPackageName || - specifier.startsWith(`${input.dependencyPackageName}/`), - ); - assertCondition( - dependencySpecifiers.length > 0, - `${input.dependencyPackageName} is an unused cross-MicroVertical dependency`, - ); - assertCondition( - input.dependencyDeclared, - `consumer must declare ${input.dependencyPackageName} as a workspace dependency`, - ); - assertCondition( - input.projectReferenceDeclared, - `consumer must project-reference ${input.dependencyPackageName}`, - ); +} - const outboxExports = publishedOutboxContractExports(input.dependencyPackageJson); - const resourceExports = publishedResourceRefContractExports(input.dependencyPackageJson); - const effectClientExports = publishedEffectClientContractExports(input.dependencyPackageJson); - const allowedSpecifiers = new Set( - [...outboxExports, ...resourceExports, ...effectClientExports].map( - (exportKey) => `${input.dependencyPackageName}${exportKey.slice(1)}`, - ), - ); - const forbiddenSpecifier = dependencySpecifiers.find( - (specifier) => !allowedSpecifiers.has(specifier), - ); - assertCondition( - forbiddenSpecifier === undefined, - `${forbiddenSpecifier} is not a published schema-only contract subpath`, - ); +const resourceExportSlug = (exportKey: string): string | undefined => + resourceExportPattern.exec(exportKey)?.groups?.slug; +const assertResourceRefUsage = ( + input: PublishedContractUsageInput, + dependencySpecifiers: readonly string[], + resourceExports: readonly string[], +): void => { const moduleId = input.dependencyPackageJson.modernjs?.ontosModule?.moduleId; for (const resourceExport of resourceExports) { const specifier = `${input.dependencyPackageName}${resourceExport.slice(1)}`; @@ -281,7 +286,7 @@ export const assertPublishedCrossMicroVerticalContractUsage = (input: { continue; } const target = input.dependencyPackageJson.exports?.[resourceExport]; - const slug = resourceExportPattern.exec(resourceExport)?.groups?.slug; + const slug = resourceExportSlug(resourceExport); assertCondition( moduleId !== undefined && target !== undefined && @@ -294,7 +299,13 @@ export const assertPublishedCrossMicroVerticalContractUsage = (input: { `${specifier} must remain a generated schema-only ResourceRef contract`, ); } +}; +const assertEffectClientUsage = ( + input: PublishedContractUsageInput, + dependencySpecifiers: readonly string[], + effectClientExports: readonly string[], +): void => { if (effectClientExports.length === 1) { const specifier = `${input.dependencyPackageName}/api/client`; if (dependencySpecifiers.includes(specifier)) { @@ -315,6 +326,47 @@ export const assertPublishedCrossMicroVerticalContractUsage = (input: { } }; +export const assertPublishedCrossMicroVerticalContractUsage = ( + input: PublishedContractUsageInput, +): void => { + const dependencySpecifiers = input.moduleSpecifiers.filter( + (specifier) => + specifier === input.dependencyPackageName || + specifier.startsWith(`${input.dependencyPackageName}/`), + ); + assertCondition( + dependencySpecifiers.length > 0, + `${input.dependencyPackageName} is an unused cross-MicroVertical dependency`, + ); + assertCondition( + input.dependencyDeclared, + `consumer must declare ${input.dependencyPackageName} as a workspace dependency`, + ); + assertCondition( + input.projectReferenceDeclared, + `consumer must project-reference ${input.dependencyPackageName}`, + ); + + const outboxExports = publishedOutboxContractExports(input.dependencyPackageJson); + const resourceExports = publishedResourceRefContractExports(input.dependencyPackageJson); + const effectClientExports = publishedEffectClientContractExports(input.dependencyPackageJson); + const allowedSpecifiers = new Set( + [...outboxExports, ...resourceExports, ...effectClientExports].map( + (exportKey) => `${input.dependencyPackageName}${exportKey.slice(1)}`, + ), + ); + const forbiddenSpecifier = dependencySpecifiers.find( + (specifier) => !allowedSpecifiers.has(specifier), + ); + assertCondition( + forbiddenSpecifier === undefined, + `${forbiddenSpecifier} is not a published schema-only contract subpath`, + ); + + assertResourceRefUsage(input, dependencySpecifiers, resourceExports); + assertEffectClientUsage(input, dependencySpecifiers, effectClientExports); +}; + export const assertPublishedOutboxDependencyUsage = (input: { readonly dependencyPackageJson: PublishedOutboxPackage; readonly dependencyPackageName: string; diff --git a/app/scripts/scaffolding/action-service/scaffold.mts b/app/scripts/scaffolding/action-service/scaffold.mts index ca7204775..ba9118768 100644 --- a/app/scripts/scaffolding/action-service/scaffold.mts +++ b/app/scripts/scaffolding/action-service/scaffold.mts @@ -21,7 +21,7 @@ import { Effect } from 'effect'; export const ${toCamelCase(service)}Service = () => Effect.succeed({}); `; -export const planActionServiceScaffold = Effect.fn('ActionServiceScaffold.plan')( +const planActionServiceScaffold = Effect.fn('ActionServiceScaffold.plan')( function* planActionServiceScaffold(workspaceRoot: string, config: ActionServiceScaffoldConfig) { const service = yield* tryScaffold('service name is invalid', () => requireCanonicalSlug(config.service, 'service'), diff --git a/app/scripts/scaffolding/action/scaffold.mts b/app/scripts/scaffolding/action/scaffold.mts index 3e5d64282..8ad0a921f 100644 --- a/app/scripts/scaffolding/action/scaffold.mts +++ b/app/scripts/scaffolding/action/scaffold.mts @@ -287,7 +287,7 @@ const planCoreActionScaffold = Effect.fn('ActionScaffold.planCore')( }, ); -export const planActionScaffold = Effect.fn('ActionScaffold.plan')(function* planActionScaffold( +const planActionScaffold = Effect.fn('ActionScaffold.plan')(function* planActionScaffold( workspaceRoot: string, config: ActionScaffoldConfig, ) { diff --git a/app/scripts/scaffolding/cli.mts b/app/scripts/scaffolding/cli.mts index 4d4b99fc5..42c405ede 100644 --- a/app/scripts/scaffolding/cli.mts +++ b/app/scripts/scaffolding/cli.mts @@ -320,6 +320,10 @@ const requireReadAuthorization = ( return { authorization: flags.authorizationMode }; }); +const isActionProvisioning = Schema.is(Schema.Literals(['tenant_membership_default', 'explicit'])); +const isAccessFiltering = Schema.is(Schema.Literals(['resource_permission', 'tenant_scope'])); +const isSearchLegalEntityScope = Schema.is(Schema.Literals(['required', 'optional'])); + const commandDefinitions = { action: defineCommand({ flags: [ @@ -364,10 +368,7 @@ Options: if (flags.authorizationMode !== 'action_execution') { return yield* failScaffolding('--authorization must be action_execution for Actions'); } - if ( - flags.provisioning !== 'tenant_membership_default' && - flags.provisioning !== 'explicit' - ) { + if (!isActionProvisioning(flags.provisioning)) { return yield* failScaffolding( '--provisioning must be tenant_membership_default or explicit', ); @@ -820,15 +821,15 @@ Options: Effect.gen(function* searchProviderAccessConfigEffect() { const { accessFiltering, legalEntityScope, tenantPermission } = flags; const filters = (flags.requestFilters ?? '').split(',').filter((value) => value !== ''); - if (accessFiltering !== 'resource_permission' && accessFiltering !== 'tenant_scope') { + if (!isAccessFiltering(accessFiltering)) { return yield* failScaffolding( '--access-filtering must be resource_permission or tenant_scope', ); } - if (legalEntityScope !== 'required' && legalEntityScope !== 'optional') { + if (!isSearchLegalEntityScope(legalEntityScope)) { return yield* failScaffolding('--legal-entity-scope must be required or optional'); } - if (filters.some((filter) => filter !== 'includeArchived' && filter !== 'role')) { + if (!filters.every(isRequestFilter)) { return yield* failScaffolding( '--request-filters may contain only includeArchived and role', ); @@ -856,6 +857,36 @@ export const isScaffoldCommand = Schema.is(ScaffoldCommandSchema); export const getHelpText = (command: ScaffoldCommand): string => commandDefinitions[command].help; +const isFlagArgument = (flag: string): boolean => + flag.startsWith('--') && flag !== '--' && !flag.includes('='); + +const parseFlagPair = ( + command: ScaffoldCommand, + allowed: ReadonlySet, + parsed: Map, + flag: string | undefined, + value: string | undefined, +) => + Effect.gen(function* parseFlagPairEffect() { + if (flag === undefined || !isFlagArgument(flag)) { + return yield* failScaffolding( + `invalid argument ${flag ?? ''}; use separate --flag value pairs`, + ); + } + const name = flag.slice(2); + if (!allowed.has(name)) { + return yield* failScaffolding(`unknown flag --${name} for scaffold:${command}`); + } + if (parsed.has(name)) { + return yield* failScaffolding(`flag --${name} may be supplied only once`); + } + if (value === undefined || value.startsWith('--') || value.trim().length === 0) { + return yield* failScaffolding(`flag --${name} requires one non-empty value`); + } + parsed.set(name, value); + return yield* Effect.void; + }); + const normalizeForwardedArguments = (argumentsList: readonly string[]): readonly string[] => { if (argumentsList[0] === '--') { return argumentsList.slice(1); @@ -874,22 +905,7 @@ const parseFlags = ( for (let index = 0; index < argumentsList.length; index += 2) { const flag = argumentsList[index]; const value = argumentsList[index + 1]; - if (flag === undefined || !flag.startsWith('--') || flag === '--' || flag.includes('=')) { - return yield* failScaffolding( - `invalid argument ${flag ?? ''}; use separate --flag value pairs`, - ); - } - const name = flag.slice(2); - if (!allowed.has(name)) { - return yield* failScaffolding(`unknown flag --${name} for scaffold:${command}`); - } - if (parsed.has(name)) { - return yield* failScaffolding(`flag --${name} may be supplied only once`); - } - if (value === undefined || value.startsWith('--') || value.trim().length === 0) { - return yield* failScaffolding(`flag --${name} requires one non-empty value`); - } - parsed.set(name, value); + yield* parseFlagPair(command, allowed, parsed, flag, value); } for (const required of definition.requiredFlags) { if (!parsed.has(required)) { diff --git a/app/scripts/scaffolding/external-http-adapter/scaffold.mts b/app/scripts/scaffolding/external-http-adapter/scaffold.mts index 3a692fb55..fc7357d10 100644 --- a/app/scripts/scaffolding/external-http-adapter/scaffold.mts +++ b/app/scripts/scaffolding/external-http-adapter/scaffold.mts @@ -1,5 +1,5 @@ import { createCodesmithGenerator } from '../generator-adapter.mts'; -import { Effect } from 'effect'; +import { Effect, Predicate } from 'effect'; import { createMutationEffect, discoverOntosModuleEffect, @@ -20,8 +20,8 @@ import type { const preserveFileSystemCause = (failure: ScaffoldFailure): ScaffoldFailure => { const { cause } = failure; - const underlying = cause instanceof Error ? cause.cause : undefined; - return underlying instanceof Error + const underlying = Predicate.isError(cause) ? cause.cause : undefined; + return Predicate.isError(underlying) ? scaffoldFailure(`${failure.message}: ${underlying.message}`, cause) : failure; }; @@ -79,7 +79,7 @@ export const ${adapterType}ServiceLive = Layer.effect( `; }; -export const planExternalHttpAdapterScaffold = Effect.fn('ExternalHttpAdapterScaffold.plan')( +const planExternalHttpAdapterScaffold = Effect.fn('ExternalHttpAdapterScaffold.plan')( function* planExternalHttpAdapterScaffold( workspaceRoot: string, config: ExternalHttpAdapterScaffoldConfig, diff --git a/app/scripts/scaffolding/governed-contribution/scaffold.mts b/app/scripts/scaffolding/governed-contribution/scaffold.mts index 4445d3f6b..17afcb151 100644 --- a/app/scripts/scaffolding/governed-contribution/scaffold.mts +++ b/app/scripts/scaffolding/governed-contribution/scaffold.mts @@ -477,20 +477,31 @@ const renderGovernedServer = ( name: string, ): string => { const type = toPascalCase(name); - const isModuleApi = kind === MODULE_API_KIND; - /* eslint-disable no-nested-ternary, unicorn/no-nested-ternary -- Preserve the compact established generator-name mapping. */ - const suffix = kind === REPORT_KIND ? REPORT_KIND : kind === SEARCH_PROVIDER_KIND ? 'search' : ''; - const contract = isModuleApi ? name : `${name}-${suffix}`; - const apiValue = isModuleApi - ? `${type}Api` - : `${type}${kind === REPORT_KIND ? 'Report' : 'Search'}Api`; - const group = isModuleApi ? toCamelCase(name) : kind === REPORT_KIND ? 'reports' : 'search'; + const names = { + [MODULE_API_KIND]: { + apiValue: `${type}Api`, + contract: name, + group: toCamelCase(name), + problemStem: type, + readImport: `../src/api/${name}.read.ts`, + }, + [REPORT_KIND]: { + apiValue: `${type}ReportApi`, + contract: `${name}-report`, + group: 'reports', + problemStem: `${type}Provider`, + readImport: `../src/reports/${name}.provider.ts`, + }, + [SEARCH_PROVIDER_KIND]: { + apiValue: `${type}SearchApi`, + contract: `${name}-search`, + group: 'search', + problemStem: `${type}Provider`, + readImport: `../src/search/${name}.provider.ts`, + }, + }; + const { apiValue, contract, group, problemStem, readImport } = names[kind]; const readValue = `${toCamelCase(name)}Read`; - const readImport = isModuleApi - ? `../src/api/${name}.read.ts` - : `../src/${kind === REPORT_KIND ? 'reports' : 'search'}/${name}.provider.ts`; - /* eslint-enable no-nested-ternary, unicorn/no-nested-ternary */ - const problemStem = `${type}${isModuleApi ? '' : 'Provider'}`; return `${generatedHeader(kind)} import { ReadRuntime } from '@app/core-runtime'; import type { ReadCoreError } from '@app/core-runtime'; @@ -767,63 +778,23 @@ const patchFederationExposure = Effect.fn('GovernedContributionScaffold.patchFed }, ); -/* eslint-disable no-nested-ternary, unicorn/no-nested-ternary -- Existing kind dispatch is kept behaviorally unchanged while the standalone lint gate is enforced. */ -export const planGovernedContributionScaffold = Effect.fn('GovernedContributionScaffold.plan')( - function* planGovernedContributionScaffold( +const planGovernedTransport = Effect.fn('GovernedContributionScaffold.transport')( + function* planGovernedTransport( workspaceRoot: string, kind: GovernedContributionKind, - config: GovernedContributionScaffoldConfig, + vertical: OntosVerticalMetadata, + name: string, ) { - const name = yield* tryScaffold('governed contribution name is invalid', () => - requireCanonicalSlug(config.name, kind), - ); - const resource = - config.resource === undefined - ? undefined - : yield* tryScaffold('governed contribution resource is invalid', () => - requireCanonicalSlug(config.resource ?? '', 'resource'), - ); - const vertical = yield* discoverOntosModuleEffect(workspaceRoot, config.vertical); - const isComponent = kind === PUBLIC_COMPONENT_KIND; const isApi = kind === MODULE_API_KIND; - const directory = - kind === REPORT_KIND ? 'reports' : kind === SEARCH_PROVIDER_KIND ? 'search' : ''; - const artifactPath = yield* tryScaffold('failed to resolve governed contribution path', () => - resolveContainedPath( - vertical.directory, - ...(isComponent - ? ['src', 'components', `${name}.tsx`] - : isApi - ? ['shared', 'apis', `${name}.ts`] - : ['src', directory, `${name}.provider.ts`]), - ), - ); - const artifact = yield* tryScaffold('failed to render governed contribution', () => { - if (isComponent) { - return renderPublicComponent(name); - } - if (isApi) { - return renderApiContract(name); - } - if (isProviderContribution(kind)) { - return renderProvider(kind, vertical, name, config); - } - return raiseScaffoldFailure('unsupported governed contribution', kind); - }); - const mutations: Mutation[] = [yield* createMutationEffect(artifactPath, artifact)]; - if (isApi) { - const readPath = yield* tryScaffold('failed to resolve governed read path', () => - resolveContainedPath(vertical.directory, 'src', 'api', `${name}.read.ts`), - ); - const readSource = yield* tryScaffold('failed to render governed read', () => - renderModuleApiRead(vertical, name, config), - ); - mutations.push(yield* createMutationEffect(readPath, readSource)); - } + const mutations: Mutation[] = []; let clientPath: string | undefined; let serverPath: string | undefined; - if (kind === MODULE_API_KIND || isProviderContribution(kind)) { - const suffix = isApi ? 'client' : kind === REPORT_KIND ? 'report-client' : 'search-client'; + if (kind !== PUBLIC_COMPONENT_KIND) { + const suffix = { + [MODULE_API_KIND]: 'client', + [REPORT_KIND]: 'report-client', + [SEARCH_PROVIDER_KIND]: 'search-client', + }[kind]; clientPath = yield* tryScaffold('failed to resolve governed client path', () => resolveContainedPath(vertical.directory, 'src', 'api', `${name}-${suffix}.ts`), ); @@ -855,7 +826,7 @@ export const planGovernedContributionScaffold = Effect.fn('GovernedContributionS resolveContainedPath( vertical.directory, 'api', - `${name}-${isApi ? 'read' : kind === REPORT_KIND ? REPORT_KIND : 'search'}-server.ts`, + `${name}-${{ [MODULE_API_KIND]: 'read', [REPORT_KIND]: REPORT_KIND, [SEARCH_PROVIDER_KIND]: 'search' }[kind]}-server.ts`, ), ); mutations.push(yield* createMutationEffect(serverPath, renderGovernedServer(kind, name))); @@ -864,6 +835,62 @@ export const planGovernedContributionScaffold = Effect.fn('GovernedContributionS }); mutations.push(...boundary.mutations); } + return { clientPath, mutations, serverPath }; + }, +); + +export const planGovernedContributionScaffold = Effect.fn('GovernedContributionScaffold.plan')( + function* planGovernedContributionScaffold( + workspaceRoot: string, + kind: GovernedContributionKind, + config: GovernedContributionScaffoldConfig, + ) { + const name = yield* tryScaffold('governed contribution name is invalid', () => + requireCanonicalSlug(config.name, kind), + ); + const resource = + config.resource === undefined + ? undefined + : yield* tryScaffold('governed contribution resource is invalid', () => + requireCanonicalSlug(config.resource ?? '', 'resource'), + ); + const vertical = yield* discoverOntosModuleEffect(workspaceRoot, config.vertical); + const isComponent = kind === PUBLIC_COMPONENT_KIND; + const isApi = kind === MODULE_API_KIND; + const artifactSegments = { + [MODULE_API_KIND]: ['shared', 'apis', `${name}.ts`], + [PUBLIC_COMPONENT_KIND]: ['src', 'components', `${name}.tsx`], + [REPORT_KIND]: ['src', 'reports', `${name}.provider.ts`], + [SEARCH_PROVIDER_KIND]: ['src', 'search', `${name}.provider.ts`], + }; + const artifactPath = yield* tryScaffold('failed to resolve governed contribution path', () => + resolveContainedPath(vertical.directory, ...artifactSegments[kind]), + ); + const artifact = yield* tryScaffold('failed to render governed contribution', () => { + if (isComponent) { + return renderPublicComponent(name); + } + if (isApi) { + return renderApiContract(name); + } + if (isProviderContribution(kind)) { + return renderProvider(kind, vertical, name, config); + } + return raiseScaffoldFailure('unsupported governed contribution', kind); + }); + const mutations: Mutation[] = [yield* createMutationEffect(artifactPath, artifact)]; + if (isApi) { + const readPath = yield* tryScaffold('failed to resolve governed read path', () => + resolveContainedPath(vertical.directory, 'src', 'api', `${name}.read.ts`), + ); + const readSource = yield* tryScaffold('failed to render governed read', () => + renderModuleApiRead(vertical, name, config), + ); + mutations.push(yield* createMutationEffect(readPath, readSource)); + } + const transport = yield* planGovernedTransport(workspaceRoot, kind, vertical, name); + const { clientPath, serverPath } = transport; + mutations.push(...transport.mutations); const ownerImport = manifestImport(kind, name); let manifest = vertical.manifestContent; if (ownerImport !== undefined) { @@ -909,4 +936,3 @@ export const planGovernedContributionScaffold = Effect.fn('GovernedContributionS return { mutations, result }; }, ); -/* eslint-enable no-nested-ternary, unicorn/no-nested-ternary */ diff --git a/app/scripts/scaffolding/microvertical-action-boundary/scaffold.mts b/app/scripts/scaffolding/microvertical-action-boundary/scaffold.mts index 306d1a259..7e9c3ced6 100644 --- a/app/scripts/scaffolding/microvertical-action-boundary/scaffold.mts +++ b/app/scripts/scaffolding/microvertical-action-boundary/scaffold.mts @@ -1,4 +1,4 @@ -import { Array as EffectArray, Effect, FileSystem, Option, Schema } from 'effect'; +import { Array as EffectArray, Effect, FileSystem, Option, Schema, Predicate } from 'effect'; import { createMutationEffect, discoverOntosModuleEffect, @@ -16,7 +16,7 @@ import type { VerticalMetadata, } from '../shared.mts'; -export const ACTION_BOUNDARY_GENERATOR_HEADER = +const ACTION_BOUNDARY_GENERATOR_HEADER = '// @generated by OntOS Codesmith MicroVertical Action Boundary v1'; const WORKSPACE_DEPENDENCY_VERSION = 'workspace:*'; @@ -34,10 +34,10 @@ const scaffoldError = (message: string, cause?: unknown): ActionBoundaryScaffold const trySync = (operation: () => Value) => Effect.try({ catch: (cause) => - cause instanceof ActionBoundaryScaffoldError + Schema.is(ActionBoundaryScaffoldError)(cause) ? cause : scaffoldError( - cause instanceof Error ? cause.message : 'action boundary update failed', + Predicate.isError(cause) ? cause.message : 'action boundary update failed', cause, ), try: operation, @@ -136,7 +136,7 @@ export const authenticateOperationPrincipal = makeMicroverticalHttpPrincipalAuth ); `; -export const renderGatewayAssertionRedemptionAdapter = ( +const renderGatewayAssertionRedemptionAdapter = ( vertical: Pick, ): string => `${ACTION_BOUNDARY_GENERATOR_HEADER} // @ontos-action-boundary-owner ${vertical.appId} @@ -198,9 +198,9 @@ export const makeActionGateway = (acquire: ActionGatewayIssuer = issueGatewayCon ), }); -export const actionGateway = makeActionGateway(); +export const operationGateway = makeActionGateway(); export const makeOperationGateway = makeActionGateway; -export const operationGateway = actionGateway; + `; export const planActionBoundaryScaffold = ( diff --git a/app/scripts/scaffolding/microvertical-page/scaffold.mts b/app/scripts/scaffolding/microvertical-page/scaffold.mts index 212481a8a..c9ce90086 100644 --- a/app/scripts/scaffolding/microvertical-page/scaffold.mts +++ b/app/scripts/scaffolding/microvertical-page/scaffold.mts @@ -162,6 +162,22 @@ const fileExists = (filePath: string) => return yield* mapFileSystemError(fileSystem.exists(filePath)); }); +const pageRouteIsInvalid = ( + canonicalPath: string, + canonicalSegments: readonly string[], + parameterNames: readonly string[], + requestedUrl: string | undefined, +): boolean => + canonicalPath.length < 2 || + canonicalPath.length > 200 || + canonicalSegments.length === 0 || + canonicalSegments.some( + (segment) => + !staticRouteSegmentPattern.test(segment) && !parameterRouteSegmentPattern.test(segment), + ) || + new Set(parameterNames).size !== parameterNames.length || + (requestedUrl === undefined && parameterNames.length > 0); + const resolvePageRoute = ( vertical: PageVerticalMetadata, page: string, @@ -176,17 +192,7 @@ const resolvePageRoute = ( const name = parameterRouteSegmentPattern.exec(segment)?.groups?.['name']; return name === undefined ? [] : [name]; }); - if ( - canonicalPath.length < 2 || - canonicalPath.length > 200 || - canonicalSegments.length === 0 || - canonicalSegments.some( - (segment) => - !staticRouteSegmentPattern.test(segment) && !parameterRouteSegmentPattern.test(segment), - ) || - new Set(parameterNames).size !== parameterNames.length || - (requestedUrl === undefined && parameterNames.length > 0) - ) { + if (pageRouteIsInvalid(canonicalPath, canonicalSegments, parameterNames, requestedUrl)) { return yield* pageScaffoldFailure( '--url must be a root-relative path of lowercase kebab-case segments and unique named :parameters, with no locale, query, fragment, wildcard, optional/catch-all syntax, or trailing slash', ); @@ -941,6 +947,27 @@ const routeCollisionIdentity = (routePath: string): string => .map((segment) => (parameterRouteSegmentPattern.test(segment) ? ':parameter' : segment)) .join('/'); +const assertShellRouteSiblingsAreAvailable = ( + entries: readonly DirectoryEntry[], + segment: string, + route: PageRoute, +) => + Effect.gen(function* assertShellRouteSiblingsAreAvailableEffect() { + const desiredSegmentIsDynamic = isDynamicShellRouteSegment(segment); + const siblingCollision = entries.find( + (entry) => + entry.isDirectory && (desiredSegmentIsDynamic || isDynamicShellRouteSegment(entry.name)), + ); + if (siblingCollision !== undefined) { + const collisionKind = isDynamicShellRouteSegment(siblingCollision.name) + ? 'dynamic' + : 'static'; + yield* pageScaffoldFailure( + `Shell route ${route.canonicalPath} collides with ${collisionKind} route segment ${siblingCollision.name}`, + ); + } + }); + const assertShellRouteSegmentIsAvailable = ( parent: string, index: number, @@ -955,19 +982,7 @@ const assertShellRouteSegmentIsAvailable = ( const entries = yield* readDirectoryEntries(parent); const childEntry = entries.find((entry) => entry.name === segment); if (childEntry === undefined) { - const desiredSegmentIsDynamic = isDynamicShellRouteSegment(segment); - const siblingCollision = entries.find( - (entry) => - entry.isDirectory && (desiredSegmentIsDynamic || isDynamicShellRouteSegment(entry.name)), - ); - if (siblingCollision !== undefined) { - const collisionKind = isDynamicShellRouteSegment(siblingCollision.name) - ? 'dynamic' - : 'static'; - yield* pageScaffoldFailure( - `Shell route ${route.canonicalPath} collides with ${collisionKind} route segment ${siblingCollision.name}`, - ); - } + yield* assertShellRouteSiblingsAreAvailable(entries, segment, route); return; } const child = resolveContainedPath(parent, segment); @@ -1053,6 +1068,50 @@ const generatedFileMatches = ( ), ); +const generatedWiringContentMatches = ( + vertical: PageVerticalMetadata, + page: string, + wiring: ReturnType, + shellClients: string, + navigationMatches: boolean, +): boolean => + generatedWiringEntryMatches( + vertical.manifestContent, + MODULE_MANIFEST_IMPORT_SLOT_START, + MODULE_MANIFEST_IMPORT_SLOT_END, + wiring.manifestImport, + new RegExp(`\\b${wiring.componentName}\\b`, 'u'), + ) && + generatedWiringEntryMatches( + vertical.manifestContent, + MODULE_MANIFEST_COMPONENT_SLOT_START, + MODULE_MANIFEST_COMPONENT_SLOT_END, + wiring.manifestComponent, + new RegExp(`["']page-${page}["']\\s*:`, 'u'), + ) && + navigationMatches && + generatedWiringEntryMatches( + vertical.manifestContent, + MODULE_MANIFEST_SHELL_PAGE_SLOT_START, + MODULE_MANIFEST_SHELL_PAGE_SLOT_END, + wiring.manifestPage, + new RegExp(`\\bcontributionKey\\s*:\\s*["']${vertical.moduleId}\\.page\\.${page}["']`, 'u'), + ) && + generatedWiringEntryMatches( + vertical.registrationContent, + MODULE_REGISTRATION_PAGE_SLOT_START, + MODULE_REGISTRATION_PAGE_SLOT_END, + wiring.registrationPage, + new RegExp(`["']page-${page}["']\\s*:`, 'u'), + ) && + generatedWiringEntryMatches( + shellClients, + SHELL_PAGE_CLIENT_SLOT_START, + SHELL_PAGE_CLIENT_SLOT_END, + wiring.shellClient, + new RegExp(`\\bcomponentKey\\s*:\\s*["']${vertical.moduleId}\\.page-${page}["']`, 'u'), + ); + const generatedWiringMatches = ( workspaceRoot: string, vertical: PageVerticalMetadata, @@ -1146,43 +1205,8 @@ const generatedWiringMatches = ( ), ); return ( - generatedWiringEntryMatches( - vertical.manifestContent, - MODULE_MANIFEST_IMPORT_SLOT_START, - MODULE_MANIFEST_IMPORT_SLOT_END, - wiring.manifestImport, - new RegExp(`\\b${wiring.componentName}\\b`, 'u'), - ) && - generatedWiringEntryMatches( - vertical.manifestContent, - MODULE_MANIFEST_COMPONENT_SLOT_START, - MODULE_MANIFEST_COMPONENT_SLOT_END, - wiring.manifestComponent, - new RegExp(`["']page-${page}["']\\s*:`, 'u'), - ) && - navigationMatches && - generatedWiringEntryMatches( - vertical.manifestContent, - MODULE_MANIFEST_SHELL_PAGE_SLOT_START, - MODULE_MANIFEST_SHELL_PAGE_SLOT_END, - wiring.manifestPage, - new RegExp(`\\bcontributionKey\\s*:\\s*["']${vertical.moduleId}\\.page\\.${page}["']`, 'u'), - ) && - generatedWiringEntryMatches( - vertical.registrationContent, - MODULE_REGISTRATION_PAGE_SLOT_START, - MODULE_REGISTRATION_PAGE_SLOT_END, - wiring.registrationPage, - new RegExp(`["']page-${page}["']\\s*:`, 'u'), - ) && + generatedWiringContentMatches(vertical, page, wiring, shellClients, navigationMatches) && federationMatches && - generatedWiringEntryMatches( - shellClients, - SHELL_PAGE_CLIENT_SLOT_START, - SHELL_PAGE_CLIENT_SLOT_END, - wiring.shellClient, - new RegExp(`\\bcomponentKey\\s*:\\s*["']${vertical.moduleId}\\.page-${page}["']`, 'u'), - ) && shellRouteMatches.every(Boolean) && shellRouteInventoryMatches ); @@ -1267,7 +1291,7 @@ const generatedPageState = ( : 'invalid'; }); -export const planPageScaffold = ( +const planPageScaffold = ( workspaceRoot: string, config: PageScaffoldConfig, ): Effect.Effect, PageScaffoldError, FileSystem.FileSystem> => diff --git a/app/scripts/scaffolding/module-contract/scaffold.mts b/app/scripts/scaffolding/module-contract/scaffold.mts index c21f68460..8adfc4ed7 100644 --- a/app/scripts/scaffolding/module-contract/scaffold.mts +++ b/app/scripts/scaffolding/module-contract/scaffold.mts @@ -1,4 +1,4 @@ -import { Array as EffectArray, Effect, FileSystem, Option, Schema } from 'effect'; +import { Array as EffectArray, Effect, FileSystem, Option, Schema, Predicate } from 'effect'; import { createCodesmithGenerator } from '../generator-adapter.mts'; import { MODULE_CONTRACT_GENERATOR_HEADER, @@ -89,10 +89,10 @@ const scaffoldError = (message: string, cause?: unknown): ModuleContractScaffold const trySync = (operation: () => Value) => Effect.try({ catch: (cause) => - cause instanceof ModuleContractScaffoldError + Schema.is(ModuleContractScaffoldError)(cause) ? cause : scaffoldError( - cause instanceof Error ? cause.message : 'module contract update failed', + Predicate.isError(cause) ? cause.message : 'module contract update failed', cause, ), try: operation, @@ -429,7 +429,7 @@ const patchTsconfig = ( ); }); -export const planModuleContractScaffold = ( +const planModuleContractScaffold = ( workspaceRoot: string, config: ModuleContractScaffoldConfig, ): Effect.Effect< diff --git a/app/scripts/scaffolding/outbox-message/scaffold.mts b/app/scripts/scaffolding/outbox-message/scaffold.mts index 3d8424b28..c3387310e 100644 --- a/app/scripts/scaffolding/outbox-message/scaffold.mts +++ b/app/scripts/scaffolding/outbox-message/scaffold.mts @@ -1,4 +1,4 @@ -import { Cause, Effect, FileSystem, Result, Schema } from 'effect'; +import { Cause, Effect, FileSystem, Predicate, Result, Schema } from 'effect'; import type { PlatformError } from 'effect'; import { createCodesmithGenerator } from '../generator-adapter.mts'; import { @@ -26,7 +26,7 @@ import type { ScaffoldPlan, } from '../shared.mts'; -export class OutboxMessageScaffoldError extends Schema.TaggedError()( +class OutboxMessageScaffoldError extends Schema.TaggedError()( 'OutboxMessageScaffoldError', { cause: Schema.optional(Schema.Unknown), reason: Schema.String }, ) { @@ -41,7 +41,7 @@ const planningFailure = (reason: string, cause?: unknown): OutboxMessageScaffold : new OutboxMessageScaffoldError({ cause, reason }); const failureFromCause = (cause: unknown): OutboxMessageScaffoldError => - planningFailure(cause instanceof Error ? cause.message : String(cause), cause); + planningFailure(Predicate.isError(cause) ? cause.message : String(cause), cause); const fromLegacySync = ( operation: () => Value, @@ -127,7 +127,29 @@ export const outboxTopic = '${topic}' as const; export const outboxProducerModuleKey = '${vertical.moduleId}' as const; `; -export const planOutboxScaffold = ( +const isMatchingGeneratedAction = ( + actionContent: string, + vertical: OntosVerticalMetadata, + action: string, +): boolean => { + const hasGeneratedActionPrefix = + actionContent.startsWith(`${ACTION_GENERATOR_HEADER}\n`) || + actionContent.startsWith(`${FORMATTED_ACTION_GENERATOR_PREFIX}${ACTION_GENERATOR_HEADER}\n`); + return ( + hasGeneratedActionPrefix && + [ + `// @ontos-action-owner ${vertical.moduleId}\n`, + `// @ontos-action-slug ${action}\n`, + `entrypoint: defineTenantModuleEntrypoint({\n`, + ` access: 'write',\n`, + ` entrypointKey: '${vertical.moduleId}.${action}',\n`, + ` moduleKey: '${vertical.moduleId}',\n`, + ` role: 'action',\n`, + ].every((fragment) => actionContent.includes(fragment)) + ); +}; + +const planOutboxScaffold = ( workspaceRoot: string, config: OutboxScaffoldConfig, ): Effect.Effect< @@ -161,19 +183,7 @@ export const planOutboxScaffold = ( ), ), ); - const hasGeneratedActionPrefix = - actionContent.startsWith(`${ACTION_GENERATOR_HEADER}\n`) || - actionContent.startsWith(`${FORMATTED_ACTION_GENERATOR_PREFIX}${ACTION_GENERATOR_HEADER}\n`); - if ( - !hasGeneratedActionPrefix || - !actionContent.includes(`// @ontos-action-owner ${vertical.moduleId}\n`) || - !actionContent.includes(`// @ontos-action-slug ${action}\n`) || - !actionContent.includes(`entrypoint: defineTenantModuleEntrypoint({\n`) || - !actionContent.includes(` access: 'write',\n`) || - !actionContent.includes(` entrypointKey: '${vertical.moduleId}.${action}',\n`) || - !actionContent.includes(` moduleKey: '${vertical.moduleId}',\n`) || - !actionContent.includes(` role: 'action',\n`) - ) { + if (!isMatchingGeneratedAction(actionContent, vertical, action)) { return yield* Effect.fail( planningFailure( 'Outbox Message can extend only the matching generated Action with its governed write entrypoint', diff --git a/app/scripts/scaffolding/outbox-worker/scaffold.mts b/app/scripts/scaffolding/outbox-worker/scaffold.mts index f6848c5da..fe5dd462b 100644 --- a/app/scripts/scaffolding/outbox-worker/scaffold.mts +++ b/app/scripts/scaffolding/outbox-worker/scaffold.mts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Match, Option, Schema } from 'effect'; +import { Effect, FileSystem, Match, Option, Predicate, Schema } from 'effect'; import { createCodesmithGenerator } from '../generator-adapter.mts'; import { MODULE_REGISTRATION_IMPORT_SLOT_END, @@ -46,7 +46,7 @@ class OutboxWorkerScaffoldError extends Schema.TaggedError new OutboxWorkerScaffoldError({ cause, - message: message ?? (cause instanceof Error ? cause.message : String(cause)), + message: message ?? (Predicate.isError(cause) ? cause.message : String(cause)), }); const trySync = (operation: () => Value) => @@ -357,6 +357,96 @@ const patchConsumerTsconfig = ( return yield* trySync(() => patchJsonObjectProperty(content, [], 'references', patched)); }); +const isMatchingOutboxContract = ( + contract: string, + producer: OntosVerticalMetadata, + topic: string, +): boolean => + contract.startsWith(`${OUTBOX_CONTRACT_GENERATOR_HEADER}\n`) && + [ + `// @ontos-outbox-producer ${producer.moduleId}\n`, + `// @ontos-outbox-topic ${topic}\n`, + `export const outboxTopic = '${topic}' as const;`, + `export const outboxProducerModuleKey = '${producer.moduleId}' as const;`, + 'export const OutboxPayloadSchema =', + ].every((fragment) => contract.includes(fragment)) && + !/(?:src\/actions|create[A-Za-z0-9]+Message|handler|repository|transport)/u.test(contract); + +const planRegistryMutation = ( + registryPath: string, + registryContent: Option.Option, + worker: string, +) => + Effect.gen(function* planRegistryMutationEffect() { + const workerVariable = `${toCamelCase(worker)}Worker`; + let registryMutation: Mutation; + if (Option.isSome(registryContent)) { + if ( + !registryContent.value.includes(OUTBOX_WORKER_IMPORT_SLOT_START) || + !registryContent.value.includes(OUTBOX_WORKER_REGISTRY_SLOT_START) + ) { + return yield* new OutboxWorkerScaffoldError({ + cause: registryPath, + message: 'generated worker registry does not contain its owned slots', + }); + } + if (new RegExp(`\\b${workerVariable}\\b`, 'u').test(registryContent.value)) { + return yield* new OutboxWorkerScaffoldError({ + cause: workerVariable, + message: `Outbox Worker identifier ${workerVariable} already exists`, + }); + } + const withRegistration = yield* trySync(() => { + const withImport = insertSortedSlot( + registryContent.value, + OUTBOX_WORKER_IMPORT_SLOT_START, + OUTBOX_WORKER_IMPORT_SLOT_END, + [`import { ${workerVariable} } from './${worker}.worker.ts';`], + (candidate) => + /^import \{ [A-Za-z0-9]+Worker \} from '\.\/[a-z0-9-]+\.worker\.ts';$/u.test(candidate), + ); + return insertSortedSlot( + withImport, + OUTBOX_WORKER_REGISTRY_SLOT_START, + OUTBOX_WORKER_REGISTRY_SLOT_END, + [`${workerVariable},`], + (candidate) => /^[a-z][A-Za-z0-9]+Worker,$/u.test(candidate), + ); + }); + const updatedRegistry = yield* trySync(() => + updateMutation(registryPath, registryContent.value, withRegistration), + ); + if (updatedRegistry === undefined) { + return yield* new OutboxWorkerScaffoldError({ + cause: registryPath, + message: 'Outbox Worker registry patch unexpectedly made no change', + }); + } + registryMutation = updatedRegistry; + } else { + const withRegistration = yield* trySync(() => { + const emptyRegistry = renderRegistry(); + const withImport = insertSortedSlot( + emptyRegistry, + OUTBOX_WORKER_IMPORT_SLOT_START, + OUTBOX_WORKER_IMPORT_SLOT_END, + [`import { ${workerVariable} } from './${worker}.worker.ts';`], + () => true, + ); + return insertSortedSlot( + withImport, + OUTBOX_WORKER_REGISTRY_SLOT_START, + OUTBOX_WORKER_REGISTRY_SLOT_END, + [`${workerVariable},`], + () => true, + ); + }); + registryMutation = yield* createMutationEffect(registryPath, withRegistration); + } + + return registryMutation; + }); + const planOutboxWorkerScaffoldEffect = ( workspaceRoot: string, config: OutboxWorkerScaffoldConfig, @@ -401,17 +491,7 @@ const planOutboxWorkerScaffoldEffect = ( message: `topic ${topic} is not published by ${producer.packageName}`, }); } - if ( - !contract.startsWith(`${OUTBOX_CONTRACT_GENERATOR_HEADER}\n`) || - !contract.includes(`// @ontos-outbox-producer ${producer.moduleId}\n`) || - !contract.includes(`// @ontos-outbox-topic ${topic}\n`) || - !contract.includes(`export const outboxTopic = '${topic}' as const;`) || - !contract.includes( - `export const outboxProducerModuleKey = '${producer.moduleId}' as const;`, - ) || - !contract.includes('export const OutboxPayloadSchema =') || - /(?:src\/actions|create[A-Za-z0-9]+Message|handler|repository|transport)/u.test(contract) - ) { + if (!isMatchingOutboxContract(contract, producer, topic)) { return yield* new OutboxWorkerScaffoldError({ cause: contractPath, message: `published Outbox contract for ${topic} has an owner/topic/schema mismatch`, @@ -437,70 +517,7 @@ const planOutboxWorkerScaffoldEffect = ( ); const registryContent = yield* readOptionalFile(registryPath); const workerVariable = `${toCamelCase(worker)}Worker`; - let registryMutation: Mutation; - if (Option.isSome(registryContent)) { - if ( - !registryContent.value.includes(OUTBOX_WORKER_IMPORT_SLOT_START) || - !registryContent.value.includes(OUTBOX_WORKER_REGISTRY_SLOT_START) - ) { - return yield* new OutboxWorkerScaffoldError({ - cause: registryPath, - message: 'generated worker registry does not contain its owned slots', - }); - } - if (new RegExp(`\\b${workerVariable}\\b`, 'u').test(registryContent.value)) { - return yield* new OutboxWorkerScaffoldError({ - cause: workerVariable, - message: `Outbox Worker identifier ${workerVariable} already exists`, - }); - } - const withRegistration = yield* trySync(() => { - const withImport = insertSortedSlot( - registryContent.value, - OUTBOX_WORKER_IMPORT_SLOT_START, - OUTBOX_WORKER_IMPORT_SLOT_END, - [`import { ${workerVariable} } from './${worker}.worker.ts';`], - (candidate) => - /^import \{ [A-Za-z0-9]+Worker \} from '\.\/[a-z0-9-]+\.worker\.ts';$/u.test(candidate), - ); - return insertSortedSlot( - withImport, - OUTBOX_WORKER_REGISTRY_SLOT_START, - OUTBOX_WORKER_REGISTRY_SLOT_END, - [`${workerVariable},`], - (candidate) => /^[a-z][A-Za-z0-9]+Worker,$/u.test(candidate), - ); - }); - const updatedRegistry = yield* trySync(() => - updateMutation(registryPath, registryContent.value, withRegistration), - ); - if (updatedRegistry === undefined) { - return yield* new OutboxWorkerScaffoldError({ - cause: registryPath, - message: 'Outbox Worker registry patch unexpectedly made no change', - }); - } - registryMutation = updatedRegistry; - } else { - const withRegistration = yield* trySync(() => { - const emptyRegistry = renderRegistry(); - const withImport = insertSortedSlot( - emptyRegistry, - OUTBOX_WORKER_IMPORT_SLOT_START, - OUTBOX_WORKER_IMPORT_SLOT_END, - [`import { ${workerVariable} } from './${worker}.worker.ts';`], - () => true, - ); - return insertSortedSlot( - withImport, - OUTBOX_WORKER_REGISTRY_SLOT_START, - OUTBOX_WORKER_REGISTRY_SLOT_END, - [`${workerVariable},`], - () => true, - ); - }); - registryMutation = yield* createMutationEffect(registryPath, withRegistration); - } + const registryMutation = yield* planRegistryMutation(registryPath, registryContent, worker); const workerHostLayerPath = yield* trySync(() => resolveContainedPath( @@ -593,6 +610,4 @@ const planOutboxWorkerScaffoldEffect = ( return { mutations, result: { registryPath, workerPath } }; }); -export const planOutboxWorkerScaffold = planOutboxWorkerScaffoldEffect; - -export default createCodesmithGenerator(planOutboxWorkerScaffold); +export default createCodesmithGenerator(planOutboxWorkerScaffoldEffect); diff --git a/app/scripts/scaffolding/policy/scaffold.mts b/app/scripts/scaffolding/policy/scaffold.mts index ccaea5c45..04ca1b258 100644 --- a/app/scripts/scaffolding/policy/scaffold.mts +++ b/app/scripts/scaffolding/policy/scaffold.mts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Match, Schema } from 'effect'; +import { Effect, FileSystem, Match, Schema, Predicate } from 'effect'; import { createCodesmithGenerator } from '../generator-adapter.mts'; import { CORE_POLICY_SLOT_END, @@ -20,17 +20,16 @@ import type { ScaffoldPlan, } from '../shared.mts'; -export class PolicyScaffoldError extends Schema.TaggedError()( - 'PolicyScaffoldError', - { reason: Schema.String }, -) { +class PolicyScaffoldError extends Schema.TaggedError()('PolicyScaffoldError', { + reason: Schema.String, +}) { override get message(): string { return this.reason; } } const planningFailure = (cause: unknown): PolicyScaffoldError => - new PolicyScaffoldError({ reason: cause instanceof Error ? cause.message : String(cause) }); + new PolicyScaffoldError({ reason: Predicate.isError(cause) ? cause.message : String(cause) }); const fromLegacySync = ( operation: () => Value, @@ -82,7 +81,7 @@ ${ownerLine} policyKey: '${policyKey}', `; }; -export const planPolicyScaffold = Effect.fn('PolicyScaffold.planPolicyScaffold')( +const planPolicyScaffold = Effect.fn('PolicyScaffold.planPolicyScaffold')( function* planPolicyScaffoldEffect( workspaceRoot: string, config: PolicyScaffoldConfig, diff --git a/app/scripts/scaffolding/resource/scaffold.mts b/app/scripts/scaffolding/resource/scaffold.mts index 29a50d838..7eab9f36e 100644 --- a/app/scripts/scaffolding/resource/scaffold.mts +++ b/app/scripts/scaffolding/resource/scaffold.mts @@ -88,77 +88,78 @@ const withResourceSlot = Effect.fn('ResourceScaffold.withResourceSlot')(function const isResourceDescriptor = (candidate: string): boolean => /^[a-z][A-Za-z0-9]*ResourceDescriptor,$/u.test(candidate); -export const planResourceScaffold = Effect.fn('ResourceScaffold.plan')( - function* planResourceScaffold(workspaceRoot: string, config: ResourceScaffoldConfig) { - const resource = yield* tryScaffold('resource name is invalid', () => - requireCanonicalSlug(config.resource, 'resource'), - ); - const vertical = yield* discoverOntosModuleEffect(workspaceRoot, config.vertical); - const resourcePath = yield* tryScaffold('failed to resolve resource path', () => - resolveContainedPath(vertical.directory, 'shared', 'resources', `${resource}.ts`), - ); - const resourceMutation = yield* createMutationEffect( - resourcePath, - renderResource(vertical, resource), - ); +const planResourceScaffold = Effect.fn('ResourceScaffold.plan')(function* planResourceScaffold( + workspaceRoot: string, + config: ResourceScaffoldConfig, +) { + const resource = yield* tryScaffold('resource name is invalid', () => + requireCanonicalSlug(config.resource, 'resource'), + ); + const vertical = yield* discoverOntosModuleEffect(workspaceRoot, config.vertical); + const resourcePath = yield* tryScaffold('failed to resolve resource path', () => + resolveContainedPath(vertical.directory, 'shared', 'resources', `${resource}.ts`), + ); + const resourceMutation = yield* createMutationEffect( + resourcePath, + renderResource(vertical, resource), + ); - const descriptor = `${toCamelCase(resource)}ResourceDescriptor`; - const ownerImport = `import { ${descriptor} } from './shared/resources/${resource}.ts';`; - const manifestWithSlot = yield* withResourceSlot(vertical.manifestContent); - const nextManifest = yield* tryScaffold('failed to patch resource manifest', () => + const descriptor = `${toCamelCase(resource)}ResourceDescriptor`; + const ownerImport = `import { ${descriptor} } from './shared/resources/${resource}.ts';`; + const manifestWithSlot = yield* withResourceSlot(vertical.manifestContent); + const nextManifest = yield* tryScaffold('failed to patch resource manifest', () => + insertSortedSlot( insertSortedSlot( - insertSortedSlot( - manifestWithSlot, - MODULE_MANIFEST_IMPORT_SLOT_START, - MODULE_MANIFEST_IMPORT_SLOT_END, - [ownerImport], - isModuleManifestImport, - ), - MODULE_MANIFEST_RESOURCE_SLOT_START, - MODULE_MANIFEST_RESOURCE_SLOT_END, - [`${descriptor},`], - isResourceDescriptor, + manifestWithSlot, + MODULE_MANIFEST_IMPORT_SLOT_START, + MODULE_MANIFEST_IMPORT_SLOT_END, + [ownerImport], + isModuleManifestImport, ), - ); - const manifestMutation = updateMutation( - vertical.manifestPath, - vertical.manifestContent, - nextManifest, - ); - if (manifestMutation === undefined) { - return yield* scaffoldFailure('Resource manifest patch unexpectedly made no change'); - } + MODULE_MANIFEST_RESOURCE_SLOT_START, + MODULE_MANIFEST_RESOURCE_SLOT_END, + [`${descriptor},`], + isResourceDescriptor, + ), + ); + const manifestMutation = updateMutation( + vertical.manifestPath, + vertical.manifestContent, + nextManifest, + ); + if (manifestMutation === undefined) { + return yield* scaffoldFailure('Resource manifest patch unexpectedly made no change'); + } - const exportsValue = yield* tryScaffold('failed to read resource package exports', () => - asJsonObject(vertical.packageJson['exports'], `vertical ${vertical.slug} package exports`), + const exportsValue = yield* tryScaffold('failed to read resource package exports', () => + asJsonObject(vertical.packageJson['exports'], `vertical ${vertical.slug} package exports`), + ); + const contractExport = `./resources/${resource}`; + if (exportsValue[contractExport] !== undefined) { + return yield* scaffoldFailure(`resource contract export ${contractExport} already exists`); + } + const packageMutation = yield* tryScaffold('failed to patch resource package export', () => { + const patchedExports = Object.fromEntries( + Object.entries({ + ...exportsValue, + [contractExport]: `./shared/resources/${resource}.ts`, + }).toSorted(([left], [right]) => left.localeCompare(right)), ); - const contractExport = `./resources/${resource}`; - if (exportsValue[contractExport] !== undefined) { - return yield* scaffoldFailure(`resource contract export ${contractExport} already exists`); - } - const packageMutation = yield* tryScaffold('failed to patch resource package export', () => { - const patchedExports = Object.fromEntries( - Object.entries({ - ...exportsValue, - [contractExport]: `./shared/resources/${resource}.ts`, - }).toSorted(([left], [right]) => left.localeCompare(right)), - ); - return updateMutation( - vertical.packagePath, - vertical.packageContent, - patchJsonObjectProperty(vertical.packageContent, [], 'exports', patchedExports), - ); - }); - if (packageMutation === undefined) { - return yield* scaffoldFailure('Resource package export patch unexpectedly made no change'); - } - - const mutations = [resourceMutation, manifestMutation, packageMutation]; - yield* tryScaffold('resource mutation paths are invalid', () => - ensureUniqueMutationPaths(mutations), + return updateMutation( + vertical.packagePath, + vertical.packageContent, + patchJsonObjectProperty(vertical.packageContent, [], 'exports', patchedExports), ); - return { mutations, result: { resourcePath } }; - }, -); + }); + if (packageMutation === undefined) { + return yield* scaffoldFailure('Resource package export patch unexpectedly made no change'); + } + + const mutations = [resourceMutation, manifestMutation, packageMutation]; + yield* tryScaffold('resource mutation paths are invalid', () => + ensureUniqueMutationPaths(mutations), + ); + return { mutations, result: { resourcePath } }; +}); export default createCodesmithGenerator(planResourceScaffold); diff --git a/app/scripts/scaffolding/retire-contribution/scaffold.mts b/app/scripts/scaffolding/retire-contribution/scaffold.mts index aca074a99..a1393aab1 100644 --- a/app/scripts/scaffolding/retire-contribution/scaffold.mts +++ b/app/scripts/scaffolding/retire-contribution/scaffold.mts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Schema } from 'effect'; +import { Effect, FileSystem, Schema, Predicate } from 'effect'; import { createCodesmithGenerator } from '../generator-adapter.mts'; import { ACTION_GENERATOR_HEADER, @@ -58,7 +58,7 @@ const scaffoldError = (message: string, cause?: unknown): RetireContributionScaf const trySync = (operation: () => Value, fallback: string) => Effect.try({ - catch: (cause) => scaffoldError(cause instanceof Error ? cause.message : fallback, cause), + catch: (cause) => scaffoldError(Predicate.isError(cause) ? cause.message : fallback, cause), try: operation, }); @@ -309,7 +309,7 @@ const planPageRetirement = Effect.fn('planPageRetirement')(function* planPageRet ]; }); -export const planRetireContributionScaffold = Effect.fn('RetireContributionScaffold.plan')( +const planRetireContributionScaffold = Effect.fn('RetireContributionScaffold.plan')( function* planRetireContributionScaffoldEffect( workspaceRoot: string, config: RetireContributionScaffoldConfig, diff --git a/app/scripts/scaffolding/search-provider-access/scaffold.mts b/app/scripts/scaffolding/search-provider-access/scaffold.mts index 95a082c40..ff21acb84 100644 --- a/app/scripts/scaffolding/search-provider-access/scaffold.mts +++ b/app/scripts/scaffolding/search-provider-access/scaffold.mts @@ -186,23 +186,24 @@ const patchManifest = ( return `${content.slice(0, start + MODULE_MANIFEST_SEARCH_SLOT_START.length)}${slot.replace(pattern, replacement)}${content.slice(end)}`; }); +const hasConsistentAccessScope = (config: SearchProviderAccessScaffoldConfig): boolean => + (config.accessFiltering === 'tenant_scope') === (config.tenantPermission !== undefined) && + (config.accessFiltering !== 'tenant_scope' || config.legalEntityScope === 'optional') && + (config.accessFiltering !== 'resource_permission' || config.legalEntityScope === 'required'); + +const hasValidAccessFlags = (config: SearchProviderAccessScaffoldConfig): boolean => + ['tenant_scope', 'resource_permission'].includes(config.accessFiltering) && + ['optional', 'required'].includes(config.legalEntityScope) && + (config.tenantPermission === undefined || config.tenantPermission === 'read_party_identity') && + config.requestFilters.every((filter) => ['includeArchived', 'role'].includes(filter)) && + new Set(config.requestFilters).size === config.requestFilters.length; + const validateConfig = ( config: SearchProviderAccessScaffoldConfig, ): Effect.Effect => Effect.gen(function* validateConfigEffect() { yield* trySync(() => requireCanonicalSlug(config.name, 'search provider')); - if ( - !['tenant_scope', 'resource_permission'].includes(config.accessFiltering) || - !['optional', 'required'].includes(config.legalEntityScope) || - (config.tenantPermission !== undefined && - config.tenantPermission !== 'read_party_identity') || - config.requestFilters.some((filter) => filter !== 'includeArchived' && filter !== 'role') || - (config.accessFiltering === 'tenant_scope') !== (config.tenantPermission !== undefined) || - (config.accessFiltering === 'tenant_scope' && config.legalEntityScope !== 'optional') || - (config.accessFiltering === 'resource_permission' && - config.legalEntityScope !== 'required') || - new Set(config.requestFilters).size !== config.requestFilters.length - ) { + if (!hasValidAccessFlags(config) || !hasConsistentAccessScope(config)) { yield* scaffoldError('search provider access flags are internally inconsistent'); } }); @@ -272,5 +273,3 @@ export const planSearchProviderAccessScaffold = ( result: { contractPath, manifestPath: vertical.manifestPath, providerPath, serverPath }, }; }); - -export default planSearchProviderAccessScaffold; diff --git a/app/scripts/scaffolding/shared.mts b/app/scripts/scaffolding/shared.mts index c8484c48e..a01d3cc86 100644 --- a/app/scripts/scaffolding/shared.mts +++ b/app/scripts/scaffolding/shared.mts @@ -86,7 +86,7 @@ export const MODULE_REGISTRATION_SEARCH_SLOT_END = '// { if (!isStringValue(value) || value.trim().length === 0) { @@ -389,9 +389,6 @@ export const requiredString = (value: JsonValue | undefined, label: string): str return value; }; -export const isMissingFileError = (error: ErrorValue): boolean => - Predicate.hasProperty(error, 'code') && error.code === 'ENOENT'; - const pathExistsEffect = (targetPath: string) => Effect.gen(function* pathExistsProgram() { const fileSystem = yield* FileSystem.FileSystem; @@ -400,11 +397,6 @@ const pathExistsEffect = (targetPath: string) => .pipe(Effect.mapError((cause) => scaffoldFailure(`failed to inspect ${targetPath}`, cause))); }); -export const pathExists: (targetPath: string) => Promise = flow( - pathExistsEffect, - scaffoldingRuntime.runPromise, -); - const regexMayStartAt = (content: string, index: number): boolean => { const prefix = content.slice(0, index).trimEnd(); if (prefix.length === 0) { @@ -464,6 +456,32 @@ interface NonCodeTransition { readonly state: NonCodeState | null; } +const closesNonCodeQuote = (state: NonCodeState, character: string): boolean => + (state === SINGLE_QUOTE_STATE && character === "'") || + (state === DOUBLE_QUOTE_STATE && character === '"') || + (state === TEMPLATE_STATE && character === '`'); + +const advanceRegexState = ( + state: NonCodeState, + character: string, + regexCharacterClass: boolean, +): NonCodeTransition => { + if (state !== REGEX_STATE) { + return { escaped: false, regexCharacterClass, state }; + } + if (character === '[') { + return { escaped: false, regexCharacterClass: true, state }; + } + if (character === ']') { + return { escaped: false, regexCharacterClass: false, state }; + } + return { + escaped: false, + regexCharacterClass, + state: character === '/' && !regexCharacterClass ? null : state, + }; +}; + const advanceNonCodeState = ( state: NonCodeState, character: string, @@ -491,27 +509,10 @@ const advanceNonCodeState = ( if (character === '\\') { return { escaped: true, regexCharacterClass, state }; } - if ( - (state === SINGLE_QUOTE_STATE && character === "'") || - (state === DOUBLE_QUOTE_STATE && character === '"') || - (state === TEMPLATE_STATE && character === '`') - ) { + if (closesNonCodeQuote(state, character)) { return { escaped: false, regexCharacterClass: false, state: null }; } - if (state !== REGEX_STATE) { - return { escaped: false, regexCharacterClass, state }; - } - if (character === '[') { - return { escaped: false, regexCharacterClass: true, state }; - } - if (character === ']') { - return { escaped: false, regexCharacterClass: false, state }; - } - return { - escaped: false, - regexCharacterClass, - state: character === '/' && !regexCharacterClass ? null : state, - }; + return advanceRegexState(state, character, regexCharacterClass); }; const shouldMaskNonCodeState = (state: NonCodeState, preserveStrings: boolean): boolean => @@ -530,6 +531,25 @@ const stringCodeUnits = (content: string): string[] => { return units; }; +const maskNonCodeOpening = ( + masked: string[], + index: number, + state: NonCodeState | null, + preserveStrings: boolean, +): number => { + if (state === null) { + return 0; + } + if (shouldMaskNonCodeState(state, preserveStrings)) { + masked[index] = ' '; + } + if (state === LINE_COMMENT_STATE || state === BLOCK_COMMENT_STATE) { + masked[index + 1] = ' '; + return 1; + } + return 0; +}; + const maskNonCode = (content: string, preserveStrings = false): string => { const masked = stringCodeUnits(content); let state: NonCodeState | null = null; @@ -540,20 +560,10 @@ const maskNonCode = (content: string, preserveStrings = false): string => { const next = content[index + 1] ?? null; if (state === null) { state = nonCodeStateAt(content, index, character, next); - if (state !== null) { - if (shouldMaskNonCodeState(state, preserveStrings)) { - masked[index] = ' '; - } - if (state === LINE_COMMENT_STATE || state === BLOCK_COMMENT_STATE) { - masked[index + 1] = ' '; - index += 1; - } - } + index += maskNonCodeOpening(masked, index, state, preserveStrings); continue; } - const isString = - state === DOUBLE_QUOTE_STATE || state === SINGLE_QUOTE_STATE || state === TEMPLATE_STATE; - if (!preserveStrings || !isString) { + if (shouldMaskNonCodeState(state, preserveStrings)) { masked[index] = character === '\n' || character === '\r' ? character : ' '; } const transition = advanceNonCodeState(state, character, next, escaped, regexCharacterClass); @@ -809,36 +819,34 @@ const scanJsonString = (source: string, start: number): number => { return raiseScaffoldFailure('unterminated JSON string while planning an owner-file patch'); }; +const scanJsonCollection = (source: string, start: number, first: '[' | '{'): number => { + const closing = first === '{' ? '}' : ']'; + let depth = 0; + let cursor = start; + while (cursor < source.length) { + const character = source[cursor]; + if (character === '"') { + cursor = scanJsonString(source, cursor) - 1; + } else if (character === first) { + depth += 1; + } else if (character === closing) { + depth -= 1; + if (depth === 0) { + return cursor + 1; + } + } + cursor += 1; + } + return raiseScaffoldFailure('unterminated JSON collection while planning an owner-file patch'); +}; + const scanJsonValue = (source: string, start: number): number => { const first = source[start]; if (first === '"') { return scanJsonString(source, start); } if (first === '{' || first === '[') { - const closing = first === '{' ? '}' : ']'; - let depth = 0; - let stringEnd = -1; - let cursor = start; - while (cursor < source.length) { - if (cursor < stringEnd) { - cursor += 1; - continue; - } - const character = source[cursor]; - if (character === '"') { - stringEnd = scanJsonString(source, cursor); - cursor = stringEnd - 1; - } else if (character === first) { - depth += 1; - } else if (character === closing) { - depth -= 1; - if (depth === 0) { - return cursor + 1; - } - } - cursor += 1; - } - return raiseScaffoldFailure('unterminated JSON collection while planning an owner-file patch'); + return scanJsonCollection(source, start, first); } let cursor = start; while (cursor < source.length && !/[\s,}\]]/u.test(source[cursor] ?? '')) { @@ -1110,11 +1118,6 @@ export const discoverVerticalEffect = ( return { ...vertical, topologyEntry }; }); -export const discoverVertical: ( - workspaceRoot: string, - requestedVertical: string, -) => Promise = flow(discoverVerticalEffect, scaffoldingRuntime.runPromise); - const readGeneratedModuleOwnerEffect = ( filePath: string, vertical: VerticalMetadata, @@ -1257,11 +1260,6 @@ export const deleteMutationEffect = ( return { kind: 'delete', path: filePath }; }); -export const deleteMutation: (filePath: string) => Promise = flow( - deleteMutationEffect, - scaffoldingRuntime.runPromise, -); - const CORE_RUNTIME_PACKAGE = '@app/core-runtime'; const WORKSPACE_DEPENDENCY_VERSION = 'workspace:*'; @@ -1386,14 +1384,6 @@ export const applyMutationPlanEffect = ( return plan.result; }); -const makeApplyMutationPlanEffect = (core: GeneratorCore, plan: ScaffoldPlan) => - applyMutationPlanEffect(core, plan); - -export const applyMutationPlan: ( - core: GeneratorCore, - plan: ScaffoldPlan, -) => Promise = flow(makeApplyMutationPlanEffect, scaffoldingRuntime.runPromise); - const dedentGeneratedSlotBody = (slotBody: string): string => { const lines = slotBody.split('\n'); while (lines[0]?.trim().length === 0) { @@ -1423,6 +1413,36 @@ interface GeneratedSlotScanState { quote: '"' | "'" | '`' | null; } +const consumeGeneratedSlotQuote = (state: GeneratedSlotScanState, character: string): void => { + if (state.escaped) { + state.escaped = false; + } else if (character === '\\') { + state.escaped = true; + } else if (character === state.quote) { + state.quote = null; + } +}; + +const startGeneratedSlotProtection = ( + state: GeneratedSlotScanState, + character: string, + nextCharacter: string, +): boolean => { + if (character === '/' && nextCharacter === '/') { + state.lineComment = true; + return true; + } + if (character === '/' && nextCharacter === '*') { + state.blockComment = true; + return true; + } + if (character === '"' || character === "'" || character === '`') { + state.quote = character; + return true; + } + return false; +}; + const consumeGeneratedSlotProtectedCharacter = ( state: GeneratedSlotScanState, character: string, @@ -1442,28 +1462,10 @@ const consumeGeneratedSlotProtectedCharacter = ( return true; } if (state.quote !== null) { - if (state.escaped) { - state.escaped = false; - } else if (character === '\\') { - state.escaped = true; - } else if (character === state.quote) { - state.quote = null; - } - return true; - } - if (character === '/' && nextCharacter === '/') { - state.lineComment = true; - return true; - } - if (character === '/' && nextCharacter === '*') { - state.blockComment = true; - return true; - } - if (character === '"' || character === "'" || character === '`') { - state.quote = character; + consumeGeneratedSlotQuote(state, character); return true; } - return false; + return startGeneratedSlotProtection(state, character, nextCharacter); }; const updateGeneratedSlotDepth = (state: GeneratedSlotScanState, character: string): void => { @@ -1485,6 +1487,16 @@ const updateGeneratedSlotDepth = (state: GeneratedSlotScanState, character: stri const generatedSlotDepthIsZero = (state: GeneratedSlotScanState): boolean => state.braces === 0 && state.brackets === 0 && state.parentheses === 0; +const generatedSlotDepthIsNegative = (state: GeneratedSlotScanState): boolean => + state.braces < 0 || state.brackets < 0 || state.parentheses < 0; + +const generatedSlotIsIncomplete = (state: GeneratedSlotScanState, current: string): boolean => + current.trim().length > 0 || + state.quote !== null || + state.lineComment || + state.blockComment || + !generatedSlotDepthIsZero(state); + const splitGeneratedSlotEntries = (slotBody: string): readonly string[] => { const body = dedentGeneratedSlotBody(slotBody); if (body.length === 0) { @@ -1502,9 +1514,9 @@ const splitGeneratedSlotEntries = (slotBody: string): readonly string[] => { quote: null, }; for (let index = 0; index < body.length; index += 1) { - const character = body[index] ?? ''; - const previousCharacter = body[index - 1] ?? ''; - const nextCharacter = body[index + 1] ?? ''; + const character = body.charAt(index); + const previousCharacter = body.charAt(index - 1); + const nextCharacter = body.charAt(index + 1); current += character; if ( consumeGeneratedSlotProtectedCharacter(state, character, previousCharacter, nextCharacter) @@ -1512,7 +1524,7 @@ const splitGeneratedSlotEntries = (slotBody: string): readonly string[] => { continue; } updateGeneratedSlotDepth(state, character); - if (state.braces < 0 || state.brackets < 0 || state.parentheses < 0) { + if (generatedSlotDepthIsNegative(state)) { return raiseScaffoldFailure('generated owner slot contains unbalanced syntax'); } if (generatedSlotDepthIsZero(state) && (character === ',' || character === ';')) { @@ -1520,13 +1532,7 @@ const splitGeneratedSlotEntries = (slotBody: string): readonly string[] => { current = ''; } } - if ( - current.trim().length > 0 || - state.quote !== null || - state.lineComment || - state.blockComment || - !generatedSlotDepthIsZero(state) - ) { + if (generatedSlotIsIncomplete(state, current)) { return raiseScaffoldFailure('generated owner slot contains incomplete syntax'); } return entries; @@ -1628,17 +1634,6 @@ export const insertSortedSlot = ( ): string => { const start = content.indexOf(startMarker); const end = content.indexOf(endMarker); - if ( - start === -1 || - end === -1 || - start >= end || - content.includes(startMarker, start + startMarker.length) || - content.includes(endMarker, end + endMarker.length) - ) { - return raiseScaffoldFailure( - `generated owner file does not contain one valid ${startMarker} slot`, - ); - } const bodyStart = start + startMarker.length; const existing = readGeneratedSlotEntries(content, startMarker, endMarker); if (existing.some((line) => !validateEntry(line))) { diff --git a/app/scripts/scaffolding/tailwind-prefix.mts b/app/scripts/scaffolding/tailwind-prefix.mts index 5c22a7d4c..96eeee5b5 100644 --- a/app/scripts/scaffolding/tailwind-prefix.mts +++ b/app/scripts/scaffolding/tailwind-prefix.mts @@ -13,10 +13,9 @@ const digitWords = [ 'nine', ] as const; -export class TailwindPrefixError extends Schema.TaggedError()( - 'TailwindPrefixError', - { message: Schema.String }, -) {} +class TailwindPrefixError extends Schema.TaggedError()('TailwindPrefixError', { + message: Schema.String, +}) {} export const tailwindPrefixForNamespace = (namespace: string): string => { const prefix = namespace diff --git a/app/scripts/scaffolding/tests/fixture-files.mts b/app/scripts/scaffolding/tests/fixture-files.mts new file mode 100644 index 000000000..c537daea9 --- /dev/null +++ b/app/scripts/scaffolding/tests/fixture-files.mts @@ -0,0 +1,30 @@ +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +export const write = async (root: string, relativePath: string, content: string): Promise => { + const target = path.join(root, relativePath); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, content, 'utf-8'); +}; + +export const snapshotTree = async ( + root: string, + excludedDirectories: readonly string[] = [], +): Promise>> => { + const snapshot: Record = {}; + const visit = async (directory: string): Promise => { + const entries = await readdir(directory, { withFileTypes: true }); + await Promise.all( + entries.map(async (entry) => { + const target = path.join(directory, entry.name); + if (entry.isDirectory() && !excludedDirectories.includes(entry.name)) { + await visit(target); + } else if (entry.isFile()) { + snapshot[path.relative(root, target)] = await readFile(target, 'utf-8'); + } + }), + ); + }; + await visit(root); + return snapshot; +}; diff --git a/app/scripts/scaffolding/tests/resource-generator.test.mts b/app/scripts/scaffolding/tests/resource-generator.test.mts index b65017d28..d3ccf305d 100644 --- a/app/scripts/scaffolding/tests/resource-generator.test.mts +++ b/app/scripts/scaffolding/tests/resource-generator.test.mts @@ -1,7 +1,8 @@ +import { snapshotTree, write } from './fixture-files.mts'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { randomUUID } from 'node:crypto'; -import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -36,31 +37,6 @@ type JsonValue = const json = (value: JsonValue): string => `${JSON.stringify(value, null, 2)}\n`; -const write = async (root: string, relativePath: string, content: string): Promise => { - const target = path.join(root, relativePath); - await mkdir(path.dirname(target), { recursive: true }); - await writeFile(target, content, 'utf-8'); -}; - -const snapshotTree = async (root: string): Promise>> => { - const snapshot: Record = {}; - const visit = async (directory: string): Promise => { - const entries = await readdir(directory, { withFileTypes: true }); - await Promise.all( - entries.map(async (entry) => { - const entryPath = path.join(directory, entry.name); - if (entry.isDirectory() && entry.name !== 'node_modules') { - await visit(entryPath); - } else if (entry.isFile()) { - snapshot[path.relative(root, entryPath)] = await readFile(entryPath, 'utf-8'); - } - }), - ); - }; - await visit(root); - return snapshot; -}; - const createFixture = async (): Promise => { const root = await mkdtemp(path.join(tmpdir(), 'ontos-resource-scaffold-')); await write(root, 'package.json', json({ name: 'fixture', private: true, type: 'module' })); @@ -288,14 +264,14 @@ await test('resource scaffold publishes a typed ResourceRef and registers its de await test('resource scaffold rejects traversal and reruns without partial writes', async () => { await withFixture(async (root) => { - const beforeTraversal = await snapshotTree(root); + const beforeTraversal = await snapshotTree(root, ['node_modules']); await assert.rejects(scaffoldResource(root, '../unsafe'), /lower-kebab-case/u); - assert.deepEqual(await snapshotTree(root), beforeTraversal); + assert.deepEqual(await snapshotTree(root, ['node_modules']), beforeTraversal); await scaffoldResource(root); - const afterFirstRun = await snapshotTree(root); + const afterFirstRun = await snapshotTree(root, ['node_modules']); await assert.rejects(scaffoldResource(root), /refusing to overwrite existing business file/u); - assert.deepEqual(await snapshotTree(root), afterFirstRun); + assert.deepEqual(await snapshotTree(root, ['node_modules']), afterFirstRun); }); }); @@ -308,9 +284,9 @@ await test('resource scaffold leaves no artifact when generated owner slots or e manifest.replace('// ', '// invalid-resource-slot'), 'utf-8', ); - const beforeMissingSlot = await snapshotTree(root); + const beforeMissingSlot = await snapshotTree(root, ['node_modules']); await assert.rejects(scaffoldResource(root), /generated owner file/u); - assert.deepEqual(await snapshotTree(root), beforeMissingSlot); + assert.deepEqual(await snapshotTree(root, ['node_modules']), beforeMissingSlot); }); await withFixture(async (root) => { @@ -326,9 +302,9 @@ await test('resource scaffold leaves no artifact when generated owner slots or e }, }; await writeFile(packagePath, json(packageWithExportCollision), 'utf-8'); - const beforeExportCollision = await snapshotTree(root); + const beforeExportCollision = await snapshotTree(root, ['node_modules']); await assert.rejects(scaffoldResource(root), /resource contract export .* already exists/u); - assert.deepEqual(await snapshotTree(root), beforeExportCollision); + assert.deepEqual(await snapshotTree(root, ['node_modules']), beforeExportCollision); }); }); diff --git a/app/scripts/scaffolding/tests/retire-contribution.test.mts b/app/scripts/scaffolding/tests/retire-contribution.test.mts index fe4d11ce6..b0f1ad49f 100644 --- a/app/scripts/scaffolding/tests/retire-contribution.test.mts +++ b/app/scripts/scaffolding/tests/retire-contribution.test.mts @@ -1,5 +1,6 @@ +import { snapshotTree, write } from './fixture-files.mts'; import assert from 'node:assert/strict'; -import { access, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -13,31 +14,6 @@ const ARCHIVE_ITEM = 'archive-item'; const ITEM_DETAIL = 'item-detail'; const ARCHIVE_ITEM_ACTION_PATH = 'verticals/inventory/src/actions/archive-item.action.ts'; -const write = async (root: string, relative: string, content: string): Promise => { - const target = path.join(root, relative); - await mkdir(path.dirname(target), { recursive: true }); - await writeFile(target, content, 'utf-8'); -}; - -const snapshotTree = async (root: string): Promise>> => { - const snapshot: Record = {}; - const visit = async (directory: string): Promise => { - const entries = await readdir(directory, { withFileTypes: true }); - await Promise.all( - entries.map(async (entry) => { - const target = path.join(directory, entry.name); - if (entry.isDirectory()) { - await visit(target); - } else if (entry.isFile()) { - snapshot[path.relative(root, target)] = await readFile(target, 'utf-8'); - } - }), - ); - }; - await visit(root); - return snapshot; -}; - const manifest = `// @generated by OntOS Codesmith Module Contract v1 // @ontos-deployment-app-id inventory // @ontos-module-id inventory.core diff --git a/app/scripts/scaffolding/tests/scaffold-generators.test.mts b/app/scripts/scaffolding/tests/scaffold-generators.test.mts index fd355ef0e..69d1f1ae9 100644 --- a/app/scripts/scaffolding/tests/scaffold-generators.test.mts +++ b/app/scripts/scaffolding/tests/scaffold-generators.test.mts @@ -560,6 +560,14 @@ const snapshotTree = async (root: string): Promise => await readFile(path.join(root, relativePath), 'utf-8'); +const contextPermissionCommands = new Set([ + scaffoldCommand.microverticalPage, + scaffoldCommand.moduleApi, + scaffoldCommand.publicComponent, + 'report', + scaffoldCommand.searchProvider, +]); + const run = async ( fixture: Fixture, command: ScaffoldCommand, @@ -588,13 +596,7 @@ const run = async ( ]; } else if (command === scaffoldCommand.outboxWorker) { flags = [...flags, scaffoldFlag.authorization, 'owner_local_background']; - } else if ( - command === scaffoldCommand.microverticalPage || - command === scaffoldCommand.moduleApi || - command === scaffoldCommand.publicComponent || - command === 'report' || - command === scaffoldCommand.searchProvider - ) { + } else if (contextPermissionCommands.has(command)) { flags = [ ...flags, scaffoldFlag.authorization, diff --git a/app/scripts/shared/core-node-services.mts b/app/scripts/shared/core-node-services.mts new file mode 100644 index 000000000..1d4bbb564 --- /dev/null +++ b/app/scripts/shared/core-node-services.mts @@ -0,0 +1,21 @@ +import { createRequire } from 'node:module'; +import { Layer, Result, Schema } from 'effect'; +import type { Command } from 'effect/unstable/cli'; + +export const loadCoreNodeServices = () => { + const loadFromCoreRuntime = createRequire( + new URL('../../packages/core-runtime/package.json', import.meta.url), + ); + const nodePlatform: unknown = loadFromCoreRuntime('@effect/platform-node'); + const AnyLayerSchema = Schema.declare(Layer.isLayer); + const NodeServicesLayerSchema = Schema.declare>( + (value): value is Layer.Layer => Schema.is(AnyLayerSchema)(value), + ); + const NodePlatformSchema = Schema.Struct({ + NodeServices: Schema.Struct({ layer: NodeServicesLayerSchema }), + }); + const { NodeServices } = Result.getOrThrow( + Schema.decodeUnknownResult(NodePlatformSchema)(nodePlatform), + ); + return NodeServices; +}; diff --git a/app/scripts/shared/ultramodern-command.mts b/app/scripts/shared/ultramodern-command.mts new file mode 100644 index 000000000..1bf128c64 --- /dev/null +++ b/app/scripts/shared/ultramodern-command.mts @@ -0,0 +1,88 @@ +import { Config, Console, Effect, Exit, Option, Path, Stdio } from 'effect'; +import type { PlatformError } from 'effect'; +import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; + +interface CommandOptions { + readonly command: string; + readonly directoryFailure: string; + readonly failure: (reason: string) => E; + readonly launchErrorDetail?: (error: PlatformError.PlatformError) => string; + readonly moduleUrl: string; + readonly nodeExecutable?: string; +} + +export const resolveUltramodernInvocation = (options: CommandOptions) => + Effect.gen(function* resolveUltramodernInvocationEffect() { + const path = yield* Path.Path; + const stdio = yield* Stdio.Stdio; + const moduleDirectory = yield* path + .fromFileUrl(new URL('.', options.moduleUrl)) + .pipe(Effect.mapError(() => options.failure(options.directoryFailure))); + const workspaceRoot = yield* Config.string('ULTRAMODERN_WORKSPACE_ROOT').pipe( + Config.withDefault(path.resolve(moduleDirectory, '..')), + Effect.mapError(() => options.failure('ULTRAMODERN_WORKSPACE_ROOT is invalid')), + ); + const createBin = yield* Config.string('ULTRAMODERN_CREATE_BIN').pipe( + Config.option, + Effect.map(Option.filter((value) => value.length > 0)), + Effect.mapError(() => options.failure('ULTRAMODERN_CREATE_BIN is invalid')), + ); + const forwardedArgs = yield* stdio.args; + const args = ['ultramodern', options.command, ...forwardedArgs]; + const nodeExecutable = options.nodeExecutable ?? 'node'; + const launch = Option.match(createBin, { + onNone: () => ({ + args, + executable: 'modern-js-create', + target: 'modern-js-create from PATH', + }), + onSome: (bin) => ({ + args: [bin, ...args], + executable: nodeExecutable, + target: `${nodeExecutable} with ULTRAMODERN_CREATE_BIN=${bin}`, + }), + }); + return { + command: ChildProcess.make(launch.executable, launch.args, { + env: { ULTRAMODERN_WORKSPACE_ROOT: workspaceRoot }, + extendEnv: true, + shell: Option.isNone(createBin) && path.sep === '\\', + stderr: 'inherit', + stdin: 'inherit', + stdout: 'inherit', + }), + forwardedArgs, + launchFailure: (error: PlatformError.PlatformError) => { + const detail = options.launchErrorDetail?.(error) ?? `: ${String(error)}`; + return options.failure( + `Failed to launch ${launch.target} for UltraModern command "${args.slice(1).join(' ')}"${detail}`, + ); + }, + workspaceRoot, + }; + }); + +export const launchUltramodern = ( + invocation: Effect.Success>>, +) => + Effect.gen(function* launchUltramodernEffect() { + const processSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return Number( + yield* processSpawner + .exitCode(invocation.command) + .pipe(Effect.mapError(invocation.launchFailure)), + ); + }); + +export const runUltramodernScript = ( + options: CommandOptions, +) => + resolveUltramodernInvocation(options).pipe( + Effect.flatMap(launchUltramodern), + Effect.tapError(({ reason }) => Console.error(reason)), + ); + +export const ultramodernExitCode = Exit.match({ + onFailure: () => 1, + onSuccess: (status) => status, +}); diff --git a/app/scripts/shared/ultramodern-launch.mts b/app/scripts/shared/ultramodern-launch.mts new file mode 100644 index 000000000..fd48f5647 --- /dev/null +++ b/app/scripts/shared/ultramodern-launch.mts @@ -0,0 +1,36 @@ +import { Option } from 'effect'; +import { ChildProcess } from 'effect/unstable/process'; + +export const ultramodernLaunch = ( + createBin: Option.Option, + ultramodernArgs: readonly string[], + workspaceRoot: string, + pathSeparator: string, +) => { + const launch = Option.match(createBin, { + onNone: () => ({ + args: ultramodernArgs, + executable: 'modern-js-create', + shell: pathSeparator === '\\', + target: 'modern-js-create from PATH', + }), + onSome: (bin) => ({ + args: [bin, ...ultramodernArgs], + executable: process.execPath, + shell: false, + target: `${process.execPath} with ULTRAMODERN_CREATE_BIN=${bin}`, + }), + }); + + return { + command: ChildProcess.make(launch.executable, launch.args, { + env: { ULTRAMODERN_WORKSPACE_ROOT: workspaceRoot }, + extendEnv: true, + shell: launch.shell, + stderr: 'inherit', + stdin: 'inherit', + stdout: 'inherit', + }), + target: launch.target, + }; +}; diff --git a/app/scripts/shared/ultramodern-wrapper-source.mts b/app/scripts/shared/ultramodern-wrapper-source.mts new file mode 100644 index 000000000..551a8ec01 --- /dev/null +++ b/app/scripts/shared/ultramodern-wrapper-source.mts @@ -0,0 +1,67 @@ +import { parseSync } from 'oxc-parser'; + +const withoutComments = (source: string): string => { + const parsed = parseSync('wrapper.mts', source); + if (parsed.errors.length > 0) { + return ''; + } + let text = source; + for (const comment of parsed.comments) { + text = + text.slice(0, comment.start) + + ' '.repeat(comment.end - comment.start) + + text.slice(comment.end); + } + return text; +}; + +const hasSharedUltramodernDispatch = (source: string): boolean => + source.includes("Config.string('ULTRAMODERN_CREATE_BIN')") && + source.includes("['ultramodern', options.command, ...forwardedArgs]") && + source.includes("executable: 'modern-js-create'") && + source.includes('ChildProcess.make(launch.executable, launch.args,') && + source.includes('resolveUltramodernInvocation(options).pipe(') && + source.includes('Effect.flatMap(launchUltramodern)'); + +export const hasUltramodernSkillsDispatch = (source: string, implementation: string): boolean => { + const wrapper = withoutComments(source); + const runner = withoutComments(implementation); + return ( + wrapper.includes("from './shared/ultramodern-launch.mts'") && + wrapper.includes("['skills', 'check',") && + wrapper.includes("['skills', 'install',") && + wrapper.includes("['ultramodern', ...skillArgs]") && + wrapper.includes('ultramodernLaunch(createBin, ultramodernArgs, workspaceRoot, path.sep)') && + wrapper.includes("Config.string('ULTRAMODERN_CREATE_BIN')") && + runner.includes("executable: 'modern-js-create'") && + runner.includes('ChildProcess.make(launch.executable, launch.args,') + ); +}; + +/** Recognize the explicit wrapper contract, not a dependency mentioned in prose. */ +export const hasUltramodernDispatch = ( + source: string | undefined, + command: string, + implementation: string | undefined, +): boolean => { + if (source === undefined || !/^[a-z-]+$/u.test(command)) { + return false; + } + const wrapper = withoutComments(source); + if (wrapper.includes(`['ultramodern', '${command}', ...forwardedArgs]`)) { + return true; + } + if (implementation === undefined) { + return false; + } + const runner = withoutComments(implementation); + const importsRunner = + /import\s*\{[^}]*\b(?:runUltramodernScript|resolveUltramodernInvocation)\b[^}]*\}\s*from\s*['"]\.\/shared\/ultramodern-command\.mts['"]/u.test( + wrapper, + ); + const invokesCommand = new RegExp( + `(?:runUltramodernScript|resolveUltramodernInvocation)\\(\\{\\s*command:\\s*['"]${command}['"]`, + 'u', + ).test(wrapper); + return importsRunner && invokesCommand && hasSharedUltramodernDispatch(runner); +}; diff --git a/app/scripts/tests/api-only-tooling.test.mts b/app/scripts/tests/api-only-tooling.test.mts index 883f7b26a..a5712bd3f 100644 --- a/app/scripts/tests/api-only-tooling.test.mts +++ b/app/scripts/tests/api-only-tooling.test.mts @@ -12,11 +12,12 @@ import type { TestContext } from 'node:test'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; import { Predicate, Schema } from 'effect'; -import { transform } from 'esbuild'; +import { build as bundleSource, transform } from 'esbuild'; import { format } from 'oxfmt'; const workspaceRoot = fileURLToPath(new URL('../..', import.meta.url)); const partyId = 'party-registry'; +const partyDirectory = 'verticals/party-registry'; const mfManifestPath = '/mf-manifest.json'; const readinessPath = '/party-registry-api/party-registry/readiness'; const localePath = '/locales/en/party-registry.json'; @@ -525,8 +526,10 @@ const evaluatePartyBuildGlobalVars = async (shellOrigin: string) => { await writeFile( harnessPath, `import * as effect from ${JSON.stringify(effectModuleUrl)}; +import * as sharedBuild from ${JSON.stringify(pathToFileURL(path.join(workspaceRoot, 'packages/shared-contracts/tooling/modern-config.ts')).href)}; import { runInNewContext } from 'node:vm'; const framework = { + ...sharedBuild, appTools: () => ({}), bffPlugin: () => ({}), createRequire: () => () => ({}), @@ -564,7 +567,7 @@ void test('Party build configuration injects the exact nonlocal Shell origin int void test('compiled Party CORS reader uses the nonlocal DefinePlugin origin without a runtime global', async () => { const shellOrigin = 'https://operations.example.test'; const globalVars = await evaluatePartyBuildGlobalVars(shellOrigin); - const partyRoot = path.join(workspaceRoot, 'verticals/party-registry'); + const partyRoot = path.join(workspaceRoot, partyDirectory); const source = await readFile(path.join(partyRoot, 'api/index.ts'), 'utf-8'); const reader = /(?declare const ULTRAMODERN_SHELL_ORIGIN[\s\S]+?const shellOrigin = readShellOrigin\(\);)/u.exec( @@ -572,7 +575,8 @@ void test('compiled Party CORS reader uses the nonlocal DefinePlugin origin with )?.groups?.reader; assert.notEqual(reader, undefined, 'compile the actual API origin-reader boundary'); const appToolsPath = require.resolve('@modern-js/app-tools/config', { paths: [partyRoot] }); - const rspackModule: unknown = require(require.resolve('@rspack/core', { paths: [appToolsPath] })); + const rsbuildPath = require.resolve('@rsbuild/core', { paths: [appToolsPath] }); + const rspackModule: unknown = require(require.resolve('@rspack/core', { paths: [rsbuildPath] })); const rspackFixture = Schema.decodeUnknownSync(RspackModuleFixtureSchema)(rspackModule); const temporaryRoot = await mkdtemp(path.join(partyRoot, 'node_modules/.ontos-compiled-cors-')); try { @@ -639,7 +643,104 @@ const normalizedGeneratedSource = async (fileName: string, source: string) => { return result.code.replaceAll(/^\s*\n/gmu, ''); }; -void test('all published scaffold formats retain lint-safe Party infrastructure parity', async () => { +const evaluatedInfrastructureSource = async ( + fileName: string, + source: string, + cloudflare: boolean, +): Promise => { + const partyRoot = path.join(workspaceRoot, partyDirectory); + const result = await bundleSource({ + bundle: true, + define: { + 'import.meta.resolve': '__resolve', + 'import.meta.url': JSON.stringify(pathToFileURL(path.join(partyRoot, fileName)).href), + }, + external: ['./src/routes/ultramodern-route-metadata'], + format: 'cjs', + packages: 'external', + platform: 'node', + stdin: { + contents: source, + loader: 'ts', + resolveDir: path.dirname(path.join(partyRoot, fileName)), + }, + write: false, + }); + const code = result.outputFiles[0]?.text; + assert.ok(code); + const effectUrl = pathToFileURL(require.resolve('effect')).href; + return runNode([ + '--input-type=module', + '-e', + ` +import * as effect from ${JSON.stringify(effectUrl)}; +import * as nodeModule from 'node:module'; +import * as nodePath from 'node:path'; +import * as nodeUrl from 'node:url'; +import { runInNewContext } from 'node:vm'; +const environment = { + MODERNJS_DEPLOY: ${JSON.stringify(cloudflare ? 'cloudflare' : 'node')}, + ULTRAMODERN_MF_DEV_ORIGIN: 'https://shell.example.test', + ULTRAMODERN_PUBLIC_URL_PARTY_REGISTRY: 'https://party.example.test', + ZE_CI_TOKEN: 'proof-token', +}; +const plugin = name => options => ({ name, options }); +const framework = { + appTools: plugin('appTools'), bffPlugin: plugin('bff'), i18nPlugin: plugin('i18n'), + moduleFederationPlugin: plugin('moduleFederation'), pluginTailwindcss: plugin('tailwind'), + tanstackRouterPlugin: plugin('tanstack'), withZephyr: plugin('zephyr'), + defineConfig: value => value, presetUltramodern: (value, identity) => ({ ...value, identity }), + getBuildConfigEnvironment: name => environment[name], + withBuildConfigEnvironment: (_name, _value, configuration) => configuration, + ultramodernLocalisedUrls: {}, +}; +const moduleShim = { ...nodeModule, createRequire: () => Object.assign(() => ({}), { resolve: name => '/dependencies/' + name }) }; +const module = { exports: {} }; +runInNewContext(${JSON.stringify(code)}, { + exports: module.exports, module, URL, + ULTRAMODERN_BUILD_MARKER: 'injected-build', ULTRAMODERN_SOURCE_REVISION: 'injected-revision', + __resolve: name => 'file:///dependencies/' + name, + require: name => ({ effect, 'node:module': moduleShim, 'node:path': nodePath, 'node:url': nodeUrl }[name] ?? framework), +}); +const configuration = module.exports.default; +const observations = {}; +if (configuration?.tools) { + const chainValues = []; + const output = { uniqueName: name => { chainValues.push(name); return output; }, chunkLoadingGlobal: name => { chainValues.push(name); return output; } }; + configuration.tools.bundlerChain?.({ output }); + observations.chain = chainValues; + observations.plugins = []; + for (const entry of configuration.plugins ?? []) entry.setup?.({ modifyRspackConfig: value => observations.plugins.push(value) }); + const plugins = { + DefinePlugin: class { constructor(definitions) { this.definitions = definitions; } }, + NormalModuleReplacementPlugin: class { constructor(pattern, replace) { + this.pattern = pattern; + this.results = ['./handler.ts', './handler.ts?loaderId=x&retain=false', './other.ts?modern-bff-runtime-source'].map(request => { + const resource = { context: ${JSON.stringify(path.join(partyRoot, 'api'))}, request }; replace(resource); return resource; + }); + } }, + }; + observations.rspack = ['client', 'workerSSR'].map(name => { + const config = { resolve: {}, externals: [], plugins: [], node: {} }; + configuration.tools.rspack?.(config, { environment: { name }, rspack: plugins }); + const externalResults = []; + for (const external of config.externals) for (const request of ['node:fs', 'fs', 'cloudflare:sockets', 'unrelated']) { + external({ request, dependencyType: 'commonjs' }, (...args) => externalResults.push(args)); + } + return { config, externalResults }; + }); +} +const normalize = (_key, value) => { + if (typeof value === 'function') return '[Function]'; + if (Object.prototype.toString.call(value) === '[object RegExp]') return String(value); + return value; +}; +process.stdout.write(JSON.stringify({ exported: module.exports, observations }, normalize)); +`, + ]); +}; + +void test('all published scaffold formats retain Party infrastructure behavior and source parity', async () => { await Promise.all( ['esm', 'esm-node', 'cjs'].map(async (moduleFormat) => { const extension = moduleFormat === 'cjs' ? 'cjs' : 'js'; @@ -697,9 +798,26 @@ void test('all published scaffold formats retain lint-safe Party infrastructure await Promise.all( Object.entries(generated).map(async ([fileName, source]) => { const actual = await readFile( - path.join(workspaceRoot, 'verticals/party-registry', fileName), + path.join(workspaceRoot, partyDirectory, fileName), 'utf-8', ); + if (fileName === 'modern.config.ts' || fileName === 'shared/ultramodern-build.ts') { + await Promise.all( + [false, true].map(async (cloudflare) => { + const [expected, evaluated] = await Promise.all([ + evaluatedInfrastructureSource(fileName, source, cloudflare), + evaluatedInfrastructureSource(fileName, actual, cloudflare), + ]); + const decode = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Json)); + assert.deepEqual( + decode(expected), + decode(evaluated), + `${moduleFormat}: ${fileName} must preserve evaluated configuration, build identity and plugin behavior`, + ); + }), + ); + return; + } assert.equal( await normalizedGeneratedSource(fileName, source), await normalizedGeneratedSource(fileName, actual), diff --git a/app/scripts/tests/initialize-local-development.test.mts b/app/scripts/tests/initialize-local-development.test.mts index fb1dc93c8..c652ec6e7 100644 --- a/app/scripts/tests/initialize-local-development.test.mts +++ b/app/scripts/tests/initialize-local-development.test.mts @@ -1,3 +1,4 @@ +import { makeModuleContractFixture } from '../../packages/core-runtime/src/testing/module-contract.ts'; import { runEffectTestPromise } from '../../packages/core-runtime/src/testing/effect-runtime.ts'; import assert from 'node:assert/strict'; import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; @@ -43,45 +44,8 @@ const topology = JSON.stringify({ verticals: [{ id: 'party-registry' }, { id: 'i const moduleContract = ( moduleId: string, -): Awaited> => ({ - deployment: { appId: 'test-module', buildMarker: 'test-build' }, - manifest: { - activation: { - defaultState: 'inactive', - preservesHistoryWhenInactive: true, - scope: 'tenant', - supportedStates: ['inactive', 'active'], - }, - module: { - description: `${moduleId} module`, - displayName: moduleId, - id: moduleId, - implementedAs: 'ultramodern_microvertical', - kind: 'business_module', - }, - publicSurface: { - actions: [], - api: [], - components: [], - events: [], - reports: [], - resourceTypes: [], - search: [], - shellContributions: { - mediaAttachments: [], - navigation: [], - pages: [], - publicComponents: [], - reports: [], - resourceDetails: [], - search: [], - timelines: [], - }, - }, - }, - runtime: { outboxSubscriptions: [] }, - schemaVersion: '2', -}); +): Awaited> => + makeModuleContractFixture({ appId: 'test-module', buildMarker: 'test-build', moduleId }); void test('accepts only a development configuration with local service endpoints', async () => { const configuration = await runEffectTestPromise( diff --git a/app/scripts/tests/module-entrypoint-boundaries.test.mts b/app/scripts/tests/module-entrypoint-boundaries.test.mts index 2e46dfac0..6dfae172b 100644 --- a/app/scripts/tests/module-entrypoint-boundaries.test.mts +++ b/app/scripts/tests/module-entrypoint-boundaries.test.mts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import nodeTest from 'node:test'; @@ -771,3 +771,72 @@ test('rejects missing, orphaned, and cross-owner route manifest entries', async await rm(root, { force: true, recursive: true }); } }); + +for (const [module, operation] of [ + ['governed-read-handler', 'governedReadHandler'], + ['governed-detail-read-execution', 'executeGovernedRead'], +] as const) { + test(`shared ${module} requires connected authentication and read registration`, async () => { + const root = await makeFixture(); + const handlerFile = `verticals/inventory-stock/api/${module}.ts`; + const serverFile = 'verticals/inventory-stock/api/stock-list-read-server.ts'; + const handler = await readFile( + new URL(`../../verticals/party-registry/api/${module}.ts`, import.meta.url), + 'utf-8', + ); + const server = `import { ${operation} } from './${module}.ts'; +export const live = HttpApiBuilder.group(StockListApi, 'reads', () => ${operation}({ registration: stockListRead, }));`; + try { + await writeGovernedModuleApi(root); + await write(root, serverFile, server); + await assert.rejects(checkModuleEntrypointBoundaries(root), /module APIs require/u); + await write(root, handlerFile, handler); + await checkModuleEntrypointBoundaries(root); + await write( + root, + handlerFile, + handler.replace('yield* authenticateOperationPrincipal(', 'yield* disconnectedPrincipal('), + ); + await assert.rejects(checkModuleEntrypointBoundaries(root), /module APIs require/u); + await write(root, handlerFile, handler); + await write( + root, + serverFile, + server.replace(`from './${module}.ts'`, "from './unused-neighbor.ts'"), + ); + await assert.rejects(checkModuleEntrypointBoundaries(root), /module APIs require/u); + await write( + root, + serverFile, + server.replace('registration: stockListRead,', 'registration: unrelatedRead,'), + ); + await assert.rejects(checkModuleEntrypointBoundaries(root), /module APIs require/u); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); +} + +test('typed issuer paths require the mounted gateway group and exact endpoint references', async () => { + const root = await makeFixture(); + const contractFile = 'apps/shell-super-app/shared/api.ts'; + const source = await readFile(new URL(`../../${contractFile}`, import.meta.url), 'utf-8'); + try { + await write(root, contractFile, source); + await checkModuleEntrypointBoundaries(root); + await write( + root, + contractFile, + source.replace('.add(GatewayContextApiGroup)', '.add(UnrelatedApiGroup)'), + ); + await assert.rejects(checkModuleEntrypointBoundaries(root), /mounted gateway contract/u); + await write( + root, + contractFile, + source.replace('endpoints.issueGatewayContext', 'endpoints.unusedNeighbor'), + ); + await assert.rejects(checkModuleEntrypointBoundaries(root), /mounted gateway contract/u); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); diff --git a/app/scripts/tests/provision-current-action-authorization.test.mts b/app/scripts/tests/provision-current-action-authorization.test.mts index 623d0ba58..6964e6013 100644 --- a/app/scripts/tests/provision-current-action-authorization.test.mts +++ b/app/scripts/tests/provision-current-action-authorization.test.mts @@ -6,7 +6,7 @@ import path from 'node:path'; import { test } from 'node:test'; import { pathToFileURL } from 'node:url'; import { v1 } from '@authzed/authzed-node'; -import { Effect, Option, Schema } from 'effect'; +import { Effect, Option, Predicate, Schema } from 'effect'; import { ACTION_AUTHORIZATION_DENIED_PRINCIPAL_ID, ActionAuthorizationProvisioningError, @@ -105,7 +105,7 @@ const rejectionOf = async (promise: Promise): Promise => { try { await promise; } catch (error) { - if (error instanceof Error) { + if (Predicate.isError(error)) { return error; } return assert.fail('Expected the Promise to reject with an Error'); @@ -343,6 +343,24 @@ interface ProvisioningClientFixture { readonly state: ProvisioningClientState; } +const permissionResponse = (hasPermission: boolean) => + Option.some( + response( + hasPermission + ? v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION + : v1.CheckPermissionResponse_Permissionship.NO_PERMISSION, + ), + ); + +const hasActionGrant = ( + grants: ReadonlySet, + resourceId: string, + principalId: string, + tenantId: string | undefined, +): boolean => + grants.has(`${resourceId}:${principalId}`) || + (tenantId !== undefined && grants.has(`${resourceId}:${tenantId}`)); + const makeProvisioningClient = ( contexts: readonly ActionAuthorizationContext[], ): ProvisioningClientFixture => { @@ -359,27 +377,13 @@ const makeProvisioningClient = ( client: { checkPermission: (request) => Effect.sync(() => { - if (request.permission === 'access') { - return Option.some( - response( - principalTenants.get(request.subject?.object?.objectId ?? '') === - request.resource?.objectId - ? v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION - : v1.CheckPermissionResponse_Permissionship.NO_PERMISSION, - ), - ); - } const principalId = request.subject?.object?.objectId ?? ''; const tenantId = principalTenants.get(principalId); - const tenantGrant = `${request.resource?.objectId ?? ''}:${tenantId ?? ''}`; - const principalGrant = `${request.resource?.objectId ?? ''}:${principalId}`; - return Option.some( - response( - state.grants.has(principalGrant) || - (tenantId !== undefined && state.grants.has(tenantGrant)) - ? v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION - : v1.CheckPermissionResponse_Permissionship.NO_PERMISSION, - ), + if (request.permission === 'access') { + return permissionResponse(tenantId === request.resource?.objectId); + } + return permissionResponse( + hasActionGrant(state.grants, request.resource?.objectId ?? '', principalId, tenantId), ); }), writeRelationships: (request) => diff --git a/app/scripts/tests/quality-audit-runtime-model.test.mts b/app/scripts/tests/quality-audit-runtime-model.test.mts index 4dea1cae8..e7091e43f 100644 --- a/app/scripts/tests/quality-audit-runtime-model.test.mts +++ b/app/scripts/tests/quality-audit-runtime-model.test.mts @@ -1,6 +1,6 @@ import { runPinnedKnip } from './quality-audit-test-support.mts'; import assert from 'node:assert/strict'; -import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import test from 'node:test'; @@ -21,6 +21,7 @@ const emptyLayout = 'export default function Layout() { return null; }'; const compilerConfig = 'tsconfig.base.json'; const pluginName = '@effect/language-service'; const compilerOptionKind = 'compiler-option'; +const tsgoName = '@effect/tsgo'; const tsgoReadme = 'node_modules/@effect/tsgo/README.md'; const tsgoPackage = 'node_modules/@effect/tsgo/package.json'; const compilerDocumentation = @@ -119,7 +120,7 @@ await test('runtime consumers require the exact CSS, shell, deployment and compi cssUsed, launchedFile, resetFile, - '@effect/tsgo', + tsgoName, pluginName, readinessConfig, ]) { @@ -220,7 +221,7 @@ await test('DTS compiler resolution belongs to the invoking workspace and exclud write(root, configFile, source); write(root, `${shellRoot}/${tsgoReadme}`, 'tries `typescript`, then `@typescript/native`'); const initial = await facts(root); - for (const target of ['@effect/tsgo', '@typescript/native']) { + for (const target of [tsgoName, '@typescript/native']) { assert.ok(initial.some((fact) => fact.target === target && fact.workspace === shellRoot)); } write(root, configFile, `/* ${source} */\nexport default {};`); @@ -324,3 +325,56 @@ await test('real Knip keeps unused neighboring files, dependency names and expor rmSync(root, { force: true, recursive: true }); } }); + +await test('shared framework runner retains compiler/readiness evidence without accepting unused neighbors', async () => { + const root = await fixture(); + const runnerFile = 'scripts/shared/ultramodern-command.mts'; + try { + const runner = readFileSync( + new URL('../shared/ultramodern-command.mts', import.meta.url), + 'utf-8', + ); + write(root, runnerFile, runner); + for (const command of ['typecheck', 'performance-readiness']) { + write( + root, + `scripts/ultramodern-${command}.mts`, + readFileSync(new URL(`../ultramodern-${command}.mts`, import.meta.url), 'utf-8'), + ); + } + const modeled = await facts(root); + for (const target of [tsgoName, pluginName, readinessConfig, `${readinessConfig}#default`]) { + assert.ok( + modeled.some((fact) => fact.target === target), + target, + ); + } + write( + root, + runnerFile, + runner.replace( + 'ChildProcess.make(launch.executable, launch.args,', + 'ChildProcess.make("unrelated", [],', + ), + ); + const disconnected = await facts(root); + assert.ok(!disconnected.some((fact) => fact.target === tsgoName)); + assert.ok(!disconnected.some((fact) => fact.target === readinessConfig)); + write(root, runnerFile, runner); + write( + root, + 'scripts/ultramodern-typecheck.mts', + "import { runUltramodernScript } from './shared/unrelated.mts'; runUltramodernScript({ command: 'typecheck' });", + ); + write( + root, + 'scripts/ultramodern-performance-readiness.mts', + "import { runUltramodernScript } from './shared/ultramodern-command.mts'; runUltramodernScript({ command: 'unrelated' });", + ); + const neighbors = await facts(root); + assert.ok(!neighbors.some((fact) => fact.target === tsgoName)); + assert.ok(!neighbors.some((fact) => fact.target === readinessConfig)); + } finally { + rmSync(root, { force: true, recursive: true }); + } +}); diff --git a/app/scripts/tests/quality-audit.test.mts b/app/scripts/tests/quality-audit.test.mts index 3a3d2b85b..5824b5274 100644 --- a/app/scripts/tests/quality-audit.test.mts +++ b/app/scripts/tests/quality-audit.test.mts @@ -448,6 +448,11 @@ await test('the CLI handles escaped paths, foreign cwd and untracked source prov yield* spawner.string(ChildProcess.make('git', ['init', '-q', root])); }).pipe(Effect.provide(NodeServices.layer)), ); + mkdirSync(path.join(root, 'scripts/shared'), { recursive: true }); + copyFileSync( + path.join(appRoot, 'scripts/shared/ultramodern-wrapper-source.mts'), + path.join(root, 'scripts/shared/ultramodern-wrapper-source.mts'), + ); const executable = path.join(root, 'scripts/quality audit.mts'); copyFileSync(path.join(appRoot, 'scripts/quality-audit.mts'), executable); for (const file of ['knip-model.mts', 'knip-runtime-model.mts']) { diff --git a/app/scripts/tests/ultramodern-command.test.mts b/app/scripts/tests/ultramodern-command.test.mts new file mode 100644 index 000000000..2669ab9e5 --- /dev/null +++ b/app/scripts/tests/ultramodern-command.test.mts @@ -0,0 +1,115 @@ +/// + +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import type { TestContext } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { NodeServices } from '@effect/platform-node'; +import { Effect, ManagedRuntime, Stream } from 'effect'; +import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; + +const workspaceRoot = fileURLToPath(new URL('../..', import.meta.url)); +const wrappers = [ + ['assert-mf-types', 'mf-types'], + ['generate-node-backend-federation', 'backend-federation-generate'], + ['generate-public-surface-assets', 'public-surface'], + ['migrate-strict-effect', 'migrate-strict-effect'], + ['proof-cloudflare-version', 'cloudflare-proof'], + ['ultramodern-performance-readiness', 'performance-readiness'], + ['ultramodern-typecheck', 'typecheck'], + ['verify-cloudflare-output', 'cloudflare-output-verify'], +] as const; + +const fixtureDirectory = (context: TestContext) => { + const directory = mkdtempSync(path.join(os.tmpdir(), 'ontos-command-')); + context.after(() => rmSync(directory, { force: true, recursive: true })); + return directory; +}; + +const wrapperRuntime = ManagedRuntime.make(NodeServices.layer); +test.after(async () => { + await wrapperRuntime.dispose(); +}); + +const invokeWrapper = async ( + script: string, + environment: Readonly>, + args: readonly string[] = [], +) => + await wrapperRuntime.runPromise( + Effect.gen(function* invokeWrapperEffect() { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make( + process.execPath, + [path.join(workspaceRoot, 'scripts', `${script}.mts`), ...args], + { + cwd: workspaceRoot, + env: environment, + extendEnv: true, + stderr: 'pipe', + stdin: 'ignore', + stdout: 'pipe', + }, + ), + ); + return yield* Effect.all( + { + status: child.exitCode.pipe(Effect.map(Number)), + stderr: child.stderr.pipe(Stream.decodeText(), Stream.mkString), + stdout: child.stdout.pipe(Stream.decodeText(), Stream.mkString), + }, + { concurrency: 'unbounded' }, + ); + }).pipe(Effect.scoped), + ); + +for (const [script, command] of wrappers) { + void test(`${script} forwards arguments, workspace and child exit status`, async (context) => { + const fixture = fixtureDirectory(context); + const createBin = path.join(fixture, 'create.mjs'); + writeFileSync( + createBin, + 'console.log(process.argv.slice(2).join("|")); console.log(process.env.ULTRAMODERN_WORKSPACE_ROOT); process.exitCode = 7;', + ); + const result = await invokeWrapper( + script, + { ULTRAMODERN_CREATE_BIN: createBin, ULTRAMODERN_WORKSPACE_ROOT: fixture }, + ['--probe', 'argument with spaces'], + ); + assert.equal(result.status, 7, result.stderr); + assert.equal( + result.stdout, + `ultramodern|${command}|--probe|argument with spaces\n${fixture}\n`, + ); + }); +} + +void test('route generation continues compatibility generation after a nonzero framework exit', async (context) => { + const fixture = fixtureDirectory(context); + const createBin = path.join(fixture, 'create.mjs'); + writeFileSync(createBin, 'process.exitCode = 7;'); + mkdirSync(path.join(fixture, '.modernjs')); + writeFileSync(path.join(fixture, '.modernjs/ultramodern.json'), '{"topology":{"apps":[]}}'); + const result = await invokeWrapper('generate-tanstack-routes', { + ULTRAMODERN_CREATE_BIN: createBin, + ULTRAMODERN_WORKSPACE_ROOT: fixture, + }); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /continuing with the repository compatibility manifest/u); +}); + +void test('missing PATH launcher reports a typed launch failure and exits one', async (context) => { + const fixture = fixtureDirectory(context); + const result = await invokeWrapper('assert-mf-types', { + PATH: fixture, + ULTRAMODERN_CREATE_BIN: '', + ULTRAMODERN_WORKSPACE_ROOT: fixture, + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /Failed to launch modern-js-create from PATH/u); + assert.match(result.stderr, /UltraModern command "mf-types"/u); +}); diff --git a/app/scripts/typescript-api-contract-boundary.mts b/app/scripts/typescript-api-contract-boundary.mts index e4aacc081..1775dd425 100644 --- a/app/scripts/typescript-api-contract-boundary.mts +++ b/app/scripts/typescript-api-contract-boundary.mts @@ -1,3 +1,5 @@ +/// + import path from 'node:path'; import type { CallExpression, Expression, MemberExpression, VariableDeclarator } from 'oxc-parser'; @@ -130,6 +132,9 @@ interface PackageExportResolution { readonly targets: readonly string[]; } +const isRootPackageExport = (value: PackageExportValue): boolean => + value === null || isPackageExportString(value) || isPackageExportArray(value); + const packageExportResolution = ( packageJson: string, exportKey: string, @@ -139,11 +144,7 @@ const packageExportResolution = ( return { governed: false, targets: [] }; } const exportsField = parsed.success.exports; - if ( - exportsField === null || - isPackageExportString(exportsField) || - isPackageExportArray(exportsField) - ) { + if (isRootPackageExport(exportsField)) { return { governed: true, targets: exportKey === '.' ? exportTargets(exportsField) : [] }; } if (!isPackageExportRecord(exportsField)) { @@ -155,6 +156,13 @@ const packageExportResolution = ( if (Object.hasOwn(exportsField, exportKey)) { return { governed: true, targets: exportTargets(exportsField[exportKey] ?? null) }; } + return wildcardExportResolution(exportsField, exportKey); +}; + +const wildcardExportResolution = ( + exportsField: Readonly>, + exportKey: string, +): PackageExportResolution => { const wildcardMatches = Object.entries(exportsField).flatMap(([key, value]) => { const wildcard = key.indexOf('*'); if (wildcard === -1) { @@ -268,9 +276,17 @@ const addExport = ( exports.set(exportedName, existing); }; +const destructuredPropertyName = ( + property: Extract['properties'][number], +): string | undefined => { + if (property.type !== 'Property' || property.key.type === 'PrivateIdentifier') { + return undefined; + } + return property.key.type === 'Identifier' ? property.key.name : staticString(property.key); +}; + const sourceModels = new WeakMap, Map>(); -// eslint-disable-next-line complexity -- This is the single TypeScript-AST indexing pass for imports, exports, calls, declarations, and references. const parseSourceModel = (file: string, content: string): SourceModel => { const parsed = parseSync(file, content, { astType: 'ts', @@ -375,7 +391,7 @@ const parseSourceModel = (file: string, content: string): SourceModel => { const object = pattern as typeof pattern & { readonly properties: readonly ({ readonly type: string } & Span)[]; }; - for (const property of object.properties) { + const addObjectShadow = (property: (typeof object.properties)[number]): void => { if (property.type === 'Property') { const value = property as typeof property & { readonly value: { readonly type: string } & Span; @@ -387,6 +403,9 @@ const parseSourceModel = (file: string, content: string): SourceModel => { }; addShadowPattern(rest.argument, scope); } + }; + for (const property of object.properties) { + addObjectShadow(property); } return; } @@ -394,10 +413,13 @@ const parseSourceModel = (file: string, content: string): SourceModel => { const array = pattern as typeof pattern & { readonly elements: readonly (({ readonly type: string } & Span) | null)[]; }; - for (const element of array.elements) { + const addArrayShadow = (element: (typeof array.elements)[number]): void => { if (element !== null) { addShadowPattern(element, scope); } + }; + for (const element of array.elements) { + addArrayShadow(element); } } }; @@ -410,7 +432,10 @@ const parseSourceModel = (file: string, content: string): SourceModel => { } }, AssignmentExpression: (node) => { - if (node.operator === '=' && node.left.type === 'Identifier') { + if (node.operator !== '=') { + return; + } + if (node.left.type === 'Identifier') { const owner = scopedBindingAt({ bindings }, node.left.name, node.start); addBinding(node.left.name, { at: node.start, @@ -418,10 +443,13 @@ const parseSourceModel = (file: string, content: string): SourceModel => { kind: 'expression', scope: owner?.scope ?? enclosingScope(node), }); - } else if (node.operator === '=' && node.left.type === 'MemberExpression') { + } else if (node.left.type === 'MemberExpression') { const assignmentPath = memberPath(node.left); - const root = assignmentPath?.[0]; - if (assignmentPath !== undefined && root !== undefined) { + if (assignmentPath === undefined) { + return; + } + const root = assignmentPath[0]; + if (root !== undefined) { const owner = scopedBindingAt({ bindings }, root, node.start); memberAssignments.push({ at: node.start, @@ -500,13 +528,9 @@ const parseSourceModel = (file: string, content: string): SourceModel => { } } } else if (node.id.type === 'ObjectPattern' && node.init !== null) { - for (const property of node.id.properties) { - const propertyName = - property.type === 'Property' && property.key.type === 'Identifier' - ? property.key.name - : property.type === 'Property' && property.key.type !== 'PrivateIdentifier' - ? staticString(property.key) - : undefined; + const source = node.init; + const addDestructuredProperty = (property: (typeof node.id.properties)[number]): void => { + const propertyName = destructuredPropertyName(property); if ( property.type === 'Property' && propertyName !== undefined && @@ -514,7 +538,7 @@ const parseSourceModel = (file: string, content: string): SourceModel => { ) { const binding = { member: propertyName, - source: node.init, + source, }; addBinding(property.value.name, { ...binding, kind: 'destructured', scope }); if (scope === parsed.program) { @@ -524,13 +548,16 @@ const parseSourceModel = (file: string, content: string): SourceModel => { addBinding(property.argument.name, { kind: 'namespace-rest', scope, - source: node.init, + source, }); } else if (property.type === 'Property') { addShadowPattern(property.value, scope); } else { addShadowPattern(property.argument, scope); } + }; + for (const property of node.id.properties) { + addDestructuredProperty(property); } } else { addShadowPattern(node.id, scope); @@ -538,57 +565,73 @@ const parseSourceModel = (file: string, content: string): SourceModel => { }, }).visit(parsed.program); - const imports = new Map(); - for (const declaration of parsed.module.staticImports) { - for (const entry of declaration.entries) { - const local = entry.localName.value; - let kind: ImportBinding['kind'] = 'named'; - if (entry.importName.kind === ImportNameKind.NamespaceObject) { - kind = 'namespace'; - } else if (entry.importName.kind === ImportNameKind.Default) { - kind = 'default'; + const collectImports = () => { + const imports = new Map(); + for (const declaration of parsed.module.staticImports) { + for (const entry of declaration.entries) { + const local = entry.localName.value; + let kind: ImportBinding['kind'] = 'named'; + if (entry.importName.kind === ImportNameKind.NamespaceObject) { + kind = 'namespace'; + } else if (entry.importName.kind === ImportNameKind.Default) { + kind = 'default'; + } + imports.set(local, { + imported: entry.importName.name ?? (kind === 'default' ? 'default' : '*'), + kind, + specifier: declaration.moduleRequest.value, + }); } - imports.set(local, { - imported: entry.importName.name ?? (kind === 'default' ? 'default' : '*'), - kind, - specifier: declaration.moduleRequest.value, - }); } - } - const exports = new Map(); - const starExports: ExportBinding[] = []; - for (const declaration of parsed.module.staticExports) { - for (const entry of declaration.entries) { - const exportedName = - entry.exportName.kind === ExportExportNameKind.Default - ? 'default' - : (entry.exportName.name ?? undefined); - const specifier = entry.moduleRequest?.value; - if (entry.importName.kind === ExportImportNameKind.AllButDefault && specifier !== undefined) { - starExports.push({ kind: 'star', specifier }); - } else if ( - exportedName !== undefined && - entry.importName.kind === ExportImportNameKind.All && - specifier !== undefined - ) { - addExport(exports, exportedName, { kind: 'namespace', specifier }); - } else if (exportedName !== undefined && specifier !== undefined) { - addExport(exports, exportedName, { - imported: entry.importName.name ?? exportedName, - kind: 'reexport', - specifier, - }); - } else if (exportedName !== undefined && entry.localName.name !== null) { - addExport(exports, exportedName, { kind: 'local', local: entry.localName.name }); - } else if (exportedName !== undefined) { - addExport(exports, exportedName, { - kind: 'expression', - span: { end: entry.end, start: entry.start }, - }); + return imports; + }; + const imports = collectImports(); + const collectExports = () => { + const exports = new Map(); + const starExports: ExportBinding[] = []; + for (const declaration of parsed.module.staticExports) { + const collectExportEntry = (entry: (typeof declaration.entries)[number]): void => { + const exportName = (): string | undefined => + entry.exportName.kind === ExportExportNameKind.Default + ? 'default' + : (entry.exportName.name ?? undefined); + const exportedName = exportName(); + const specifier = entry.moduleRequest?.value; + if ( + entry.importName.kind === ExportImportNameKind.AllButDefault && + specifier !== undefined + ) { + starExports.push({ kind: 'star', specifier }); + return; + } + if (exportedName === undefined) { + return; + } + if (entry.importName.kind === ExportImportNameKind.All && specifier !== undefined) { + addExport(exports, exportedName, { kind: 'namespace', specifier }); + } else if (specifier !== undefined) { + addExport(exports, exportedName, { + imported: entry.importName.name ?? exportedName, + kind: 'reexport', + specifier, + }); + } else if (entry.localName.name === null) { + addExport(exports, exportedName, { + kind: 'expression', + span: { end: entry.end, start: entry.start }, + }); + } else { + addExport(exports, exportedName, { kind: 'local', local: entry.localName.name }); + } + }; + for (const entry of declaration.entries) { + collectExportEntry(entry); } } - } + return { exports, starExports }; + }; + const { exports, starExports } = collectExports(); return { bindings, calls, @@ -812,7 +855,6 @@ const within = (inner: Span, outer: Span): boolean => const isForbiddenMember = (member: string, forbidRecord: boolean): boolean => forbiddenSchemaMembers.has(member) || (forbidRecord && member === 'Record'); -// eslint-disable-next-line complexity -- Namespace provenance intentionally handles lexical aliases, destructuring, imports, and local shadows together. const schemaNamespacePath = ( context: ApiContractSourceContext, file: string, @@ -834,72 +876,88 @@ const schemaNamespacePath = ( return false; } const scoped = scopedBindingAt(model, root, position); - if (scoped?.kind === 'expression') { - const declarationPath = memberPath(scoped.expression); - if (declarationPath !== undefined) { - return schemaNamespacePath( - context, - file, - [...declarationPath, ...rest], - scoped.expression.start, - visited, - ); + const resolveScopedSchema = () => { + if (scoped === undefined) { + return null; } - const unwrapped = unwrapExpression(scoped.expression); - return ( - rest.length === 0 && - unwrapped.type === 'ObjectExpression' && - unwrapped.properties.some( - (property) => - property.type === 'SpreadElement' && - memberPath(property.argument) !== undefined && - schemaNamespacePath( + if (scoped.kind === 'expression') { + const resolveExpressionSchema = () => { + const declarationPath = memberPath(scoped.expression); + if (declarationPath !== undefined) { + return schemaNamespacePath( context, file, - memberPath(property.argument) ?? [], - property.argument.start, + [...declarationPath, ...rest], + scoped.expression.start, visited, - ), - ) - ); - } - if (scoped?.kind === 'destructured') { - const sourcePath = memberPath(scoped.source); - return ( - sourcePath !== undefined && - schemaNamespacePath( - context, - file, - [...sourcePath, scoped.member, ...rest], - scoped.source.start, - visited, - ) - ); - } - if (scoped?.kind === 'namespace-rest') { - const sourcePath = memberPath(scoped.source); - return ( - sourcePath !== undefined && - schemaNamespacePath(context, file, [...sourcePath, ...rest], scoped.source.start, visited) - ); - } - if (scoped?.kind === 'shadow') { - return false; - } - const binding = model.imports.get(root); - if (binding === undefined) { - return root === 'Schema' && rest.length === 0; - } - if (!schemaProviderSpecifiers.has(binding.specifier)) { - return false; - } - if (binding.kind === 'named' && binding.imported === 'Schema') { - return rest.length === 0; - } - if (binding.kind === 'namespace' && binding.specifier === 'effect/Schema') { - return rest.length === 0; - } - return binding.kind === 'namespace' && rest.length === 1 && rest[0] === 'Schema'; + ); + } + const unwrapped = unwrapExpression(scoped.expression); + return ( + rest.length === 0 && + unwrapped.type === 'ObjectExpression' && + unwrapped.properties.some( + (property) => + property.type === 'SpreadElement' && + memberPath(property.argument) !== undefined && + schemaNamespacePath( + context, + file, + memberPath(property.argument) ?? [], + property.argument.start, + visited, + ), + ) + ); + }; + return resolveExpressionSchema(); + } + if (scoped.kind === 'destructured') { + const sourcePath = memberPath(scoped.source); + return ( + sourcePath !== undefined && + schemaNamespacePath( + context, + file, + [...sourcePath, scoped.member, ...rest], + scoped.source.start, + visited, + ) + ); + } + if (scoped.kind === 'namespace-rest') { + const sourcePath = memberPath(scoped.source); + return ( + sourcePath !== undefined && + schemaNamespacePath(context, file, [...sourcePath, ...rest], scoped.source.start, visited) + ); + } + if (scoped.kind === 'shadow') { + return false; + } + return null; + }; + const scopedResult = resolveScopedSchema(); + if (scopedResult !== null) { + return scopedResult; + } + const resolveImportedSchema = (): boolean => { + const binding = model.imports.get(root); + if (binding === undefined) { + return root === 'Schema' && rest.length === 0; + } + if (!schemaProviderSpecifiers.has(binding.specifier)) { + return false; + } + if (binding.kind === 'named' && binding.imported === 'Schema') { + return rest.length === 0; + } + if (binding.kind === 'namespace' && binding.specifier === 'effect/Schema') { + return rest.length === 0; + } + return binding.kind === 'namespace' && rest.length === 1 && rest[0] === 'Schema'; + }; + return resolveImportedSchema(); }; const moduleExportsName = ( @@ -1073,32 +1131,42 @@ function localNamespaceTargets( return []; } const scoped = scopedBindingAt(model, name, position); - if (scoped?.kind === 'expression') { - const pathParts = memberPath(scoped.expression); - return pathParts === undefined - ? [] - : namespacePathTargets(context, file, pathParts, scoped.expression.start, visited); - } - if (scoped?.kind === 'destructured') { - const pathParts = memberPath(scoped.source); - return pathParts === undefined - ? [] - : namespacePathTargets( - context, - file, - [...pathParts, scoped.member], - scoped.source.start, - visited, - ); - } - if (scoped?.kind === 'namespace-rest') { - const pathParts = memberPath(scoped.source); - return pathParts === undefined - ? [] - : namespacePathTargets(context, file, pathParts, scoped.source.start, visited); - } - if (scoped?.kind === 'shadow') { - return []; + const resolveScopedNamespace = () => { + if (scoped === undefined) { + return null; + } + if (scoped.kind === 'expression') { + const pathParts = memberPath(scoped.expression); + return pathParts === undefined + ? [] + : namespacePathTargets(context, file, pathParts, scoped.expression.start, visited); + } + if (scoped.kind === 'destructured') { + const pathParts = memberPath(scoped.source); + return pathParts === undefined + ? [] + : namespacePathTargets( + context, + file, + [...pathParts, scoped.member], + scoped.source.start, + visited, + ); + } + if (scoped.kind === 'namespace-rest') { + const pathParts = memberPath(scoped.source); + return pathParts === undefined + ? [] + : namespacePathTargets(context, file, pathParts, scoped.source.start, visited); + } + if (scoped.kind === 'shadow') { + return []; + } + return null; + }; + const scopedTargets = resolveScopedNamespace(); + if (scopedTargets !== null) { + return scopedTargets; } const binding = model.imports.get(name); if (binding === undefined) { @@ -1156,7 +1224,7 @@ function exportedExpressionIsForbidden( } visited.add(key); const model = sourceModel(context, file); - for (const binding of model?.exports.get(exportedName) ?? []) { + const bindingMatches = (binding: ExportBinding): boolean => { if ( binding.kind === 'expression' && expressionIsForbidden(context, file, binding.span, forbidRecord, visited) @@ -1185,6 +1253,10 @@ function exportedExpressionIsForbidden( return true; } } + return false; + }; + if ((model?.exports.get(exportedName) ?? []).some(bindingMatches)) { + return true; } for (const target of unambiguousStarTargets(context, file, exportedName)) { if (exportedExpressionIsForbidden(context, target, exportedName, forbidRecord, visited)) { @@ -1343,7 +1415,7 @@ function exportedBindingResolvesSymbol( } visited.add(key); const model = sourceModel(context, file); - for (const binding of model?.exports.get(exportedName) ?? []) { + const bindingMatches = (binding: ExportBinding): boolean => { if ( binding.kind === 'local' && localBindingResolvesSymbol( @@ -1375,6 +1447,10 @@ function exportedBindingResolvesSymbol( ) { return true; } + return false; + }; + if ((model?.exports.get(exportedName) ?? []).some(bindingMatches)) { + return true; } for (const target of unambiguousStarTargets(context, file, exportedName)) { if (exportedBindingResolvesSymbol(context, target, exportedName, symbols, visited)) { @@ -1488,7 +1564,7 @@ const pathResolvesSymbol = ( return false; } const model = sourceModel(context, file); - if ( + const assignedPathResolvesSymbol = (): boolean => model?.memberAssignments.some( (assignment) => assignment.at <= position && @@ -1497,26 +1573,38 @@ const pathResolvesSymbol = ( assignment.path.length === pathParts.length && assignment.path.every((part, index) => part === pathParts[index]) && expressionResolvesSymbol(context, file, assignment.expression, symbols, visited), - ) === true - ) { + ) === true; + if (assignedPathResolvesSymbol()) { return true; } - const scoped = model === undefined ? undefined : scopedBindingAt(model, root, position); - if (scoped?.kind === 'expression') { - const propertyValue = objectPathExpression(scoped.expression, pathParts.slice(1)); + const localPathResolvesSymbol = (): boolean => { + const scoped = model === undefined ? undefined : scopedBindingAt(model, root, position); + const assignedObjectResolvesSymbol = (): boolean => { + if (scoped?.kind === 'expression') { + const propertyValue = objectPathExpression(scoped.expression, pathParts.slice(1)); + if ( + propertyValue !== undefined && + expressionResolvesSymbol(context, file, propertyValue, symbols, visited) + ) { + return true; + } + } + return false; + }; + if (assignedObjectResolvesSymbol()) { + return true; + } + const directImport = scoped === undefined ? model?.imports.get(root) : undefined; if ( - propertyValue !== undefined && - expressionResolvesSymbol(context, file, propertyValue, symbols, visited) + pathParts.length === 2 && + directImport?.kind === 'namespace' && + specifierProvidesSymbol(directImport.specifier, symbol, symbols) ) { return true; } - } - const directImport = scoped === undefined ? model?.imports.get(root) : undefined; - if ( - pathParts.length === 2 && - directImport?.kind === 'namespace' && - specifierProvidesSymbol(directImport.specifier, symbol, symbols) - ) { + return false; + }; + if (localPathResolvesSymbol()) { return true; } return namespacePathTargets(context, file, pathParts.slice(0, -1), position, new Set()).some( @@ -1609,7 +1697,7 @@ function exportedExpressionResolvesEndpointFactory( } visited.add(key); const model = sourceModel(context, file); - for (const binding of model?.exports.get(exportedName) ?? []) { + const bindingMatches = (binding: ExportBinding): boolean => { if ( binding.kind === 'expression' && expressionResolvesEndpointFactory(context, file, binding.span, isBuilder, visited) @@ -1643,6 +1731,10 @@ function exportedExpressionResolvesEndpointFactory( ) { return true; } + return false; + }; + if ((model?.exports.get(exportedName) ?? []).some(bindingMatches)) { + return true; } return unambiguousStarTargets(context, file, exportedName).some((target) => exportedExpressionResolvesEndpointFactory(context, target, exportedName, isBuilder, visited), @@ -1739,40 +1831,47 @@ function expressionResolvesEndpointFactory( const member = model?.members.find( (candidate) => candidate.start === expression.start && candidate.end === expression.end, ); - if (member !== undefined) { - const method = memberNameAt(context, file, member); - if ( - method !== undefined && - (isBuilder ? method === 'make' : endpointMethods.has(method)) && - expressionResolvesSymbol( - context, - file, - member.object, - new Set(['HttpApiEndpoint']), - new Set(), - ) - ) { - return true; - } - const pathParts = memberPathAt(context, file, member, member.start); - const exportedName = pathParts?.at(-1); - if (pathParts !== undefined && exportedName !== undefined) { - return namespacePathTargets( - context, - file, - pathParts.slice(0, -1), - member.start, - new Set(), - ).some((target) => - exportedExpressionResolvesEndpointFactory( + const resolveMemberFactory = () => { + if (member !== undefined) { + const method = memberNameAt(context, file, member); + if ( + method !== undefined && + (isBuilder ? method === 'make' : endpointMethods.has(method)) && + expressionResolvesSymbol( context, - target, - exportedName, - isBuilder, - visited, - ), - ); + file, + member.object, + new Set(['HttpApiEndpoint']), + new Set(), + ) + ) { + return true; + } + const pathParts = memberPathAt(context, file, member, member.start); + const exportedName = pathParts?.at(-1); + if (pathParts !== undefined && exportedName !== undefined) { + return namespacePathTargets( + context, + file, + pathParts.slice(0, -1), + member.start, + new Set(), + ).some((target) => + exportedExpressionResolvesEndpointFactory( + context, + target, + exportedName, + isBuilder, + visited, + ), + ); + } } + return null; + }; + const memberResult = resolveMemberFactory(); + if (memberResult !== null) { + return memberResult; } const identifier = model?.identifiers.find( (candidate) => candidate.start === expression.start && candidate.end === expression.end, @@ -1809,37 +1908,47 @@ const isEndpointCall = ( if (expressionResolvesEndpointFactory(context, file, callee, false, new Set())) { return true; } - if (callee.type === 'Identifier') { - const model = sourceModel(context, file); - const scoped = - model === undefined ? undefined : scopedBindingAt(model, callee.name, callee.start); - if (scoped?.kind === 'destructured' && endpointMethods.has(scoped.member)) { - return expressionResolvesSymbol( - context, - file, - scoped.source, - new Set(['HttpApiEndpoint']), - new Set(), - ); - } - if (scoped?.kind === 'expression') { - const aliased = unwrapExpression(scoped.expression); - if (aliased.type === 'MemberExpression') { - const method = memberNameAt(context, file, aliased); - return ( - method !== undefined && - endpointMethods.has(method) && - expressionResolvesSymbol( - context, - file, - aliased.object, - new Set(['HttpApiEndpoint']), - new Set(), - ) + const resolveIdentifierCall = () => { + if (callee.type === 'Identifier') { + const model = sourceModel(context, file); + const scoped = + model === undefined ? undefined : scopedBindingAt(model, callee.name, callee.start); + if (scoped?.kind === 'destructured' && endpointMethods.has(scoped.member)) { + return expressionResolvesSymbol( + context, + file, + scoped.source, + new Set(['HttpApiEndpoint']), + new Set(), ); } + const resolveAliasedCall = (): boolean => { + if (scoped?.kind === 'expression') { + const aliased = unwrapExpression(scoped.expression); + if (aliased.type === 'MemberExpression') { + const method = memberNameAt(context, file, aliased); + return ( + method !== undefined && + endpointMethods.has(method) && + expressionResolvesSymbol( + context, + file, + aliased.object, + new Set(['HttpApiEndpoint']), + new Set(), + ) + ); + } + } + return false; + }; + return resolveAliasedCall(); } - return false; + return null; + }; + const identifierResult = resolveIdentifierCall(); + if (identifierResult !== null) { + return identifierResult; } if (callee.type !== 'MemberExpression') { return false; diff --git a/app/scripts/ultramodern-api-boundary-rules.mts b/app/scripts/ultramodern-api-boundary-rules.mts index e507f9d9e..c064b68ac 100644 --- a/app/scripts/ultramodern-api-boundary-rules.mts +++ b/app/scripts/ultramodern-api-boundary-rules.mts @@ -1,9 +1,8 @@ +/// + import path from 'node:path'; -export { - type ApiContractSourceContext, - unconstrainedHttpApiContractSchemaViolation, -} from './typescript-api-contract-boundary.mts'; +export { unconstrainedHttpApiContractSchemaViolation } from './typescript-api-contract-boundary.mts'; const normalize = (filePath: string): string => filePath.split(path.sep).join('/'); diff --git a/app/scripts/ultramodern-performance-readiness.mts b/app/scripts/ultramodern-performance-readiness.mts index b3e718309..a89db14a8 100644 --- a/app/scripts/ultramodern-performance-readiness.mts +++ b/app/scripts/ultramodern-performance-readiness.mts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { NodeServices } from '@effect/platform-node'; -import { Config, Console, Effect, Exit, Option, Path, Schema, Stdio } from 'effect'; -import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; +import { Effect, Schema } from 'effect'; +import { runUltramodernScript, ultramodernExitCode } from './shared/ultramodern-command.mts'; class PerformanceReadinessError extends Schema.TaggedError()( 'PerformanceReadinessError', @@ -11,65 +11,12 @@ class PerformanceReadinessError extends Schema.TaggedError new PerformanceReadinessError({ reason }); -const program = Effect.gen(function* ultramodernPerformanceReadiness() { - const path = yield* Path.Path; - const stdio = yield* Stdio.Stdio; - const processSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const moduleDirectory = yield* path - .fromFileUrl(new URL('.', import.meta.url)) - .pipe(Effect.mapError(() => failure('Unable to resolve the performance-readiness directory'))); - const defaultWorkspaceRoot = path.resolve(moduleDirectory, '..'); - const workspaceRoot = yield* Config.string('ULTRAMODERN_WORKSPACE_ROOT').pipe( - Config.withDefault(defaultWorkspaceRoot), - Effect.mapError(() => failure('ULTRAMODERN_WORKSPACE_ROOT is invalid')), - ); - const createBin = yield* Config.string('ULTRAMODERN_CREATE_BIN').pipe( - Config.option, - Effect.map(Option.filter((value) => value.length > 0)), - Effect.mapError(() => failure('ULTRAMODERN_CREATE_BIN is invalid')), - ); - const forwardedArgs = yield* stdio.args; - const ultramodernArgs = ['ultramodern', 'performance-readiness', ...forwardedArgs]; - const executable = Option.isSome(createBin) ? 'node' : 'modern-js-create'; - const executableArgs = Option.isSome(createBin) - ? [createBin.value, ...ultramodernArgs] - : ultramodernArgs; - const launchTarget = Option.isSome(createBin) - ? `node with ULTRAMODERN_CREATE_BIN=${createBin.value}` - : 'modern-js-create from PATH'; - - return Number( - yield* processSpawner - .exitCode( - ChildProcess.make(executable, executableArgs, { - env: { ULTRAMODERN_WORKSPACE_ROOT: workspaceRoot }, - extendEnv: true, - shell: Option.isNone(createBin) && path.sep === '\\', - stderr: 'inherit', - stdin: 'inherit', - stdout: 'inherit', - }), - ) - .pipe( - Effect.mapError((error) => - failure( - `Failed to launch ${launchTarget} for UltraModern command "${ultramodernArgs - .slice(1) - .join(' ')}": ${String(error)}`, - ), - ), - ), - ); -}); - const exit = await Effect.runPromiseExit( - program.pipe( - Effect.tapError((error) => Console.error(error.reason)), - Effect.provide(NodeServices.layer), - Effect.scoped, - ), + runUltramodernScript({ + command: 'performance-readiness', + directoryFailure: 'Unable to resolve the performance-readiness directory', + failure, + moduleUrl: import.meta.url, + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); -process.exitCode = Exit.match(exit, { - onFailure: () => 1, - onSuccess: (status) => status, -}); +process.exitCode = ultramodernExitCode(exit); diff --git a/app/scripts/ultramodern-typecheck.mts b/app/scripts/ultramodern-typecheck.mts index fb0b6a114..6eb8ab144 100644 --- a/app/scripts/ultramodern-typecheck.mts +++ b/app/scripts/ultramodern-typecheck.mts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { NodeServices } from '@effect/platform-node'; -import { Config, Console, Effect, Exit, Option, Path, Schema, Stdio } from 'effect'; -import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; +import { Effect, Schema } from 'effect'; +import { runUltramodernScript, ultramodernExitCode } from './shared/ultramodern-command.mts'; class UltramodernTypecheckError extends Schema.TaggedError()( 'UltramodernTypecheckError', @@ -11,65 +11,12 @@ class UltramodernTypecheckError extends Schema.TaggedError new UltramodernTypecheckError({ reason }); -const program = Effect.gen(function* ultramodernTypecheck() { - const path = yield* Path.Path; - const stdio = yield* Stdio.Stdio; - const processSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const moduleDirectory = yield* path - .fromFileUrl(new URL('.', import.meta.url)) - .pipe(Effect.mapError(() => failure('Unable to resolve the typecheck wrapper directory'))); - const defaultWorkspaceRoot = path.resolve(moduleDirectory, '..'); - const workspaceRoot = yield* Config.string('ULTRAMODERN_WORKSPACE_ROOT').pipe( - Config.withDefault(defaultWorkspaceRoot), - Effect.mapError(() => failure('ULTRAMODERN_WORKSPACE_ROOT is invalid')), - ); - const createBin = yield* Config.string('ULTRAMODERN_CREATE_BIN').pipe( - Config.option, - Effect.map(Option.filter((value) => value.length > 0)), - Effect.mapError(() => failure('ULTRAMODERN_CREATE_BIN is invalid')), - ); - const forwardedArgs = yield* stdio.args; - const ultramodernArgs = ['ultramodern', 'typecheck', ...forwardedArgs]; - const executable = Option.isSome(createBin) ? 'node' : 'modern-js-create'; - const executableArgs = Option.isSome(createBin) - ? [createBin.value, ...ultramodernArgs] - : ultramodernArgs; - const launchTarget = Option.isSome(createBin) - ? `node with ULTRAMODERN_CREATE_BIN=${createBin.value}` - : 'modern-js-create from PATH'; - - return Number( - yield* processSpawner - .exitCode( - ChildProcess.make(executable, executableArgs, { - env: { ULTRAMODERN_WORKSPACE_ROOT: workspaceRoot }, - extendEnv: true, - shell: Option.isNone(createBin) && path.sep === '\\', - stderr: 'inherit', - stdin: 'inherit', - stdout: 'inherit', - }), - ) - .pipe( - Effect.mapError((error) => - failure( - `Failed to launch ${launchTarget} for UltraModern command "${ultramodernArgs - .slice(1) - .join(' ')}": ${String(error)}`, - ), - ), - ), - ); -}); - const exit = await Effect.runPromiseExit( - program.pipe( - Effect.tapError((error) => Console.error(error.reason)), - Effect.provide(NodeServices.layer), - Effect.scoped, - ), + runUltramodernScript({ + command: 'typecheck', + directoryFailure: 'Unable to resolve the typecheck wrapper directory', + failure, + moduleUrl: import.meta.url, + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); -process.exitCode = Exit.match(exit, { - onFailure: () => 1, - onSuccess: (status) => status, -}); +process.exitCode = ultramodernExitCode(exit); diff --git a/app/scripts/validate-ultramodern-workspace.mts b/app/scripts/validate-ultramodern-workspace.mts index 8b9ca2c57..0f6ef47c6 100644 --- a/app/scripts/validate-ultramodern-workspace.mts +++ b/app/scripts/validate-ultramodern-workspace.mts @@ -1,3 +1,7 @@ +import { + hasUltramodernDispatch, + hasUltramodernSkillsDispatch, +} from './shared/ultramodern-wrapper-source.mts'; import { ok as assertCondition } from 'node:assert'; import type { execFileSync as nodeExecFileSync } from 'node:child_process'; import crypto from 'node:crypto'; @@ -287,6 +291,235 @@ const jsonEquivalent = Schema.toEquivalence(Schema.Unknown); const IdentifierEntrySchema = Schema.Struct({ id: Schema.String }); type IdentifierEntry = typeof IdentifierEntrySchema.Type; const isIdentifierEntry = Schema.is(IdentifierEntrySchema); +const createQualityGates = () => ({ + assets: { + cacheControlRequiredForCss: true, + cssPreloadRequired: true, + cssResponseRequired: true, + sourcemapsPubliclyReferenced: false, + }, + budgets: { + cssAssetMaxBytes: 750_000, + localeJsonMaxBytes: 100_000, + mfManifestMaxBytes: 500_000, + sitemapXmlMaxBytes: 500_000, + ssrHtmlMaxBytes: 250_000, + }, + csp: { + decision: SHARED_VALIDATOR_STRING_109, + finalMode: SHARED_VALIDATOR_STRING_110, + }, + indexing: { + previewNoindex: true, + productionPublicRoutesIndexable: true, + }, + publicRoutes: { + requireRobotsSitemapConsistency: true, + requireSitemapWhenPresent: true, + requireWebManifestWhenPresent: true, + }, + statusCodes: { + notFoundRoute: SHARED_VALIDATOR_STRING_030, + unknownRouteStatus: 404, + }, +}); +const createVerticalNodeExecution = () => ({ + adapterVersion: SHARED_VALIDATOR_STRING_054, + containerEntry: SHARED_VALIDATOR_STRING_072, + expected: { + buildMarker: SHARED_VALIDATOR_STRING_038, + unitId: SHARED_VALIDATOR_STRING_046, + }, + expose: SHARED_VALIDATOR_STRING_004, + kind: SHARED_VALIDATOR_STRING_088, + manifestEnv: SHARED_VALIDATOR_STRING_153, + manifestUrl: SHARED_VALIDATOR_STRING_071, + remoteName: SHARED_VALIDATOR_STRING_160, + remoteType: SHARED_VALIDATOR_STRING_063, + runtimePackage: SHARED_VALIDATOR_STRING_026, +}); +const createVerticalCloudflareExecution = () => ({ + kind: SHARED_VALIDATOR_STRING_058, + publicUrlEnv: SHARED_VALIDATOR_STRING_147, + ssr: { + assetsBinding: 'ASSETS', + effectBffBundle: SHARED_VALIDATOR_STRING_013, + routeManifest: SHARED_VALIDATOR_STRING_012, + ssrBundle: SHARED_VALIDATOR_STRING_014, + workerEntry: SHARED_VALIDATOR_STRING_010, + workerManifest: SHARED_VALIDATOR_STRING_011, + }, + workerDispatch: { + dispatchNamespaceEnv: SHARED_VALIDATOR_STRING_154, + dispatchWorkerNameEnv: SHARED_VALIDATOR_STRING_158, + preferred: SHARED_VALIDATOR_STRING_126, + requestInterface: 'fetch', + serviceBinding: SHARED_VALIDATOR_STRING_156, + serviceBindingEnv: SHARED_VALIDATOR_STRING_157, + }, + workerName: SHARED_VALIDATOR_STRING_044, + zephyr: { + applicationUidEnv: SHARED_VALIDATOR_STRING_170, + integration: SHARED_VALIDATOR_STRING_076, + runtime: SHARED_VALIDATOR_STRING_139, + snapshotIdEnv: SHARED_VALIDATOR_STRING_171, + versionIdEnv: SHARED_VALIDATOR_STRING_172, + }, +}); + +const createCloudflareSecurityContract = () => ({ + contentSecurityPolicy: { + directives: { + 'base-uri': ["'self'"], + 'connect-src': ["'self'", 'https:', 'http:', 'wss:', 'ws:'], + 'default-src': ["'self'"], + 'font-src': ["'self'", 'data:', 'https:', 'http:'], + 'form-action': ["'self'"], + 'frame-ancestors': ["'self'"], + 'img-src': ["'self'", 'data:', 'blob:', 'https:', 'http:'], + 'manifest-src': ["'self'", 'https:', 'http:'], + 'object-src': ["'none'"], + 'script-src': [ + "'self'", + SHARED_VALIDATOR_STRING_016, + SHARED_VALIDATOR_STRING_015, + 'https:', + 'http:', + 'blob:', + ], + 'style-src': ["'self'", SHARED_VALIDATOR_STRING_016, 'https:', 'http:'], + 'worker-src': ["'self'", 'blob:'], + }, + mode: SHARED_VALIDATOR_STRING_107, + reason: SHARED_VALIDATOR_STRING_108, + }, + enabled: true, + headers: { + contentTypeOptions: 'nosniff', + permissionsPolicy: SHARED_VALIDATOR_STRING_055, + referrerPolicy: SHARED_VALIDATOR_STRING_140, + }, + noindex: { + localhost: true, + previewHostnames: [], + workersDev: true, + }, +}); + +const createVerticalExecutionSurfaces = () => ({ + cloudflare: createVerticalCloudflareExecution(), + node: createVerticalNodeExecution(), +}); + +const createShellCloudflareContract = () => ({ + assetsBinding: 'ASSETS', + compatibilityDate: SHARED_VALIDATOR_STRING_036, + compatibilityFlags: [SHARED_VALIDATOR_STRING_089, SHARED_VALIDATOR_STRING_070], + evidence: { + proofScript: SHARED_VALIDATOR_STRING_119, + reportDefault: SHARED_VALIDATOR_STRING_008, + }, + publicUrlEnv: SHARED_VALIDATOR_STRING_148, + qualityGates: createQualityGates(), + routes: { + locale: '/locales/en/shell.json', + mfManifest: SHARED_VALIDATOR_STRING_031, + ssr: '/en', + }, + security: createCloudflareSecurityContract(), + target: SHARED_VALIDATOR_STRING_056, + workerName: 'app-shell-super-app', +}); + +const createVerticalCloudflareContract = () => ({ + assetsBinding: 'ASSETS', + compatibilityDate: SHARED_VALIDATOR_STRING_036, + compatibilityFlags: [SHARED_VALIDATOR_STRING_089, SHARED_VALIDATOR_STRING_070], + evidence: { + proofScript: SHARED_VALIDATOR_STRING_119, + reportDefault: SHARED_VALIDATOR_STRING_008, + }, + jsonSmokeChecks: [ + { + expect: { + 'checks.api': 'ready', + 'checks.moduleFederation': 'ready', + 'checks.ssr': 'ready', + status: 'ready', + }, + id: 'party-registry-readiness-smoke', + route: SHARED_VALIDATOR_STRING_034, + }, + ], + publicUrlEnv: SHARED_VALIDATOR_STRING_147, + qualityGates: createQualityGates(), + routes: { + apiReadiness: SHARED_VALIDATOR_STRING_034, + mfManifest: SHARED_VALIDATOR_STRING_031, + }, + security: createCloudflareSecurityContract(), + target: SHARED_VALIDATOR_STRING_056, + workerName: SHARED_VALIDATOR_STRING_044, +}); + +const createVerticalBackendFederationContract = () => ({ + cache: { + cloudflareSnapshot: 'immutable', + nodeManifest: 'no-store', + nodeUnpinnedContainer: 'revalidate', + nodeVersionedContainer: 'immutable', + }, + compatibility: { + contractVersion: SHARED_VALIDATOR_STRING_079, + effectVersion: SHARED_VALIDATOR_STRING_039, + moduleFederationVersion: '2.8.0', + packageName: SHARED_VALIDATOR_STRING_018, + }, + deliveryUnit: { + buildMarker: SHARED_VALIDATOR_STRING_038, + kind: SHARED_VALIDATOR_STRING_077, + packageName: SHARED_VALIDATOR_STRING_018, + schemaVersion: 1, + sourceRevision: 'workspace', + unitId: SHARED_VALIDATOR_STRING_046, + version: '0.1.0', + }, + executionSurfaces: createVerticalExecutionSurfaces(), + exposes: { + './effect-api': { + client: SHARED_VALIDATOR_STRING_167, + contract: SHARED_VALIDATOR_STRING_166, + openapi: SHARED_VALIDATOR_STRING_033, + readiness: SHARED_VALIDATOR_STRING_034, + runtime: SHARED_VALIDATOR_STRING_162, + }, + }, + fallback: { + failureEvent: 'modernjs:microvertical-server-fallback', + strategy: 'typed-effect-error', + timeoutMs: 1500, + }, + name: SHARED_VALIDATOR_STRING_160, + role: SHARED_VALIDATOR_STRING_078, + runtimeFramework: 'effect', + strictEffectApproach: true, + versionBoundary: { + api: { + buildMarker: 'verticals/party-registry/shared/ultramodern-build.ts', + publicUrlEnv: SHARED_VALIDATOR_STRING_147, + readiness: SHARED_VALIDATOR_STRING_034, + }, + identityRoot: SHARED_VALIDATOR_STRING_065, + invariant: SHARED_VALIDATOR_STRING_168, + packageName: SHARED_VALIDATOR_STRING_018, + ui: { + buildMarker: 'verticals/party-registry/src/routes/ultramodern-route-metadata.ts', + manifestEnv: SHARED_VALIDATOR_STRING_155, + manifestUrl: SHARED_VALIDATOR_STRING_073, + }, + }, +}); + const workspaceValidationContractDefinition = { ciEvidenceScripts: { 'action:test:integration': 'pnpm --filter @app/core-runtime action:test:integration', @@ -297,47 +530,10 @@ const workspaceValidationContractDefinition = { 'node --test scripts/scaffolding/tests/module-contract-generator.test.mts scripts/scaffolding/tests/resource-generator.test.mts scripts/scaffolding/tests/retire-contribution.test.mts scripts/scaffolding/tests/scaffold-generators.test.mts', 'test:integration': 'pnpm -r --if-present run test:integration', 'test:scripts': - 'node --test scripts/local-environment-values.test.mts scripts/tests/audit-database-trust-boundaries.test.mts scripts/tests/authorization-rollout-contract.test.mts scripts/tests/check-authorization-readiness.test.mts scripts/tests/database-access-boundaries.test.mts scripts/tests/initialize-local-development.test.mts scripts/tests/locki-feature.test.mts scripts/tests/migrate-contacts-authorization.test.mts scripts/tests/module-entrypoint-boundaries.test.mts scripts/tests/plan-deployment-impact.test.mts scripts/tests/protected-entrypoint-inventory.test.mts scripts/tests/provision-current-action-authorization.test.mts scripts/tests/report-fail-closed-authorization-impact.test.mts scripts/tests/api-only-tooling.test.mts scripts/tests/root-environment.test.mts scripts/tests/typecheck-project-references.test.mts', + 'node --test scripts/local-environment-values.test.mts scripts/tests/audit-database-trust-boundaries.test.mts scripts/tests/authorization-rollout-contract.test.mts scripts/tests/check-authorization-readiness.test.mts scripts/tests/database-access-boundaries.test.mts scripts/tests/initialize-local-development.test.mts scripts/tests/locki-feature.test.mts scripts/tests/migrate-contacts-authorization.test.mts scripts/tests/module-entrypoint-boundaries.test.mts scripts/tests/plan-deployment-impact.test.mts scripts/tests/protected-entrypoint-inventory.test.mts scripts/tests/provision-current-action-authorization.test.mts scripts/tests/report-fail-closed-authorization-impact.test.mts scripts/tests/api-only-tooling.test.mts scripts/tests/root-environment.test.mts scripts/tests/typecheck-project-references.test.mts scripts/tests/ultramodern-command.test.mts', 'test:unit': 'pnpm -r --if-present run test:unit && pnpm -r --if-present run test:component', }, - cloudflareSecurity: { - contentSecurityPolicy: { - directives: { - 'base-uri': ["'self'"], - 'connect-src': ["'self'", 'https:', 'http:', 'wss:', 'ws:'], - 'default-src': ["'self'"], - 'font-src': ["'self'", 'data:', 'https:', 'http:'], - 'form-action': ["'self'"], - 'frame-ancestors': ["'self'"], - 'img-src': ["'self'", 'data:', 'blob:', 'https:', 'http:'], - 'manifest-src': ["'self'", 'https:', 'http:'], - 'object-src': ["'none'"], - 'script-src': [ - "'self'", - SHARED_VALIDATOR_STRING_016, - SHARED_VALIDATOR_STRING_015, - 'https:', - 'http:', - 'blob:', - ], - 'style-src': ["'self'", SHARED_VALIDATOR_STRING_016, 'https:', 'http:'], - 'worker-src': ["'self'", 'blob:'], - }, - mode: SHARED_VALIDATOR_STRING_107, - reason: SHARED_VALIDATOR_STRING_108, - }, - enabled: true, - headers: { - contentTypeOptions: 'nosniff', - permissionsPolicy: SHARED_VALIDATOR_STRING_055, - referrerPolicy: SHARED_VALIDATOR_STRING_140, - }, - noindex: { - localhost: true, - previewHostnames: [], - workersDev: true, - }, - }, + cloudflareSecurity: createCloudflareSecurityContract(), cohort: { appIds: [SHARED_VALIDATOR_STRING_131, SHARED_VALIDATOR_STRING_098], backendAppIds: [SHARED_VALIDATOR_STRING_098], @@ -1091,51 +1287,7 @@ const workspaceValidationContractDefinition = { unitId: SHARED_VALIDATOR_STRING_046, version: '0.1.0', }, - executionSurfaces: { - cloudflare: { - kind: SHARED_VALIDATOR_STRING_058, - publicUrlEnv: SHARED_VALIDATOR_STRING_147, - ssr: { - assetsBinding: 'ASSETS', - effectBffBundle: SHARED_VALIDATOR_STRING_013, - routeManifest: SHARED_VALIDATOR_STRING_012, - ssrBundle: SHARED_VALIDATOR_STRING_014, - workerEntry: SHARED_VALIDATOR_STRING_010, - workerManifest: SHARED_VALIDATOR_STRING_011, - }, - workerDispatch: { - dispatchNamespaceEnv: SHARED_VALIDATOR_STRING_154, - dispatchWorkerNameEnv: SHARED_VALIDATOR_STRING_158, - preferred: SHARED_VALIDATOR_STRING_126, - requestInterface: 'fetch', - serviceBinding: SHARED_VALIDATOR_STRING_156, - serviceBindingEnv: SHARED_VALIDATOR_STRING_157, - }, - workerName: SHARED_VALIDATOR_STRING_044, - zephyr: { - applicationUidEnv: SHARED_VALIDATOR_STRING_170, - integration: SHARED_VALIDATOR_STRING_076, - runtime: SHARED_VALIDATOR_STRING_139, - snapshotIdEnv: SHARED_VALIDATOR_STRING_171, - versionIdEnv: SHARED_VALIDATOR_STRING_172, - }, - }, - node: { - adapterVersion: SHARED_VALIDATOR_STRING_054, - containerEntry: SHARED_VALIDATOR_STRING_072, - expected: { - buildMarker: SHARED_VALIDATOR_STRING_038, - unitId: SHARED_VALIDATOR_STRING_046, - }, - expose: SHARED_VALIDATOR_STRING_004, - kind: SHARED_VALIDATOR_STRING_088, - manifestEnv: SHARED_VALIDATOR_STRING_153, - manifestUrl: SHARED_VALIDATOR_STRING_071, - remoteName: SHARED_VALIDATOR_STRING_160, - remoteType: SHARED_VALIDATOR_STRING_063, - runtimePackage: SHARED_VALIDATOR_STRING_026, - }, - }, + executionSurfaces: createVerticalExecutionSurfaces(), id: SHARED_VALIDATOR_STRING_098, name: SHARED_VALIDATOR_STRING_160, path: SHARED_VALIDATOR_STRING_161, @@ -1337,93 +1489,7 @@ const workspaceValidationContractDefinition = { version: '0.1.0', }, deploy: { - cloudflare: { - assetsBinding: 'ASSETS', - compatibilityDate: SHARED_VALIDATOR_STRING_036, - compatibilityFlags: [SHARED_VALIDATOR_STRING_089, SHARED_VALIDATOR_STRING_070], - evidence: { - proofScript: SHARED_VALIDATOR_STRING_119, - reportDefault: SHARED_VALIDATOR_STRING_008, - }, - publicUrlEnv: SHARED_VALIDATOR_STRING_148, - qualityGates: { - assets: { - cacheControlRequiredForCss: true, - cssPreloadRequired: true, - cssResponseRequired: true, - sourcemapsPubliclyReferenced: false, - }, - budgets: { - cssAssetMaxBytes: 750_000, - localeJsonMaxBytes: 100_000, - mfManifestMaxBytes: 500_000, - sitemapXmlMaxBytes: 500_000, - ssrHtmlMaxBytes: 250_000, - }, - csp: { - decision: SHARED_VALIDATOR_STRING_109, - finalMode: SHARED_VALIDATOR_STRING_110, - }, - indexing: { - previewNoindex: true, - productionPublicRoutesIndexable: true, - }, - publicRoutes: { - requireRobotsSitemapConsistency: true, - requireSitemapWhenPresent: true, - requireWebManifestWhenPresent: true, - }, - statusCodes: { - notFoundRoute: SHARED_VALIDATOR_STRING_030, - unknownRouteStatus: 404, - }, - }, - routes: { - locale: '/locales/en/shell.json', - mfManifest: SHARED_VALIDATOR_STRING_031, - ssr: '/en', - }, - security: { - contentSecurityPolicy: { - directives: { - 'base-uri': ["'self'"], - 'connect-src': ["'self'", 'https:', 'http:', 'wss:', 'ws:'], - 'default-src': ["'self'"], - 'font-src': ["'self'", 'data:', 'https:', 'http:'], - 'form-action': ["'self'"], - 'frame-ancestors': ["'self'"], - 'img-src': ["'self'", 'data:', 'blob:', 'https:', 'http:'], - 'manifest-src': ["'self'", 'https:', 'http:'], - 'object-src': ["'none'"], - 'script-src': [ - "'self'", - SHARED_VALIDATOR_STRING_016, - SHARED_VALIDATOR_STRING_015, - 'https:', - 'http:', - 'blob:', - ], - 'style-src': ["'self'", SHARED_VALIDATOR_STRING_016, 'https:', 'http:'], - 'worker-src': ["'self'", 'blob:'], - }, - mode: SHARED_VALIDATOR_STRING_107, - reason: SHARED_VALIDATOR_STRING_108, - }, - enabled: true, - headers: { - contentTypeOptions: 'nosniff', - permissionsPolicy: SHARED_VALIDATOR_STRING_055, - referrerPolicy: SHARED_VALIDATOR_STRING_140, - }, - noindex: { - localhost: true, - previewHostnames: [], - workersDev: true, - }, - }, - target: SHARED_VALIDATOR_STRING_056, - workerName: 'app-shell-super-app', - }, + cloudflare: createShellCloudflareContract(), }, displayName: 'Shell Super App', id: SHARED_VALIDATOR_STRING_131, @@ -1462,107 +1528,7 @@ const workspaceValidationContractDefinition = { serverEntry: SHARED_VALIDATOR_STRING_162, stem: SHARED_VALIDATOR_STRING_098, }, - backendFederation: { - cache: { - cloudflareSnapshot: 'immutable', - nodeManifest: 'no-store', - nodeUnpinnedContainer: 'revalidate', - nodeVersionedContainer: 'immutable', - }, - compatibility: { - contractVersion: SHARED_VALIDATOR_STRING_079, - effectVersion: SHARED_VALIDATOR_STRING_039, - moduleFederationVersion: '2.8.0', - packageName: SHARED_VALIDATOR_STRING_018, - }, - deliveryUnit: { - buildMarker: SHARED_VALIDATOR_STRING_038, - kind: SHARED_VALIDATOR_STRING_077, - packageName: SHARED_VALIDATOR_STRING_018, - schemaVersion: 1, - sourceRevision: 'workspace', - unitId: SHARED_VALIDATOR_STRING_046, - version: '0.1.0', - }, - executionSurfaces: { - cloudflare: { - kind: SHARED_VALIDATOR_STRING_058, - publicUrlEnv: SHARED_VALIDATOR_STRING_147, - ssr: { - assetsBinding: 'ASSETS', - effectBffBundle: SHARED_VALIDATOR_STRING_013, - routeManifest: SHARED_VALIDATOR_STRING_012, - ssrBundle: SHARED_VALIDATOR_STRING_014, - workerEntry: SHARED_VALIDATOR_STRING_010, - workerManifest: SHARED_VALIDATOR_STRING_011, - }, - workerDispatch: { - dispatchNamespaceEnv: SHARED_VALIDATOR_STRING_154, - dispatchWorkerNameEnv: SHARED_VALIDATOR_STRING_158, - preferred: SHARED_VALIDATOR_STRING_126, - requestInterface: 'fetch', - serviceBinding: SHARED_VALIDATOR_STRING_156, - serviceBindingEnv: SHARED_VALIDATOR_STRING_157, - }, - workerName: SHARED_VALIDATOR_STRING_044, - zephyr: { - applicationUidEnv: SHARED_VALIDATOR_STRING_170, - integration: SHARED_VALIDATOR_STRING_076, - runtime: SHARED_VALIDATOR_STRING_139, - snapshotIdEnv: SHARED_VALIDATOR_STRING_171, - versionIdEnv: SHARED_VALIDATOR_STRING_172, - }, - }, - node: { - adapterVersion: SHARED_VALIDATOR_STRING_054, - containerEntry: SHARED_VALIDATOR_STRING_072, - expected: { - buildMarker: SHARED_VALIDATOR_STRING_038, - unitId: SHARED_VALIDATOR_STRING_046, - }, - expose: SHARED_VALIDATOR_STRING_004, - kind: SHARED_VALIDATOR_STRING_088, - manifestEnv: SHARED_VALIDATOR_STRING_153, - manifestUrl: SHARED_VALIDATOR_STRING_071, - remoteName: SHARED_VALIDATOR_STRING_160, - remoteType: SHARED_VALIDATOR_STRING_063, - runtimePackage: SHARED_VALIDATOR_STRING_026, - }, - }, - exposes: { - './effect-api': { - client: SHARED_VALIDATOR_STRING_167, - contract: SHARED_VALIDATOR_STRING_166, - openapi: SHARED_VALIDATOR_STRING_033, - readiness: SHARED_VALIDATOR_STRING_034, - runtime: SHARED_VALIDATOR_STRING_162, - }, - }, - fallback: { - failureEvent: 'modernjs:microvertical-server-fallback', - strategy: 'typed-effect-error', - timeoutMs: 1500, - }, - name: SHARED_VALIDATOR_STRING_160, - role: SHARED_VALIDATOR_STRING_078, - runtimeFramework: 'effect', - strictEffectApproach: true, - versionBoundary: { - api: { - buildMarker: 'verticals/party-registry/shared/ultramodern-build.ts', - publicUrlEnv: SHARED_VALIDATOR_STRING_147, - readiness: SHARED_VALIDATOR_STRING_034, - }, - identityRoot: SHARED_VALIDATOR_STRING_065, - invariant: SHARED_VALIDATOR_STRING_168, - packageName: SHARED_VALIDATOR_STRING_018, - ui: { - buildMarker: 'verticals/party-registry/src/routes/ultramodern-route-metadata.ts', - manifestEnv: SHARED_VALIDATOR_STRING_155, - manifestUrl: SHARED_VALIDATOR_STRING_073, - }, - }, - }, + backendFederation: createVerticalBackendFederationContract(), deliveryUnit: { buildMarker: SHARED_VALIDATOR_STRING_038, kind: SHARED_VALIDATOR_STRING_077, @@ -1573,104 +1539,7 @@ const workspaceValidationContractDefinition = { version: '0.1.0', }, deploy: { - cloudflare: { - assetsBinding: 'ASSETS', - compatibilityDate: SHARED_VALIDATOR_STRING_036, - compatibilityFlags: [SHARED_VALIDATOR_STRING_089, SHARED_VALIDATOR_STRING_070], - evidence: { - proofScript: SHARED_VALIDATOR_STRING_119, - reportDefault: SHARED_VALIDATOR_STRING_008, - }, - jsonSmokeChecks: [ - { - expect: { - 'checks.api': 'ready', - 'checks.moduleFederation': 'ready', - 'checks.ssr': 'ready', - status: 'ready', - }, - id: 'party-registry-readiness-smoke', - route: SHARED_VALIDATOR_STRING_034, - }, - ], - publicUrlEnv: SHARED_VALIDATOR_STRING_147, - qualityGates: { - assets: { - cacheControlRequiredForCss: true, - cssPreloadRequired: true, - cssResponseRequired: true, - sourcemapsPubliclyReferenced: false, - }, - budgets: { - cssAssetMaxBytes: 750_000, - localeJsonMaxBytes: 100_000, - mfManifestMaxBytes: 500_000, - sitemapXmlMaxBytes: 500_000, - ssrHtmlMaxBytes: 250_000, - }, - csp: { - decision: SHARED_VALIDATOR_STRING_109, - finalMode: SHARED_VALIDATOR_STRING_110, - }, - indexing: { - previewNoindex: true, - productionPublicRoutesIndexable: true, - }, - publicRoutes: { - requireRobotsSitemapConsistency: true, - requireSitemapWhenPresent: true, - requireWebManifestWhenPresent: true, - }, - statusCodes: { - notFoundRoute: SHARED_VALIDATOR_STRING_030, - unknownRouteStatus: 404, - }, - }, - routes: { - apiReadiness: SHARED_VALIDATOR_STRING_034, - mfManifest: SHARED_VALIDATOR_STRING_031, - }, - security: { - contentSecurityPolicy: { - directives: { - 'base-uri': ["'self'"], - 'connect-src': ["'self'", 'https:', 'http:', 'wss:', 'ws:'], - 'default-src': ["'self'"], - 'font-src': ["'self'", 'data:', 'https:', 'http:'], - 'form-action': ["'self'"], - 'frame-ancestors': ["'self'"], - 'img-src': ["'self'", 'data:', 'blob:', 'https:', 'http:'], - 'manifest-src': ["'self'", 'https:', 'http:'], - 'object-src': ["'none'"], - 'script-src': [ - "'self'", - SHARED_VALIDATOR_STRING_016, - SHARED_VALIDATOR_STRING_015, - 'https:', - 'http:', - 'blob:', - ], - 'style-src': ["'self'", SHARED_VALIDATOR_STRING_016, 'https:', 'http:'], - 'worker-src': ["'self'", 'blob:'], - }, - mode: SHARED_VALIDATOR_STRING_107, - reason: SHARED_VALIDATOR_STRING_108, - }, - enabled: true, - headers: { - contentTypeOptions: 'nosniff', - permissionsPolicy: SHARED_VALIDATOR_STRING_055, - referrerPolicy: SHARED_VALIDATOR_STRING_140, - }, - noindex: { - localhost: true, - previewHostnames: [], - workersDev: true, - }, - }, - target: SHARED_VALIDATOR_STRING_056, - workerName: SHARED_VALIDATOR_STRING_044, - }, + cloudflare: createVerticalCloudflareContract(), }, displayName: 'Party Registry Vertical', domain: SHARED_VALIDATOR_STRING_098, @@ -1711,53 +1580,12 @@ const workspaceValidationContractDefinition = { serverExecution: { 'party-registry': { apiBaseUrl: 'http://localhost:4102/party-registry-api', - cloudflare: { - kind: SHARED_VALIDATOR_STRING_058, - publicUrlEnv: SHARED_VALIDATOR_STRING_147, - ssr: { - assetsBinding: 'ASSETS', - effectBffBundle: SHARED_VALIDATOR_STRING_013, - routeManifest: SHARED_VALIDATOR_STRING_012, - ssrBundle: SHARED_VALIDATOR_STRING_014, - workerEntry: SHARED_VALIDATOR_STRING_010, - workerManifest: SHARED_VALIDATOR_STRING_011, - }, - workerDispatch: { - dispatchNamespaceEnv: SHARED_VALIDATOR_STRING_154, - dispatchWorkerNameEnv: SHARED_VALIDATOR_STRING_158, - preferred: SHARED_VALIDATOR_STRING_126, - requestInterface: 'fetch', - serviceBinding: SHARED_VALIDATOR_STRING_156, - serviceBindingEnv: SHARED_VALIDATOR_STRING_157, - }, - workerName: SHARED_VALIDATOR_STRING_044, - zephyr: { - applicationUidEnv: SHARED_VALIDATOR_STRING_170, - integration: SHARED_VALIDATOR_STRING_076, - runtime: SHARED_VALIDATOR_STRING_139, - snapshotIdEnv: SHARED_VALIDATOR_STRING_171, - versionIdEnv: SHARED_VALIDATOR_STRING_172, - }, - }, + cloudflare: createVerticalCloudflareExecution(), deliveryUnit: { buildMarker: SHARED_VALIDATOR_STRING_038, unitId: SHARED_VALIDATOR_STRING_046, }, - node: { - adapterVersion: SHARED_VALIDATOR_STRING_054, - containerEntry: SHARED_VALIDATOR_STRING_072, - expected: { - buildMarker: SHARED_VALIDATOR_STRING_038, - unitId: SHARED_VALIDATOR_STRING_046, - }, - expose: SHARED_VALIDATOR_STRING_004, - kind: SHARED_VALIDATOR_STRING_088, - manifestEnv: SHARED_VALIDATOR_STRING_153, - manifestUrl: SHARED_VALIDATOR_STRING_071, - remoteName: SHARED_VALIDATOR_STRING_160, - remoteType: SHARED_VALIDATOR_STRING_063, - runtimePackage: SHARED_VALIDATOR_STRING_026, - }, + node: createVerticalNodeExecution(), versionBoundary: SHARED_VALIDATOR_STRING_168, }, }, @@ -1918,93 +1746,7 @@ const workspaceValidationContractDefinition = { kind: 'shell-core-capability', owners: [SHARED_VALIDATOR_STRING_131, SHARED_VALIDATOR_STRING_064], }, - cloudflare: { - assetsBinding: 'ASSETS', - compatibilityDate: SHARED_VALIDATOR_STRING_036, - compatibilityFlags: [SHARED_VALIDATOR_STRING_089, SHARED_VALIDATOR_STRING_070], - evidence: { - proofScript: SHARED_VALIDATOR_STRING_119, - reportDefault: SHARED_VALIDATOR_STRING_008, - }, - publicUrlEnv: SHARED_VALIDATOR_STRING_148, - qualityGates: { - assets: { - cacheControlRequiredForCss: true, - cssPreloadRequired: true, - cssResponseRequired: true, - sourcemapsPubliclyReferenced: false, - }, - budgets: { - cssAssetMaxBytes: 750_000, - localeJsonMaxBytes: 100_000, - mfManifestMaxBytes: 500_000, - sitemapXmlMaxBytes: 500_000, - ssrHtmlMaxBytes: 250_000, - }, - csp: { - decision: SHARED_VALIDATOR_STRING_109, - finalMode: SHARED_VALIDATOR_STRING_110, - }, - indexing: { - previewNoindex: true, - productionPublicRoutesIndexable: true, - }, - publicRoutes: { - requireRobotsSitemapConsistency: true, - requireSitemapWhenPresent: true, - requireWebManifestWhenPresent: true, - }, - statusCodes: { - notFoundRoute: SHARED_VALIDATOR_STRING_030, - unknownRouteStatus: 404, - }, - }, - routes: { - locale: '/locales/en/shell.json', - mfManifest: SHARED_VALIDATOR_STRING_031, - ssr: '/en', - }, - security: { - contentSecurityPolicy: { - directives: { - 'base-uri': ["'self'"], - 'connect-src': ["'self'", 'https:', 'http:', 'wss:', 'ws:'], - 'default-src': ["'self'"], - 'font-src': ["'self'", 'data:', 'https:', 'http:'], - 'form-action': ["'self'"], - 'frame-ancestors': ["'self'"], - 'img-src': ["'self'", 'data:', 'blob:', 'https:', 'http:'], - 'manifest-src': ["'self'", 'https:', 'http:'], - 'object-src': ["'none'"], - 'script-src': [ - "'self'", - SHARED_VALIDATOR_STRING_016, - SHARED_VALIDATOR_STRING_015, - 'https:', - 'http:', - 'blob:', - ], - 'style-src': ["'self'", SHARED_VALIDATOR_STRING_016, 'https:', 'http:'], - 'worker-src': ["'self'", 'blob:'], - }, - mode: SHARED_VALIDATOR_STRING_107, - reason: SHARED_VALIDATOR_STRING_108, - }, - enabled: true, - headers: { - contentTypeOptions: 'nosniff', - permissionsPolicy: SHARED_VALIDATOR_STRING_055, - referrerPolicy: SHARED_VALIDATOR_STRING_140, - }, - noindex: { - localhost: true, - previewHostnames: [], - workersDev: true, - }, - }, - target: SHARED_VALIDATOR_STRING_056, - workerName: 'app-shell-super-app', - }, + cloudflare: createShellCloudflareContract(), deliveryUnit: { buildMarker: '090dd0a19fdd0853', kind: SHARED_VALIDATOR_STRING_077, @@ -2097,205 +1839,8 @@ const workspaceValidationContractDefinition = { runtime: 'effect', serverEntry: SHARED_VALIDATOR_STRING_162, }, - backendFederation: { - cache: { - cloudflareSnapshot: 'immutable', - nodeManifest: 'no-store', - nodeUnpinnedContainer: 'revalidate', - nodeVersionedContainer: 'immutable', - }, - compatibility: { - contractVersion: SHARED_VALIDATOR_STRING_079, - effectVersion: SHARED_VALIDATOR_STRING_039, - moduleFederationVersion: '2.8.0', - packageName: SHARED_VALIDATOR_STRING_018, - }, - deliveryUnit: { - buildMarker: SHARED_VALIDATOR_STRING_038, - kind: SHARED_VALIDATOR_STRING_077, - packageName: SHARED_VALIDATOR_STRING_018, - schemaVersion: 1, - sourceRevision: 'workspace', - unitId: SHARED_VALIDATOR_STRING_046, - version: '0.1.0', - }, - executionSurfaces: { - cloudflare: { - kind: SHARED_VALIDATOR_STRING_058, - publicUrlEnv: SHARED_VALIDATOR_STRING_147, - ssr: { - assetsBinding: 'ASSETS', - effectBffBundle: SHARED_VALIDATOR_STRING_013, - routeManifest: SHARED_VALIDATOR_STRING_012, - ssrBundle: SHARED_VALIDATOR_STRING_014, - workerEntry: SHARED_VALIDATOR_STRING_010, - workerManifest: SHARED_VALIDATOR_STRING_011, - }, - workerDispatch: { - dispatchNamespaceEnv: SHARED_VALIDATOR_STRING_154, - dispatchWorkerNameEnv: SHARED_VALIDATOR_STRING_158, - preferred: SHARED_VALIDATOR_STRING_126, - requestInterface: 'fetch', - serviceBinding: SHARED_VALIDATOR_STRING_156, - serviceBindingEnv: SHARED_VALIDATOR_STRING_157, - }, - workerName: SHARED_VALIDATOR_STRING_044, - zephyr: { - applicationUidEnv: SHARED_VALIDATOR_STRING_170, - integration: SHARED_VALIDATOR_STRING_076, - runtime: SHARED_VALIDATOR_STRING_139, - snapshotIdEnv: SHARED_VALIDATOR_STRING_171, - versionIdEnv: SHARED_VALIDATOR_STRING_172, - }, - }, - node: { - adapterVersion: SHARED_VALIDATOR_STRING_054, - containerEntry: SHARED_VALIDATOR_STRING_072, - expected: { - buildMarker: SHARED_VALIDATOR_STRING_038, - unitId: SHARED_VALIDATOR_STRING_046, - }, - expose: SHARED_VALIDATOR_STRING_004, - kind: SHARED_VALIDATOR_STRING_088, - manifestEnv: SHARED_VALIDATOR_STRING_153, - manifestUrl: SHARED_VALIDATOR_STRING_071, - remoteName: SHARED_VALIDATOR_STRING_160, - remoteType: SHARED_VALIDATOR_STRING_063, - runtimePackage: SHARED_VALIDATOR_STRING_026, - }, - }, - exposes: { - './effect-api': { - client: SHARED_VALIDATOR_STRING_167, - contract: SHARED_VALIDATOR_STRING_166, - openapi: SHARED_VALIDATOR_STRING_033, - readiness: SHARED_VALIDATOR_STRING_034, - runtime: SHARED_VALIDATOR_STRING_162, - }, - }, - fallback: { - failureEvent: 'modernjs:microvertical-server-fallback', - strategy: 'typed-effect-error', - timeoutMs: 1500, - }, - name: SHARED_VALIDATOR_STRING_160, - role: SHARED_VALIDATOR_STRING_078, - runtimeFramework: 'effect', - strictEffectApproach: true, - versionBoundary: { - api: { - buildMarker: 'verticals/party-registry/shared/ultramodern-build.ts', - publicUrlEnv: SHARED_VALIDATOR_STRING_147, - readiness: SHARED_VALIDATOR_STRING_034, - }, - identityRoot: SHARED_VALIDATOR_STRING_065, - invariant: SHARED_VALIDATOR_STRING_168, - packageName: SHARED_VALIDATOR_STRING_018, - ui: { - buildMarker: 'verticals/party-registry/src/routes/ultramodern-route-metadata.ts', - manifestEnv: SHARED_VALIDATOR_STRING_155, - manifestUrl: SHARED_VALIDATOR_STRING_073, - }, - }, - }, - cloudflare: { - assetsBinding: 'ASSETS', - compatibilityDate: SHARED_VALIDATOR_STRING_036, - compatibilityFlags: [SHARED_VALIDATOR_STRING_089, SHARED_VALIDATOR_STRING_070], - evidence: { - proofScript: SHARED_VALIDATOR_STRING_119, - reportDefault: SHARED_VALIDATOR_STRING_008, - }, - jsonSmokeChecks: [ - { - expect: { - 'checks.api': 'ready', - 'checks.moduleFederation': 'ready', - 'checks.ssr': 'ready', - status: 'ready', - }, - id: 'party-registry-readiness-smoke', - route: SHARED_VALIDATOR_STRING_034, - }, - ], - publicUrlEnv: SHARED_VALIDATOR_STRING_147, - qualityGates: { - assets: { - cacheControlRequiredForCss: true, - cssPreloadRequired: true, - cssResponseRequired: true, - sourcemapsPubliclyReferenced: false, - }, - budgets: { - cssAssetMaxBytes: 750_000, - localeJsonMaxBytes: 100_000, - mfManifestMaxBytes: 500_000, - sitemapXmlMaxBytes: 500_000, - ssrHtmlMaxBytes: 250_000, - }, - csp: { - decision: SHARED_VALIDATOR_STRING_109, - finalMode: SHARED_VALIDATOR_STRING_110, - }, - indexing: { - previewNoindex: true, - productionPublicRoutesIndexable: true, - }, - publicRoutes: { - requireRobotsSitemapConsistency: true, - requireSitemapWhenPresent: true, - requireWebManifestWhenPresent: true, - }, - statusCodes: { - notFoundRoute: SHARED_VALIDATOR_STRING_030, - unknownRouteStatus: 404, - }, - }, - routes: { - apiReadiness: SHARED_VALIDATOR_STRING_034, - mfManifest: SHARED_VALIDATOR_STRING_031, - }, - security: { - contentSecurityPolicy: { - directives: { - 'base-uri': ["'self'"], - 'connect-src': ["'self'", 'https:', 'http:', 'wss:', 'ws:'], - 'default-src': ["'self'"], - 'font-src': ["'self'", 'data:', 'https:', 'http:'], - 'form-action': ["'self'"], - 'frame-ancestors': ["'self'"], - 'img-src': ["'self'", 'data:', 'blob:', 'https:', 'http:'], - 'manifest-src': ["'self'", 'https:', 'http:'], - 'object-src': ["'none'"], - 'script-src': [ - "'self'", - SHARED_VALIDATOR_STRING_016, - SHARED_VALIDATOR_STRING_015, - 'https:', - 'http:', - 'blob:', - ], - 'style-src': ["'self'", SHARED_VALIDATOR_STRING_016, 'https:', 'http:'], - 'worker-src': ["'self'", 'blob:'], - }, - mode: SHARED_VALIDATOR_STRING_107, - reason: SHARED_VALIDATOR_STRING_108, - }, - enabled: true, - headers: { - contentTypeOptions: 'nosniff', - permissionsPolicy: SHARED_VALIDATOR_STRING_055, - referrerPolicy: SHARED_VALIDATOR_STRING_140, - }, - noindex: { - localhost: true, - previewHostnames: [], - workersDev: true, - }, - }, - target: SHARED_VALIDATOR_STRING_056, - workerName: SHARED_VALIDATOR_STRING_044, - }, + backendFederation: createVerticalBackendFederationContract(), + cloudflare: createVerticalCloudflareContract(), deliveryUnit: { buildMarker: SHARED_VALIDATOR_STRING_038, kind: SHARED_VALIDATOR_STRING_077, @@ -3020,6 +2565,34 @@ const assertGeneratedSurfacePattern = ( `generated surface policy ${rule.id}.${pattern.id} fixArea is required`, ); }; +const assertGeneratedSurfaceRules = (contract: WorkspaceValidationContract): void => { + const { generatedSurfacePolicy } = contract; + assert( + generatedSurfacePolicy.schemaVersion === 1, + `Unsupported generated surface policy schemaVersion ${formatJson(generatedSurfacePolicy.schemaVersion)}; expected 1`, + ); + assertUniqueIdEntries( + generatedSurfacePolicy.rules, + 'workspace validation contract generated surface policy rules', + ); + for (const rule of generatedSurfacePolicy.rules) { + assertUniqueStrings( + rule.paths.map((entry) => entry.path), + `generated surface policy ${rule.id} paths`, + ); + assert( + Array.isArray(rule.paths) && rule.paths.length > 0, + `generated surface policy ${rule.id} must target generated paths`, + ); + for (const target of rule.paths) { + assertGeneratedSurfaceTarget(rule, target); + } + assertUniqueIdEntries(rule.patterns, `generated surface policy ${rule.id} patterns`); + for (const pattern of rule.patterns) { + assertGeneratedSurfacePattern(rule, pattern); + } + } +}; const assertWorkspaceValidationContract = (contract: WorkspaceValidationContract): void => { assert(!Array.isArray(contract), 'Workspace validation contract must be a JSON object'); assert( @@ -3093,32 +2666,7 @@ const assertWorkspaceValidationContract = (contract: WorkspaceValidationContract 'workspace validation contract package manifest paths', ); - const { generatedSurfacePolicy } = contract; - assert( - generatedSurfacePolicy.schemaVersion === 1, - `Unsupported generated surface policy schemaVersion ${formatJson(generatedSurfacePolicy.schemaVersion)}; expected 1`, - ); - assertUniqueIdEntries( - generatedSurfacePolicy.rules, - 'workspace validation contract generated surface policy rules', - ); - for (const rule of generatedSurfacePolicy.rules) { - assertUniqueStrings( - rule.paths.map((entry) => entry.path), - `generated surface policy ${rule.id} paths`, - ); - assert( - Array.isArray(rule.paths) && rule.paths.length > 0, - `generated surface policy ${rule.id} must target generated paths`, - ); - for (const target of rule.paths) { - assertGeneratedSurfaceTarget(rule, target); - } - assertUniqueIdEntries(rule.patterns, `generated surface policy ${rule.id} patterns`); - for (const pattern of rule.patterns) { - assertGeneratedSurfacePattern(rule, pattern); - } - } + assertGeneratedSurfaceRules(contract); }; const generatedSurfacePolicyFiles = (target: GeneratedSurfaceTarget): string[] => { const absolutePath = path.join(root, target.path); @@ -3176,6 +2724,58 @@ const skipSourceComment = (source: string, start: number): number => { } return start; }; +const isSourceQuote = (character: string | undefined): boolean => + character !== undefined && '\'"`'.includes(character); +const matchesJsxAttribute = (source: string, cursor: number, attributeName: string): boolean => { + if ( + !source.startsWith(attributeName, cursor) || + isJsxNameCharacter(source[cursor - 1]) || + isJsxNameCharacter(source[cursor + attributeName.length]) + ) { + return false; + } + let equalsIndex = cursor + attributeName.length; + while (/\s/u.test(source[equalsIndex] ?? '')) { + equalsIndex += 1; + } + return source[equalsIndex] === '='; +}; +const skipJsxNonAttributeSource = (source: string, cursor: number): number => { + if (isSourceQuote(source[cursor])) { + return skipQuotedSource(source, cursor); + } + return source[cursor] === '/' ? Math.max(cursor + 1, skipSourceComment(source, cursor)) : cursor; +}; +const findOpeningElementAttribute = ( + source: string, + start: number, + attributeName: string, +): { readonly index: number } | null => { + let cursor = start; + let expressionDepth = 0; + while (cursor < source.length) { + const character = source[cursor]; + const afterSkippedSource = skipJsxNonAttributeSource(source, cursor); + if (afterSkippedSource !== cursor) { + cursor = afterSkippedSource; + continue; + } + if (character === '{') { + expressionDepth += 1; + } else if (character === '}') { + expressionDepth = Math.max(0, expressionDepth - 1); + } else if (expressionDepth === 0) { + if (character === '>') { + return null; + } + if (matchesJsxAttribute(source, cursor, attributeName)) { + return { index: cursor }; + } + } + cursor += 1; + } + return null; +}; const findJsxAttribute = ( source: string, matcher: NonNullable, @@ -3183,49 +2783,14 @@ const findJsxAttribute = ( const opening = `<${matcher.elementName}`; let elementIndex = source.indexOf(opening); while (elementIndex !== -1) { - const elementBoundary = source[elementIndex + opening.length]; - if (!isJsxNameCharacter(elementBoundary)) { - let cursor = elementIndex + opening.length; - let expressionDepth = 0; - while (cursor < source.length) { - const character = source[cursor]; - if (character === "'" || character === '"' || character === '`') { - cursor = skipQuotedSource(source, cursor); - } else if (character === '/') { - const afterComment = skipSourceComment(source, cursor); - if (afterComment === cursor) { - cursor += 1; - } else { - cursor = afterComment; - } - } else if (character === '{') { - expressionDepth += 1; - cursor += 1; - } else if (character === '}') { - expressionDepth = Math.max(0, expressionDepth - 1); - cursor += 1; - } else if (character === '>' && expressionDepth === 0) { - break; - } else if ( - expressionDepth === 0 && - source.startsWith(matcher.attributeName, cursor) && - !isJsxNameCharacter(source[cursor - 1]) && - !isJsxNameCharacter(source[cursor + matcher.attributeName.length]) - ) { - let equalsIndex = cursor + matcher.attributeName.length; - while (/\s/u.test(source[equalsIndex] ?? '')) { - equalsIndex += 1; - } - if (source[equalsIndex] === '=') { - return { index: cursor }; - } - cursor += 1; - } else { - cursor += 1; - } + const start = elementIndex + opening.length; + if (!isJsxNameCharacter(source[start])) { + const match = findOpeningElementAttribute(source, start, matcher.attributeName); + if (match !== null) { + return match; } } - elementIndex = source.indexOf(opening, elementIndex + opening.length); + elementIndex = source.indexOf(opening, start); } return null; }; @@ -3240,24 +2805,23 @@ const findGeneratedSurfacePolicyMatch = ( ? null : new RegExp(pattern.expression, pattern.flags).exec(source); }; -const assertGeneratedSurfacePolicy = () => { - for (const rule of workspaceValidationContract.generatedSurfacePolicy.rules) { - const files = sortedCopy(rule.paths.flatMap(generatedSurfacePolicyFiles), (left, right) => - left.localeCompare(right), +const assertSingleShellDeclarations = (): void => { + assert( + workspaceValidationContract.cohort?.additionalShellManifests === undefined, + 'Single-shell workspace must not declare additional-shell manifests', + ); + assert( + workspaceValidationContract.additionalShells === undefined, + 'Single-shell workspace must not declare additional-shell records', + ); + for (const field of additionalShellCohortFields) { + assert( + workspaceValidationContract.cohort?.[field] === undefined, + `Single-shell workspace must not declare ${field}`, ); - for (const relativePath of files) { - const source = readText(relativePath); - for (const pattern of rule.patterns) { - const match = findGeneratedSurfacePolicyMatch(source, pattern); - assertSelfCheck( - match === null, - `generated surface policy ${rule.id}.${pattern.id}`, - `${pattern.diagnostic} Found forbidden source at ${relativePath}:${match?.index ?? 0}`, - pattern.fixArea, - ); - } - } } +}; +const assertGeneratedAdditionalShellDeclarations = (): void => { if ((workspaceValidationContract.cohort?.additionalShellIds ?? []).length > 0) { const additionalShellIds = workspaceValidationContract.cohort.additionalShellIds ?? []; for (const field of additionalShellCohortFields) { @@ -3281,21 +2845,28 @@ const assertGeneratedSurfacePolicy = () => { 'restore every generated additional-shell contract record', ); } else { - assert( - workspaceValidationContract.cohort?.additionalShellManifests === undefined, - 'Single-shell workspace must not declare additional-shell manifests', - ); - assert( - workspaceValidationContract.additionalShells === undefined, - 'Single-shell workspace must not declare additional-shell records', + assertSingleShellDeclarations(); + } +}; +const assertGeneratedSurfacePolicy = () => { + for (const rule of workspaceValidationContract.generatedSurfacePolicy.rules) { + const files = sortedCopy(rule.paths.flatMap(generatedSurfacePolicyFiles), (left, right) => + left.localeCompare(right), ); - for (const field of additionalShellCohortFields) { - assert( - workspaceValidationContract.cohort?.[field] === undefined, - `Single-shell workspace must not declare ${field}`, - ); + for (const relativePath of files) { + const source = readText(relativePath); + for (const pattern of rule.patterns) { + const match = findGeneratedSurfacePolicyMatch(source, pattern); + assertSelfCheck( + match === null, + `generated surface policy ${rule.id}.${pattern.id}`, + `${pattern.diagnostic} Found forbidden source at ${relativePath}:${match?.index ?? 0}`, + pattern.fixArea, + ); + } } } + assertGeneratedAdditionalShellDeclarations(); }; const compactConfigPolicyView = (config: CompactConfig): Json => ({ agentSkills: config.agentSkills, @@ -3308,6 +2879,62 @@ const compactConfigPolicyView = (config: CompactConfig): Json => ({ tooling: config.tooling, workspace: config.workspace, }); +const assertLegacyMetadataFields = (): void => { + assertObject( + ultramodernConfig.packageSource, + `${compactConfigPath} packageSource`, + 'restore generated compact package-source metadata', + ); + for (const field of workspaceValidationContract.legacy.forbiddenCompactConfigFields) { + assert( + !Object.hasOwn(ultramodernConfig, field), + `Stale legacy field ${compactConfigPath}.${field} is forbidden`, + ); + } + for (const field of workspaceValidationContract.legacy.forbiddenPackageSourceFields) { + assert( + !Object.hasOwn(ultramodernConfig.packageSource, field), + `Stale legacy field ${compactConfigPath}.packageSource.${field} is forbidden`, + ); + } + for (const field of workspaceValidationContract.legacy.forbiddenTopologyFields) { + assert( + !Object.hasOwn(topology, field), + `Stale legacy field ${workspaceValidationContract.metadata.referenceTopology.path}.${field} is forbidden`, + ); + } +}; +const assertMetadataPackageManifests = (): void => { + for (const manifest of workspaceValidationContract.cohort.packageManifests) { + assertExists(manifest.path); + const packageJson = readJson(PackageJsonSchema, manifest.path); + assert( + packageJson.name === manifest.packageName, + `${manifest.path} package name must be ${manifest.packageName}`, + ); + if (manifest.role === 'shell' || manifest.role === 'vertical') { + assert( + packageJson.modernjs?.appId === manifest.id, + `${manifest.path} modernjs.appId must be ${manifest.id}`, + ); + } + } + if (expectedReleaseCohort !== undefined) { + const releaseCohortContract = workspaceValidationContract.metadata.releaseCohort; + assertSelfCheck( + releaseCohortContract?.path === SHARED_VALIDATOR_STRING_009, + 'authenticated release cohort projection', + 'Expected release-cohort metadata path is missing or invalid', + SHARED_VALIDATOR_STRING_009, + ); + assertSameJson( + readJson(ComparableJsonSchema, releaseCohortContract.path), + expectedReleaseCohort, + 'authenticated release cohort projection', + releaseCohortContract.path, + ); + } +}; const assertStructuredWorkspaceMetadata = (): void => { const observedMetadata = [ { @@ -3352,30 +2979,7 @@ const assertStructuredWorkspaceMetadata = (): void => { ); } - assertObject( - ultramodernConfig.packageSource, - `${compactConfigPath} packageSource`, - 'restore generated compact package-source metadata', - ); - for (const field of workspaceValidationContract.legacy.forbiddenCompactConfigFields) { - assert( - !Object.hasOwn(ultramodernConfig, field), - `Stale legacy field ${compactConfigPath}.${field} is forbidden`, - ); - } - for (const field of workspaceValidationContract.legacy.forbiddenPackageSourceFields) { - assert( - !Object.hasOwn(ultramodernConfig.packageSource, field), - `Stale legacy field ${compactConfigPath}.packageSource.${field} is forbidden`, - ); - } - for (const field of workspaceValidationContract.legacy.forbiddenTopologyFields) { - assert( - !Object.hasOwn(topology, field), - `Stale legacy field ${workspaceValidationContract.metadata.referenceTopology.path}.${field} is forbidden`, - ); - } - + assertLegacyMetadataFields(); assertSameIdCohort( ultramodernConfig.topology?.apps, workspaceValidationContract.cohort.appIds, @@ -3421,35 +3025,7 @@ const assertStructuredWorkspaceMetadata = (): void => { 'restore the complete generated ownership cohort', ); - for (const manifest of workspaceValidationContract.cohort.packageManifests) { - assertExists(manifest.path); - const packageJson = readJson(PackageJsonSchema, manifest.path); - assert( - packageJson.name === manifest.packageName, - `${manifest.path} package name must be ${manifest.packageName}`, - ); - if (manifest.role === 'shell' || manifest.role === 'vertical') { - assert( - packageJson.modernjs?.appId === manifest.id, - `${manifest.path} modernjs.appId must be ${manifest.id}`, - ); - } - } - if (expectedReleaseCohort !== undefined) { - const releaseCohortContract = workspaceValidationContract.metadata.releaseCohort; - assertSelfCheck( - releaseCohortContract?.path === SHARED_VALIDATOR_STRING_009, - 'authenticated release cohort projection', - 'Expected release-cohort metadata path is missing or invalid', - SHARED_VALIDATOR_STRING_009, - ); - assertSameJson( - readJson(ComparableJsonSchema, releaseCohortContract.path), - expectedReleaseCohort, - 'authenticated release cohort projection', - releaseCohortContract.path, - ); - } + assertMetadataPackageManifests(); }; const assertStructuredWorkspaceMetadataSemantics = (): void => { assertSameJson( @@ -3623,6 +3199,17 @@ const normalizedAppPortEnv = ( ? SHARED_VALIDATOR_STRING_130 : `VERTICAL_${toEnvSegment(domain ?? id)}_PORT`; }; +const normalizedApiExports = ( + appPath: string, + api: NormalizedApp['api'], +): Pick => { + const packageExports = readJson(PackageJsonSchema, `${appPath}/package.json`).exports ?? {}; + const apiContractExport = packageExports['./api'] === undefined ? undefined : './api'; + const clientExport = + api?.protocol === 'rpc' ? SHARED_VALIDATOR_STRING_003 : SHARED_VALIDATOR_STRING_002; + const apiClientExport = packageExports[clientExport] === undefined ? undefined : clientExport; + return { apiClientExport, apiContractExport }; +}; const normalizeCompactApp = (rawApp: CompactApp): NormalizedApp => { const { api: rawApi, domain: rawDomain, id, port: rawPort, portEnv: rawPortEnv } = rawApp; const kind = rawApp.kind === 'vertical' ? 'vertical' : 'shell'; @@ -3635,15 +3222,7 @@ const normalizeCompactApp = (rawApp: CompactApp): NormalizedApp => { // Preserve the API protocol so the synthesized generated contract can branch // between REST and RPC surfaces. const api = normalizedAppApi(rawApi, domain, id); - const packageExports = readJson(PackageJsonSchema, `${appPath}/package.json`).exports ?? {}; - const apiContractExport = packageExports['./api'] === undefined ? undefined : './api'; - let apiClientExport: NormalizedApp['apiClientExport']; - if (api?.protocol === 'rpc' && packageExports[SHARED_VALIDATOR_STRING_003] !== undefined) { - apiClientExport = SHARED_VALIDATOR_STRING_003; - } - if (api?.protocol !== 'rpc' && packageExports[SHARED_VALIDATOR_STRING_002] !== undefined) { - apiClientExport = SHARED_VALIDATOR_STRING_002; - } + const { apiClientExport, apiContractExport } = normalizedApiExports(appPath, api); const mfName = normalizedAppMfName(moduleFederation.name, domain, id, kind); const port = normalizedAppPort(rawPort, kind); const portEnv = normalizedAppPortEnv(rawPortEnv, domain, id, kind); @@ -3817,38 +3396,7 @@ const createPublicHead = () => ({ required: ['twitter:card', 'twitter:title', 'twitter:description'], }, }); -const createQualityGates = () => ({ - assets: { - cacheControlRequiredForCss: true, - cssPreloadRequired: true, - cssResponseRequired: true, - sourcemapsPubliclyReferenced: false, - }, - budgets: { - cssAssetMaxBytes: 750_000, - localeJsonMaxBytes: 100_000, - mfManifestMaxBytes: 500_000, - sitemapXmlMaxBytes: 500_000, - ssrHtmlMaxBytes: 250_000, - }, - csp: { - decision: SHARED_VALIDATOR_STRING_109, - finalMode: SHARED_VALIDATOR_STRING_110, - }, - indexing: { - previewNoindex: true, - productionPublicRoutesIndexable: true, - }, - publicRoutes: { - requireRobotsSitemapConsistency: true, - requireSitemapWhenPresent: true, - requireWebManifestWhenPresent: true, - }, - statusCodes: { - notFoundRoute: SHARED_VALIDATOR_STRING_030, - unknownRouteStatus: 404, - }, -}); + const createCloudflareRoutes = (app: NormalizedApp) => { const hasRenderedSurface = app.kind === 'shell' || app.exposes.length > 0; return { @@ -4072,6 +3620,21 @@ const createApiContract = (app: NormalizedApp) => { ...createEffectOperationContract(app), }; }; +const createAppFederationContract = (app: NormalizedApp, apps: readonly NormalizedApp[]) => ({ + browserSafeExposesOnly: true, + dts: + app.kind === 'shell' || app.exposes.length > 0 + ? { + compilerInstance: SHARED_VALIDATOR_STRING_068, + displayErrorInTerminal: true, + tsConfigPath: SHARED_VALIDATOR_STRING_007, + } + : undefined, + exposes: app.exposes, + name: app.mfName, + remotes: app.verticalRefs.length > 0 ? remoteContractsFor(app, apps) : undefined, + verticalRefs: app.verticalRefs.length > 0 ? app.verticalRefs : undefined, +}); const createAppContract = (app: NormalizedApp, apps: readonly NormalizedApp[]) => ({ api: createApiContract(app), config: createAppConfigContract(app), @@ -4101,21 +3664,7 @@ const createAppContract = (app: NormalizedApp, apps: readonly NormalizedApp[]) = uiSurface: 'ui', version: '0.1.0', }, - moduleFederation: { - browserSafeExposesOnly: true, - dts: - app.kind === 'shell' || app.exposes.length > 0 - ? { - compilerInstance: SHARED_VALIDATOR_STRING_068, - displayErrorInTerminal: true, - tsConfigPath: SHARED_VALIDATOR_STRING_007, - } - : undefined, - exposes: app.exposes, - name: app.mfName, - remotes: app.verticalRefs.length > 0 ? remoteContractsFor(app, apps) : undefined, - verticalRefs: app.verticalRefs.length > 0 ? app.verticalRefs : undefined, - }, + moduleFederation: createAppFederationContract(app, apps), package: app.package ?? packageNameFor(packageScope, app.packageSuffix), path: app.path, routes: { @@ -4540,6 +4089,13 @@ const assertTopologyVerticalDeliveryUnitContract = ( ); } }; +const topologyVerticalFederationView = (topologyEntry: ReferenceTopologyVertical) => ({ + exposes: topologyEntry.moduleFederation?.exposes ?? [], + manifestUrl: topologyEntry.moduleFederation?.manifestUrl, + name: topologyEntry.moduleFederation?.name, + remotes: (topologyEntry.moduleFederation?.remotes ?? []).map(remoteContractSubset), + verticalRefs: topologyEntry.moduleFederation?.verticalRefs ?? [], +}); const assertTopologyVerticalContract = (vertical: FullStackVertical): void => { const topologyEntry = findById(topology.verticals, vertical.id); const expectedRefs = vertical.verticalRefs ?? []; @@ -4560,13 +4116,7 @@ const assertTopologyVerticalContract = (vertical: FullStackVertical): void => { } : undefined, kind: topologyEntry.kind, - moduleFederation: { - exposes: topologyEntry.moduleFederation?.exposes ?? [], - manifestUrl: topologyEntry.moduleFederation?.manifestUrl, - name: topologyEntry.moduleFederation?.name, - remotes: (topologyEntry.moduleFederation?.remotes ?? []).map(remoteContractSubset), - verticalRefs: topologyEntry.moduleFederation?.verticalRefs ?? [], - }, + moduleFederation: topologyVerticalFederationView(topologyEntry), package: topologyEntry.package, path: topologyEntry.path, }, @@ -4687,6 +4237,12 @@ const assertShellDependenciesForVertical = ( } } }; +const generatedVerticalFederationView = (contractEntry: ReturnType) => ({ + exposes: contractEntry.moduleFederation?.exposes ?? [], + name: contractEntry.moduleFederation?.name, + remotes: (contractEntry.moduleFederation?.remotes ?? []).map(remoteContractSubset), + verticalRefs: contractEntry.moduleFederation?.verticalRefs ?? [], +}); const assertGeneratedVerticalContract = ( vertical: FullStackVertical, generatedContract: ReturnType, @@ -4711,12 +4267,7 @@ const assertGeneratedVerticalContract = ( } : undefined, kind: contractEntry.kind, - moduleFederation: { - exposes: contractEntry.moduleFederation?.exposes ?? [], - name: contractEntry.moduleFederation?.name, - remotes: (contractEntry.moduleFederation?.remotes ?? []).map(remoteContractSubset), - verticalRefs: contractEntry.moduleFederation?.verticalRefs ?? [], - }, + moduleFederation: generatedVerticalFederationView(contractEntry), package: contractEntry.package, path: contractEntry.path, ssr: contractEntry.ssr, @@ -4744,6 +4295,44 @@ const assertGeneratedVerticalContract = ( regenerateMicroVerticalContractFix, ); }; +const assertGeneratedPrimaryShellContract = ( + generatedContract: ReturnType, + expectedShellVerticalIds: readonly string[], + expectedShellRemotes: ReturnType[], +): boolean => { + const shellContract = findById(generatedContract.apps, SHARED_VALIDATOR_STRING_131); + assertObject( + shellContract, + `${generatedContractLabel} apps.shell-super-app`, + 'regenerate the generated shell contract entry', + ); + if (shellContract === undefined) { + return false; + } + assertSameJson( + shellContract.moduleFederation?.verticalRefs ?? [], + expectedShellVerticalIds, + `${generatedContractLabel} shell moduleFederation.verticalRefs`, + 'regenerate the generated shell Module Federation contract', + ); + assertSameJson( + (shellContract.moduleFederation?.remotes ?? []).map(remoteContractSubset), + expectedShellRemotes, + `${generatedContractLabel} shell moduleFederation.remotes`, + 'regenerate the generated shell Module Federation contract', + ); + assertSameJson( + shellContract.ssr, + { + mode: 'stream', + moduleFederationAppSSR: true, + }, + `${generatedContractLabel} shell SSR contract`, + 'restore generated streaming SSR Module Federation settings', + ); + + return true; +}; const assertMicroVerticalContractGraph = ( generatedContract: ReturnType, ): void => { @@ -4829,37 +4418,15 @@ const assertMicroVerticalContractGraph = ( 'regenerate the generated contract after topology changes', ); - const shellContract = findById(generatedContract.apps, SHARED_VALIDATOR_STRING_131); - assertObject( - shellContract, - `${generatedContractLabel} apps.shell-super-app`, - 'regenerate the generated shell contract entry', - ); - if (shellContract === undefined) { + if ( + !assertGeneratedPrimaryShellContract( + generatedContract, + expectedShellVerticalIds, + expectedShellRemotes, + ) + ) { return; } - assertSameJson( - shellContract.moduleFederation?.verticalRefs ?? [], - expectedShellVerticalIds, - `${generatedContractLabel} shell moduleFederation.verticalRefs`, - 'regenerate the generated shell Module Federation contract', - ); - assertSameJson( - (shellContract.moduleFederation?.remotes ?? []).map(remoteContractSubset), - expectedShellRemotes, - `${generatedContractLabel} shell moduleFederation.remotes`, - 'regenerate the generated shell Module Federation contract', - ); - assertSameJson( - shellContract.ssr, - { - mode: 'stream', - moduleFederationAppSSR: true, - }, - `${generatedContractLabel} shell SSR contract`, - 'restore generated streaming SSR Module Federation settings', - ); - for (const vertical of fullStackVerticals) { for (const requiredPath of requiredMicroVerticalPaths(vertical)) { assertRequiredVerticalFile(vertical)(requiredPath); @@ -4914,6 +4481,20 @@ const assertProjectReferenceEmitConfig = (tsConfig: TsConfig, packagePath: strin `${packagePath} must keep TS-Go build info in the generated cache`, ); }; +const expectedVerticalTypecheckIncludes = ( + vertical: FullStackVertical, + verticalPackage: PackageJson, +) => + vertical.typecheckIncludes ?? [ + 'src', + SHARED_VALIDATOR_STRING_075, + SHARED_VALIDATOR_STRING_091, + 'shared', + ...(vertical.emitsApi ? ['api'] : []), + ...(verticalPackage.modernjs?.ontosModule === undefined + ? [] + : ['vertical.manifest.ts', 'vertical.registration.ts']), + ]; const assertVerticalTsConfigReferenceGraph = (vertical: FullStackVertical): void => { const verticalTsConfig = readJson(TsConfigSchema, `${vertical.path}/tsconfig.json`); const verticalMfTypesTsConfig = readJson( @@ -5020,16 +4601,7 @@ const assertVerticalTsConfigReferenceGraph = (vertical: FullStackVertical): void ); assertSameJson( verticalTsConfig.include ?? [], - vertical.typecheckIncludes ?? [ - 'src', - SHARED_VALIDATOR_STRING_075, - SHARED_VALIDATOR_STRING_091, - 'shared', - ...(vertical.emitsApi ? ['api'] : []), - ...(verticalPackage.modernjs?.ontosModule === undefined - ? [] - : ['vertical.manifest.ts', 'vertical.registration.ts']), - ], + expectedVerticalTypecheckIncludes(vertical, verticalPackage), `${vertical.path}/tsconfig.json include`, 'restore the generated MicroVertical typecheck boundary', ); @@ -5052,11 +4624,69 @@ const assertVerticalTsConfigReferenceGraph = (vertical: FullStackVertical): void ] : [SHARED_VALIDATOR_STRING_137], }, - `${vertical.path}/tsconfig.mf-types.json`, - 'restore the generated MicroVertical Module Federation DTS boundary', + `${vertical.path}/tsconfig.mf-types.json`, + 'restore the generated MicroVertical Module Federation DTS boundary', + ); +}; + +const assertAdditionalShellTsConfigReferences = ( + shell: (typeof expectedAdditionalShells)[number], +): void => { + const additionalShellTsConfig = readJson(TsConfigSchema, `${shell.path}/tsconfig.json`); + const additionalShellMfTypesTsConfig = readJson( + TsConfigSchema, + `${shell.path}/tsconfig.mf-types.json`, + ); + const expectedAdditionalShellReferences = [ + ...sharedPackagePaths, + ...(shell.verticalRefs ?? []) + .flatMap((verticalRef) => { + const referencedVertical = fullStackVerticals.find( + (candidate) => candidate.id === verticalRef, + ); + return referencedVertical === undefined ? [] : [referencedVertical]; + }) + .map((referencedVertical) => referencedVertical.path), + ].map((referencePath) => referenceFrom(shell.path, referencePath)); + assertSameJson( + additionalShellTsConfig.references ?? [], + expectedAdditionalShellReferences, + `${shell.path}/tsconfig.json references`, + 'restore the generated additional-shell project-reference graph', + ); + assertSameJson( + additionalShellTsConfig.include ?? [], + ['src', SHARED_VALIDATOR_STRING_075, SHARED_VALIDATOR_STRING_091, 'shared'], + `${shell.path}/tsconfig.json include`, + 'restore the generated additional-shell typecheck boundary', + ); + assertProjectReferenceEmitConfig(additionalShellTsConfig, shell.path); + assertSameJson( + additionalShellMfTypesTsConfig, + { + extends: SHARED_VALIDATOR_STRING_001, + include: [SHARED_VALIDATOR_STRING_137], + }, + `${shell.path}/tsconfig.mf-types.json`, + 'restore the generated additional-shell Module Federation DTS boundary', ); }; - +const primaryShellTsConfigReferences = () => { + const expectedShellReferences = [ + SHARED_VALIDATOR_STRING_092, + ...sharedPackagePaths.filter((packagePath) => packagePath !== SHARED_VALIDATOR_STRING_177), + ...(topology.shell?.verticalRefs ?? []) + .flatMap((verticalRef) => { + const vertical = fullStackVerticals.find((candidate) => candidate.id === verticalRef); + return vertical === undefined ? [] : [vertical]; + }) + // The shell only project-references verticals whose API client types it + // imports; UI-only remotes are federated at runtime, not type-referenced. + .filter((vertical) => vertical.emitsApi) + .map((vertical) => vertical.path), + ].map((referencePath) => referenceFrom(SHARED_VALIDATOR_STRING_047, referencePath)); + return expectedShellReferences; +}; const assertTsConfigReferenceGraph = () => { const baseTsConfig = readJson(TsConfigSchema, 'tsconfig.base.json'); const rootTsConfig = readJson(TsConfigSchema, 'tsconfig.json'); @@ -5071,19 +4701,7 @@ const assertTsConfigReferenceGraph = () => { ...fullStackVerticals.map((vertical) => vertical.path), ...additionalShellPaths, ].map((referencePath) => ({ path: referencePath })); - const expectedShellReferences = [ - SHARED_VALIDATOR_STRING_092, - ...sharedPackagePaths.filter((packagePath) => packagePath !== SHARED_VALIDATOR_STRING_177), - ...(topology.shell?.verticalRefs ?? []) - .flatMap((verticalRef) => { - const vertical = fullStackVerticals.find((candidate) => candidate.id === verticalRef); - return vertical === undefined ? [] : [vertical]; - }) - // The shell only project-references verticals whose API client types it - // imports; UI-only remotes are federated at runtime, not type-referenced. - .filter((vertical) => vertical.emitsApi) - .map((vertical) => vertical.path), - ].map((referencePath) => referenceFrom(SHARED_VALIDATOR_STRING_047, referencePath)); + const expectedShellReferences = primaryShellTsConfigReferences(); assertSameJson( rootTsConfig.files, @@ -5135,44 +4753,7 @@ const assertTsConfigReferenceGraph = () => { } for (const shell of expectedAdditionalShells) { - const additionalShellTsConfig = readJson(TsConfigSchema, `${shell.path}/tsconfig.json`); - const additionalShellMfTypesTsConfig = readJson( - TsConfigSchema, - `${shell.path}/tsconfig.mf-types.json`, - ); - const expectedAdditionalShellReferences = [ - ...sharedPackagePaths, - ...(shell.verticalRefs ?? []) - .flatMap((verticalRef) => { - const referencedVertical = fullStackVerticals.find( - (candidate) => candidate.id === verticalRef, - ); - return referencedVertical === undefined ? [] : [referencedVertical]; - }) - .map((referencedVertical) => referencedVertical.path), - ].map((referencePath) => referenceFrom(shell.path, referencePath)); - assertSameJson( - additionalShellTsConfig.references ?? [], - expectedAdditionalShellReferences, - `${shell.path}/tsconfig.json references`, - 'restore the generated additional-shell project-reference graph', - ); - assertSameJson( - additionalShellTsConfig.include ?? [], - ['src', SHARED_VALIDATOR_STRING_075, SHARED_VALIDATOR_STRING_091, 'shared'], - `${shell.path}/tsconfig.json include`, - 'restore the generated additional-shell typecheck boundary', - ); - assertProjectReferenceEmitConfig(additionalShellTsConfig, shell.path); - assertSameJson( - additionalShellMfTypesTsConfig, - { - extends: SHARED_VALIDATOR_STRING_001, - include: [SHARED_VALIDATOR_STRING_137], - }, - `${shell.path}/tsconfig.mf-types.json`, - 'restore the generated additional-shell Module Federation DTS boundary', - ); + assertAdditionalShellTsConfigReferences(shell); } }; const packageJsonFiles = (startDir: string): string[] => { @@ -5215,9 +4796,47 @@ const packageDependencySections = [ 'optionalDependencies', 'peerDependencies', ] as const; +const observeModernPackageDependencies = ( + packageJson: PackageJson, + relativePath: string, + observedModernPackageNames: Set, +): void => { + const modernPackageNameSet = new Set(workspaceValidationContract.cohort.modernPackages); + const standaloneModernTools = workspaceValidationContract.cohort.standaloneModernTools ?? {}; + for (const packageName of modernDependencyNames(packageJson)) { + observedModernPackageNames.add(packageName); + if (!modernPackageNameSet.has(packageName)) { + const expected = valueForKey(Object.entries(standaloneModernTools), packageName); + assert( + expected !== undefined, + `${relativePath} declares ${packageName} outside package source metadata`, + ); + const declared = packageDependencySections.map( + (section) => packageJson[section]?.[packageName], + ); + assert( + declared.every((specifier) => specifier === undefined || specifier === expected), + `${relativePath} ${packageName} must match standalone package source metadata`, + ); + } + } +}; +const assertModernPackageSpecifiers = (packageJson: PackageJson, relativePath: string): void => { + const modernPackageNames = workspaceValidationContract.cohort.modernPackages; + for (const section of packageDependencySections) { + for (const packageName of modernPackageNames) { + const actual = packageJson[section]?.[packageName]; + if (actual !== undefined) { + assert( + actual === expectedModernPackageSpecifier(packageName), + `${relativePath} ${section}.${packageName} must match package source metadata`, + ); + } + } + } +}; const assertModernPackageCohort = () => { const modernPackageNames = workspaceValidationContract.cohort.modernPackages; - const modernPackageNameSet = new Set(modernPackageNames); const standaloneModernTools = workspaceValidationContract.cohort.standaloneModernTools ?? {}; const observedModernPackageNames = new Set(); const observedAppIds: string[] = []; @@ -5239,34 +4858,8 @@ const assertModernPackageCohort = () => { ) { observedAppIds.push(packageJson.modernjs.appId); } - for (const packageName of modernDependencyNames(packageJson)) { - observedModernPackageNames.add(packageName); - if (!modernPackageNameSet.has(packageName)) { - const expected = valueForKey(Object.entries(standaloneModernTools), packageName); - assert( - expected !== undefined, - `${relativePath} declares ${packageName} outside package source metadata`, - ); - const declared = packageDependencySections.map( - (section) => packageJson[section]?.[packageName], - ); - assert( - declared.every((specifier) => specifier === undefined || specifier === expected), - `${relativePath} ${packageName} must match standalone package source metadata`, - ); - } - } - for (const section of packageDependencySections) { - for (const packageName of modernPackageNames) { - const actual = packageJson[section]?.[packageName]; - if (actual !== undefined) { - assert( - actual === expectedModernPackageSpecifier(packageName), - `${relativePath} ${section}.${packageName} must match package source metadata`, - ); - } - } - } + observeModernPackageDependencies(packageJson, relativePath, observedModernPackageNames); + assertModernPackageSpecifiers(packageJson, relativePath); } for (const packageName of modernPackageNames) { @@ -6187,36 +5780,25 @@ const sourceRegexCanFollow = (scanner: SourceScanner): boolean => { scanner.regexPrecedingKeywords.has(scanner.currentWord) ); }; -const scanSourceCodeCharacter = ( - scanner: SourceScanner, - character: string, - next: string, -): number => { - if (character === '/' && next === '/') { +const scanSourceSlash = (scanner: SourceScanner, next: string): number | undefined => { + if (next === '/') { scanner.state = 'line'; return 1; } - if (character === '/' && next === '*') { + if (next === '*') { scanner.state = 'block'; scanner.result += ' '; return 1; } - if (character === '/' && sourceRegexCanFollow(scanner)) { + if (sourceRegexCanFollow(scanner)) { scanner.state = 'regex'; scanner.regexInClass = false; - emitSourceCodeCharacter(scanner, character); - return 0; - } - if (character === "'" || character === '"') { - scanner.state = character === "'" ? 'single' : 'double'; - scanner.result += character; - return 0; - } - if (character === '`') { - scanner.state = 'template'; - scanner.result += character; + emitSourceCodeCharacter(scanner, '/'); return 0; } + return undefined; +}; +const scanSourceInterpolation = (scanner: SourceScanner, character: string): boolean => { if ( character === '}' && scanner.interpolations.length > 0 && @@ -6227,7 +5809,7 @@ const scanSourceCodeCharacter = ( scanner.result += character; scanner.lastSignificant = character; scanner.currentWord = ''; - return 0; + return true; } if (scanner.interpolations.length > 0) { const lastIndex = scanner.interpolations.length - 1; @@ -6237,7 +5819,32 @@ const scanSourceCodeCharacter = ( scanner.interpolations[lastIndex] -= 1; } } - emitSourceCodeCharacter(scanner, character); + return false; +}; +const scanSourceCodeCharacter = ( + scanner: SourceScanner, + character: string, + next: string, +): number => { + if (character === '/') { + const consumed = scanSourceSlash(scanner, next); + if (consumed !== undefined) { + return consumed; + } + } + if (character === "'" || character === '"') { + scanner.state = character === "'" ? 'single' : 'double'; + scanner.result += character; + return 0; + } + if (character === '`') { + scanner.state = 'template'; + scanner.result += character; + return 0; + } + if (!scanSourceInterpolation(scanner, character)) { + emitSourceCodeCharacter(scanner, character); + } return 0; }; const scanSourceLineComment = (scanner: SourceScanner, character: string): number => { @@ -6492,41 +6099,49 @@ const assertThinShellPolicy = (policy: StructuralShellPolicy): void => { } } }; +const assertFederatedCompositionFile = ( + file: string, + host: FederatedCompositionSourcePolicy['hosts'][number], + policy: FederatedCompositionSourcePolicy, +): void => { + const source = stripSourceComments(fs.readFileSync(file, 'utf-8')); + const relative = path.relative(root, file).split(path.sep).join('/'); + for (const pattern of policy.forbiddenSourcePatterns) { + const match = new RegExp(pattern.expression, pattern.flags).exec(source); + assert( + match === null, + selfCheckFailure( + `federated composition ${pattern.id}`, + `${pattern.diagnostic} Found forbidden source at ${relative}:${match?.index ?? 0}`, + 'compose remote rendering through framework Module Federation primitives', + ), + ); + } + // Declaration files cannot execute. Ambient federation declarations may + // re-export a workspace component solely to preserve its public prop + // type, so their specifiers are not runtime implementation imports. + if (!/\.d\.(?:ts|mts|cts)$/u.test(relative)) { + for (const specifier of runtimeModuleSpecifiers(source)) { + const remote = remoteImplementationFor(specifier, host.remotes); + assert( + remote === undefined, + selfCheckFailure( + 'federated composition remote-runtime-package-import', + `Host ${host.id} imports remote render implementation ${specifier} from ${remote?.id} at ${relative}`, + 'use import type for contracts or compose the implementation through Module Federation', + ), + ); + } + } +}; const assertFederatedCompositionSourcePolicy = (policy: FederatedCompositionSourcePolicy): void => { for (const host of policy.hosts) { const srcAbsolute = path.join(root, host.srcDir); - if (fs.existsSync(srcAbsolute)) { - for (const file of collectSourceFiles(srcAbsolute)) { - const source = stripSourceComments(fs.readFileSync(file, 'utf-8')); - const relative = path.relative(root, file).split(path.sep).join('/'); - for (const pattern of policy.forbiddenSourcePatterns) { - const match = new RegExp(pattern.expression, pattern.flags).exec(source); - assert( - match === null, - selfCheckFailure( - `federated composition ${pattern.id}`, - `${pattern.diagnostic} Found forbidden source at ${relative}:${match?.index ?? 0}`, - 'compose remote rendering through framework Module Federation primitives', - ), - ); - } - // Declaration files cannot execute. Ambient federation declarations may - // re-export a workspace component solely to preserve its public prop - // type, so their specifiers are not runtime implementation imports. - if (!/\.d\.(?:ts|mts|cts)$/u.test(relative)) { - for (const specifier of runtimeModuleSpecifiers(source)) { - const remote = remoteImplementationFor(specifier, host.remotes); - assert( - remote === undefined, - selfCheckFailure( - 'federated composition remote-runtime-package-import', - `Host ${host.id} imports remote render implementation ${specifier} from ${remote?.id} at ${relative}`, - 'use import type for contracts or compose the implementation through Module Federation', - ), - ); - } - } - } + if (!fs.existsSync(srcAbsolute)) { + continue; + } + for (const file of collectSourceFiles(srcAbsolute)) { + assertFederatedCompositionFile(file, host, policy); } } }; @@ -6661,6 +6276,24 @@ const assertAdditionalShellOwnerAndDeliveryUnit = ( ); return true; }; +const assertAdditionalShellPackage = (shell: (typeof expectedAdditionalShells)[number]): void => { + const packagePath = `${shell.path}/package.json`; + const packageJson = readJson(PackageJsonSchema, packagePath); + assert(packageJson.name === shell.packageName, `${shell.id} package name is incorrect`); + assert( + packageJson.modernjs?.appId === shell.id, + `${shell.id} package modernjs.appId is incorrect`, + ); + assert(packageJson.modernjs?.role === 'shell', `${shell.id} package modernjs.role must be shell`); + assert( + packageJson.scripts?.[SHARED_VALIDATOR_STRING_060] === SHARED_VALIDATOR_STRING_144, + `${shell.id} must expose cloudflare:deploy`, + ); + assert( + packageJson.scripts?.[SHARED_VALIDATOR_STRING_061]?.includes(`--app ${shell.id}`) ?? false, + `${shell.id} must expose cloudflare:proof`, + ); +}; const assertAdditionalShellContract = ( shell: (typeof expectedAdditionalShells)[number], configuredShellById: ReadonlyMap, @@ -6714,23 +6347,7 @@ const assertAdditionalShellContract = ( return; } - const packagePath = `${shell.path}/package.json`; - const packageJson = readJson(PackageJsonSchema, packagePath); - assert(packageJson.name === shell.packageName, `${shell.id} package name is incorrect`); - assert( - packageJson.modernjs?.appId === shell.id, - `${shell.id} package modernjs.appId is incorrect`, - ); - assert(packageJson.modernjs?.role === 'shell', `${shell.id} package modernjs.role must be shell`); - assert( - packageJson.scripts?.[SHARED_VALIDATOR_STRING_060] === SHARED_VALIDATOR_STRING_144, - `${shell.id} must expose cloudflare:deploy`, - ); - assert( - packageJson.scripts?.[SHARED_VALIDATOR_STRING_061]?.includes(`--app ${shell.id}`) ?? false, - `${shell.id} must expose cloudflare:proof`, - ); - + assertAdditionalShellPackage(shell); const buildArtifact = readJson( BuildArtifactSchema, `${shell.path}/shared/ultramodern-build.json`, @@ -6776,7 +6393,7 @@ const assertAdditionalShellContract = ( assertAdditionalShellSources(shell); }; -const assertAdditionalShellCohort = () => { +const configuredShellPorts = () => { const primaryShellConfig = findById( ultramodernConfig.topology?.apps, SHARED_VALIDATOR_STRING_131, @@ -6807,6 +6424,41 @@ const assertAdditionalShellCohort = () => { portsByValue.set(port, id); } + return { portsByValue, primaryShellConfig }; +}; +const assertAdditionalShellZeropsServices = (): void => { + // Zerops artifacts exist whenever the workspace has delivery units at all + // (ui-only and horizontal-remote units deploy too); a shell-only workspace + // must not carry one. + if (hasDeliveryUnits) { + assertExists(SHARED_VALIDATOR_STRING_174); + const zeropsYaml = readText(SHARED_VALIDATOR_STRING_174); + assert( + zeropsYaml.includes(`setup: ${quoteYamlString(SHARED_VALIDATOR_STRING_132)}`), + 'shell-super-app must have a Zerops service', + ); + for (const shell of expectedAdditionalShells) { + const runtimePath = `.zerops/runtime/${shell.id}`; + assert( + zeropsYaml.includes(`setup: ${quoteYamlString(shell.id)}`), + `${shell.id} must have a Zerops service`, + ); + assert( + zeropsYaml.includes(`start: cd ${quoteShellValue(runtimePath)} && npm run serve`), + `${shell.id} Zerops service start command is missing`, + ); + assert( + zeropsYaml.includes(` ${shell.portEnv}: ${quoteYamlString(String(shell.port))}`), + `${shell.id} Zerops service port environment is missing`, + ); + } + } else { + assertNotExists(SHARED_VALIDATOR_STRING_174); + } +}; +const assertAdditionalShellCohort = () => { + const { portsByValue, primaryShellConfig } = configuredShellPorts(); + if (expectedAdditionalShellIds.length === 0) { assert( ultramodernConfig.shells === undefined, @@ -6872,34 +6524,7 @@ const assertAdditionalShellCohort = () => { ); } - // Zerops artifacts exist whenever the workspace has delivery units at all - // (ui-only and horizontal-remote units deploy too); a shell-only workspace - // must not carry one. - if (hasDeliveryUnits) { - assertExists(SHARED_VALIDATOR_STRING_174); - const zeropsYaml = readText(SHARED_VALIDATOR_STRING_174); - assert( - zeropsYaml.includes(`setup: ${quoteYamlString(SHARED_VALIDATOR_STRING_132)}`), - 'shell-super-app must have a Zerops service', - ); - for (const shell of expectedAdditionalShells) { - const runtimePath = `.zerops/runtime/${shell.id}`; - assert( - zeropsYaml.includes(`setup: ${quoteYamlString(shell.id)}`), - `${shell.id} must have a Zerops service`, - ); - assert( - zeropsYaml.includes(`start: cd ${quoteShellValue(runtimePath)} && npm run serve`), - `${shell.id} Zerops service start command is missing`, - ); - assert( - zeropsYaml.includes(` ${shell.portEnv}: ${quoteYamlString(String(shell.port))}`), - `${shell.id} Zerops service port environment is missing`, - ); - } - } else { - assertNotExists(SHARED_VALIDATOR_STRING_174); - } + assertAdditionalShellZeropsServices(); }; assertAdditionalShellCohort(); assert( @@ -7441,16 +7066,12 @@ if (hasDeliveryUnits) { const performanceReadinessConfig = readText(SHARED_VALIDATOR_STRING_121); const assertToolWrapper = (scriptPath: string, command: string): void => { const source = readText(scriptPath); - assert(source.includes('modern-js-create'), `${scriptPath} must delegate to modern-js-create`); assert( - source.includes('ULTRAMODERN_CREATE_BIN'), - `${scriptPath} must support local create-bin overrides for generated-workspace tests`, + hasUltramodernDispatch(source, command, readText('scripts/shared/ultramodern-command.mts')) || + (command === 'skills' && + hasUltramodernSkillsDispatch(source, readText('scripts/shared/ultramodern-launch.mts'))), + `${scriptPath} must delegate ${command} through the override-aware UltraModern runner`, ); - assert( - source.includes("'ultramodern'") || source.includes('"ultramodern"'), - `${scriptPath} must dispatch through the UltraModern tool surface`, - ); - assert(source.includes(command), `${scriptPath} must dispatch ${command}`); }; assert( performanceReadinessConfig.includes('UltramodernPerformanceReadinessDiagnosticsConfig'), @@ -7685,7 +7306,9 @@ assert( 'Shell route metadata compatibility manifest must be marked generated', ); assert( - shellRouteMetadata.includes("authoring: 'colocated-route-meta'"), + shellRouteMetadata.includes( + 'Author route metadata in colocated src/routes/**/route.meta.ts files.', + ), 'Shell route metadata manifest must advertise colocated authoring', ); const expectedZephyrDependencies = Object.fromEntries( @@ -8125,7 +7748,9 @@ for (const vertical of fullStackVerticals) { `${vertical.id} route metadata compatibility manifest must be marked generated`, ); assert( - routeMetadata.includes("authoring: 'colocated-route-meta'"), + routeMetadata.includes( + 'Author route metadata in colocated src/routes/**/route.meta.ts files.', + ), `${vertical.id} route metadata manifest must advertise colocated authoring`, ); } diff --git a/app/scripts/verify-cloudflare-output.mts b/app/scripts/verify-cloudflare-output.mts index 46797a966..c45c5b24c 100644 --- a/app/scripts/verify-cloudflare-output.mts +++ b/app/scripts/verify-cloudflare-output.mts @@ -1,7 +1,7 @@ #!/usr/bin/env node import { NodeServices } from '@effect/platform-node'; -import { Config, Console, Effect, Exit, Option, Path, Schema, Stdio } from 'effect'; -import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; +import { Effect, Schema } from 'effect'; +import { runUltramodernScript, ultramodernExitCode } from './shared/ultramodern-command.mts'; class CloudflareOutputVerificationLaunchError extends Schema.TaggedError()( 'CloudflareOutputVerificationLaunchError', @@ -11,67 +11,13 @@ class CloudflareOutputVerificationLaunchError extends Schema.TaggedError new CloudflareOutputVerificationLaunchError({ reason }); -const program = Effect.gen(function* verifyCloudflareOutputEffect() { - const path = yield* Path.Path; - const stdio = yield* Stdio.Stdio; - const processSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const moduleDirectory = yield* path - .fromFileUrl(new URL('.', import.meta.url)) - .pipe( - Effect.mapError(() => failure('Unable to resolve the Cloudflare output verifier directory')), - ); - const defaultWorkspaceRoot = path.resolve(moduleDirectory, '..'); - const workspaceRoot = yield* Config.string('ULTRAMODERN_WORKSPACE_ROOT').pipe( - Config.withDefault(defaultWorkspaceRoot), - Effect.mapError(() => failure('ULTRAMODERN_WORKSPACE_ROOT is invalid')), - ); - const createBin = yield* Config.string('ULTRAMODERN_CREATE_BIN').pipe( - Config.option, - Effect.map(Option.filter((value) => value.length > 0)), - Effect.mapError(() => failure('ULTRAMODERN_CREATE_BIN is invalid')), - ); - const forwardedArgs = yield* stdio.args; - const ultramodernArgs = ['ultramodern', 'cloudflare-output-verify', ...forwardedArgs]; - const executable = Option.isSome(createBin) ? process.execPath : 'modern-js-create'; - const executableArgs = Option.isSome(createBin) - ? [createBin.value, ...ultramodernArgs] - : ultramodernArgs; - const launchTarget = Option.isSome(createBin) - ? `${process.execPath} with ULTRAMODERN_CREATE_BIN=${createBin.value}` - : 'modern-js-create from PATH'; - - return Number( - yield* processSpawner - .exitCode( - ChildProcess.make(executable, executableArgs, { - env: { ULTRAMODERN_WORKSPACE_ROOT: workspaceRoot }, - extendEnv: true, - shell: Option.isNone(createBin) && path.sep === '\\', - stderr: 'inherit', - stdin: 'inherit', - stdout: 'inherit', - }), - ) - .pipe( - Effect.mapError((error) => - failure( - `Failed to launch ${launchTarget} for UltraModern command "${ultramodernArgs - .slice(1) - .join(' ')}": ${String(error)}`, - ), - ), - ), - ); -}); - const exit = await Effect.runPromiseExit( - program.pipe( - Effect.tapError((error) => Console.error(error.reason)), - Effect.provide(NodeServices.layer), - Effect.scoped, - ), + runUltramodernScript({ + command: 'cloudflare-output-verify', + directoryFailure: 'Unable to resolve the Cloudflare output verifier directory', + failure, + moduleUrl: import.meta.url, + nodeExecutable: process.execPath, + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); -process.exitCode = Exit.match(exit, { - onFailure: () => 1, - onSuccess: (status) => status, -}); +process.exitCode = ultramodernExitCode(exit); diff --git a/app/tools/oxlint/anti-slop/rules/no-module-mocking.ts b/app/tools/oxlint/anti-slop/rules/no-module-mocking.ts index d6fb5b45c..995385b7c 100644 --- a/app/tools/oxlint/anti-slop/rules/no-module-mocking.ts +++ b/app/tools/oxlint/anti-slop/rules/no-module-mocking.ts @@ -44,25 +44,27 @@ function isTestFrameworkObject( } const source = definition.parent.source.value; const name = importedName(definition.node); - return (source === "vitest" && name === "vi") || (source === "@jest/globals" && name === "jest"); + return ( + (source === "vitest" && name === "vi") || (source === "@jest/globals" && name === "jest") + ); }); } function moduleMockCall(sourceCode: SourceCode, callee: ESTree.Expression): boolean { if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) return false; if (!isTestFrameworkObject(sourceCode, callee.object)) return false; - const property = callee.property; - const method = callee.computed - ? property.type === "Literal" && - (property.value === "doMock" || - property.value === "mock" || - property.value === "unstable_mockModule") - ? property.value - : null - : property.type === "Identifier" - ? property.name - : null; - return method !== null && moduleMockMethods.has(method); + return isModuleMockProperty(callee.property, callee.computed); +} + +function isModuleMockProperty(property: ESTree.Node, computed: boolean): boolean { + if (computed) { + return ( + property.type === "Literal" && + typeof property.value === "string" && + moduleMockMethods.has(property.value) + ); + } + return property.type === "Identifier" && moduleMockMethods.has(property.name); } /** Ban test framework module mocking in favor of real dependency seams. */ diff --git a/app/tools/oxlint/anti-slop/rules/no-object-parameters.ts b/app/tools/oxlint/anti-slop/rules/no-object-parameters.ts index 29b990f33..6e1a1d35f 100644 --- a/app/tools/oxlint/anti-slop/rules/no-object-parameters.ts +++ b/app/tools/oxlint/anti-slop/rules/no-object-parameters.ts @@ -2,125 +2,96 @@ import { defineRule } from "@oxlint/plugins"; import type { ESTree, SourceCode } from "@oxlint/plugins"; -import { lexicalTypeParameterNames } from "../shared/lexical-type-parameters.ts"; +import { unshadowedAliasName } from "../shared/type-alias-reference.ts"; -type Parameter = ESTree.ParamPattern; -type ParameterOwner = - | ESTree.ArrowFunctionExpression - | ESTree.Function - | ESTree.TSCallSignatureDeclaration - | ESTree.TSConstructSignatureDeclaration - | ESTree.TSConstructorType - | ESTree.TSFunctionType - | ESTree.TSMethodSignature; +import { lexicalTypeParameterNames } from "../shared/lexical-type-parameters.ts"; -function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined { - if (parameter.type === "TSParameterProperty") { - return parameterAnnotation(parameter.parameter); - } - if (parameter.type === "RestElement") { - return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument); - } - if (parameter.type === "AssignmentPattern") { - return parameter.typeAnnotation ?? parameter.left.typeAnnotation; - } - return parameter.typeAnnotation; -} +import { + parameterAnnotation, + type Parameter, + type ParameterOwner, +} from "../shared/function-parameters.ts"; function parameterName(parameter: Parameter, sourceCode: SourceCode): string { - return parameter.type === "Identifier" - ? parameter.name - : sourceCode.getText(parameter).replace(/\s*:\s*object\s*$/u, ""); + return parameter.type === "Identifier" + ? parameter.name + : sourceCode.getText(parameter).replace(/\s*:\s*object\s*$/u, ""); } /** Ban the broad object type on function inputs, including local aliases to object. */ export const noObjectParametersRule = defineRule({ - meta: { - type: "problem", - docs: { - description: - "Disallow object function parameters; inputs must use an owner-provided type and be parsed at their boundary.", - }, - messages: { - objectParameter: - "Parameter `{{parameter}}` uses the broad `object` type. Accept a named owner type; parse external input at its boundary before calling this function.", - }, - }, - createOnce(context) { - const aliases = new Map(); + meta: { + type: "problem", + docs: { + description: + "Disallow object function parameters; inputs must use an owner-provided type and be parsed at their boundary.", + }, + messages: { + objectParameter: + "Parameter `{{parameter}}` uses the broad `object` type. Accept a named owner type; parse external input at its boundary before calling this function.", + }, + }, + createOnce(context) { + const aliases = new Map(); - const resolvesToObject = ( - type: ESTree.TSType, - shadowedAliases: ReadonlySet, - visited = new Set(), - ): boolean => { - if (type.type === "TSObjectKeyword") return true; - if (type.type === "TSParenthesizedType") - return resolvesToObject(type.typeAnnotation, shadowedAliases, visited); - if (type.type === "TSUnionType") { - return type.types.some((member) => - resolvesToObject(member, shadowedAliases, visited), - ); - } - if ( - type.type !== "TSTypeReference" || - type.typeName.type !== "Identifier" || - (type.typeArguments !== null && - type.typeArguments !== undefined && - type.typeArguments.params.length > 0) || - visited.has(type.typeName.name) || - shadowedAliases.has(type.typeName.name) - ) { - return false; - } - const alias = aliases.get(type.typeName.name); - if (alias === undefined) return false; - const nextVisited = new Set(visited); - nextVisited.add(type.typeName.name); - return resolvesToObject(alias, shadowedAliases, nextVisited); - }; + const resolvesToObject = ( + type: ESTree.TSType, + shadowedAliases: ReadonlySet, + visited = new Set(), + ): boolean => { + if (type.type === "TSObjectKeyword") return true; + if (type.type === "TSParenthesizedType") + return resolvesToObject(type.typeAnnotation, shadowedAliases, visited); + if (type.type === "TSUnionType") { + return type.types.some((member) => resolvesToObject(member, shadowedAliases, visited)); + } + const name = unshadowedAliasName(type, shadowedAliases, visited); + if (name === null) return false; + const alias = aliases.get(name); + if (alias === undefined) return false; + const nextVisited = new Set(visited); + nextVisited.add(name); + return resolvesToObject(alias, shadowedAliases, nextVisited); + }; - const checkParameters = (node: ParameterOwner) => { - const shadowedAliases = lexicalTypeParameterNames( - node, - context.sourceCode.visitorKeys, - ); - for (const parameter of node.params) { - const annotation = parameterAnnotation(parameter); - if (annotation === null || annotation === undefined) continue; - if (!resolvesToObject(annotation.typeAnnotation, shadowedAliases)) continue; - context.report({ - node: annotation.typeAnnotation, - messageId: "objectParameter", - data: { parameter: parameterName(parameter, context.sourceCode) }, - }); - } - }; + const checkParameters = (node: ParameterOwner) => { + const shadowedAliases = lexicalTypeParameterNames(node, context.sourceCode.visitorKeys); + for (const parameter of node.params) { + const annotation = parameterAnnotation(parameter); + if (annotation === null || annotation === undefined) continue; + if (!resolvesToObject(annotation.typeAnnotation, shadowedAliases)) continue; + context.report({ + node: annotation.typeAnnotation, + messageId: "objectParameter", + data: { parameter: parameterName(parameter, context.sourceCode) }, + }); + } + }; - return { - Program(node) { - aliases.clear(); - for (const statement of node.body) { - const declaration = - statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; - if ( - declaration?.type === "TSTypeAliasDeclaration" && - (declaration.typeParameters === null || declaration.typeParameters === undefined) - ) { - aliases.set(declaration.id.name, declaration.typeAnnotation); - } - } - }, - ArrowFunctionExpression: checkParameters, - FunctionDeclaration: checkParameters, - FunctionExpression: checkParameters, - TSCallSignatureDeclaration: checkParameters, - TSConstructSignatureDeclaration: checkParameters, - TSConstructorType: checkParameters, - TSDeclareFunction: checkParameters, - TSEmptyBodyFunctionExpression: checkParameters, - TSFunctionType: checkParameters, - TSMethodSignature: checkParameters, - }; - }, + return { + Program(node) { + aliases.clear(); + for (const statement of node.body) { + const declaration = + statement.type === "ExportNamedDeclaration" ? statement.declaration : statement; + if ( + declaration?.type === "TSTypeAliasDeclaration" && + (declaration.typeParameters === null || declaration.typeParameters === undefined) + ) { + aliases.set(declaration.id.name, declaration.typeAnnotation); + } + } + }, + ArrowFunctionExpression: checkParameters, + FunctionDeclaration: checkParameters, + FunctionExpression: checkParameters, + TSCallSignatureDeclaration: checkParameters, + TSConstructSignatureDeclaration: checkParameters, + TSConstructorType: checkParameters, + TSDeclareFunction: checkParameters, + TSEmptyBodyFunctionExpression: checkParameters, + TSFunctionType: checkParameters, + TSMethodSignature: checkParameters, + }; + }, }); diff --git a/app/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts b/app/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts index cdc6c2351..d3a740e7c 100644 --- a/app/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts +++ b/app/tools/oxlint/anti-slop/rules/no-unknown-parameters.ts @@ -1,28 +1,10 @@ import { defineRule } from "@oxlint/plugins"; -import type { ESTree } from "@oxlint/plugins"; -type Parameter = ESTree.ParamPattern; -type ParameterOwner = - | ESTree.ArrowFunctionExpression - | ESTree.Function - | ESTree.TSCallSignatureDeclaration - | ESTree.TSConstructSignatureDeclaration - | ESTree.TSConstructorType - | ESTree.TSFunctionType - | ESTree.TSMethodSignature; - -function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined { - if (parameter.type === "TSParameterProperty") { - return parameterAnnotation(parameter.parameter); - } - if (parameter.type === "RestElement") { - return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument); - } - if (parameter.type === "AssignmentPattern") { - return parameter.typeAnnotation ?? parameter.left.typeAnnotation; - } - return parameter.typeAnnotation; -} +import { + parameterAnnotation, + type Parameter, + type ParameterOwner, +} from "../shared/function-parameters.ts"; function parameterName(parameter: Parameter, sourceText: string): string { if (parameter.type === "TSParameterProperty") { diff --git a/app/tools/oxlint/anti-slop/rules/no-unknown-returns.ts b/app/tools/oxlint/anti-slop/rules/no-unknown-returns.ts index 4b16d6ef3..a25389f28 100644 --- a/app/tools/oxlint/anti-slop/rules/no-unknown-returns.ts +++ b/app/tools/oxlint/anti-slop/rules/no-unknown-returns.ts @@ -2,6 +2,8 @@ import { defineRule } from "@oxlint/plugins"; import type { ESTree } from "@oxlint/plugins"; +import { unshadowedAliasName } from "../shared/type-alias-reference.ts"; + import { lexicalTypeParameterNames } from "../shared/lexical-type-parameters.ts"; type FunctionWithReturnType = @@ -13,14 +15,19 @@ type FunctionWithReturnType = | ESTree.TSFunctionType | ESTree.TSMethodSignature; -function referencedAliasName(type: ESTree.TSType): string | null { - if (type.type === "TSParenthesizedType") return referencedAliasName(type.typeAnnotation); - if (type.type !== "TSTypeReference" || type.typeName.type !== "Identifier") return null; - return type.typeArguments === null || - type.typeArguments === undefined || - type.typeArguments.params.length === 0 - ? type.typeName.name - : null; +function isPromiseReference(type: ESTree.TSType): type is ESTree.TSTypeReference { + return ( + type.type === "TSTypeReference" && + type.typeName.type === "Identifier" && + (type.typeName.name === "Promise" || type.typeName.name === "PromiseLike") + ); +} + +function nonGenericAliasType( + alias: ESTree.TSTypeAliasDeclaration | undefined, +): ESTree.TSType | undefined { + if (alias === undefined || alias.typeParameters != null) return undefined; + return alias.typeAnnotation; } /** Ban function contracts that return unknown instead of a parsed domain type. */ @@ -49,30 +56,19 @@ export const noUnknownReturnsRule = defineRule({ return resolvesToUnknown(type.typeAnnotation, shadowedAliases, visited); } if (type.type === "TSUnionType") { - return type.types.some((member) => - resolvesToUnknown(member, shadowedAliases, visited), - ); + return type.types.some((member) => resolvesToUnknown(member, shadowedAliases, visited)); } - if ( - type.type === "TSTypeReference" && - type.typeName.type === "Identifier" && - (type.typeName.name === "Promise" || type.typeName.name === "PromiseLike") - ) { + if (isPromiseReference(type)) { const value = type.typeArguments?.params[0]; return value !== undefined && resolvesToUnknown(value, shadowedAliases, visited); } - const name = referencedAliasName(type); - if (name === null || visited.has(name) || shadowedAliases.has(name)) return false; - const alias = aliases.get(name); - if ( - alias === undefined || - (alias.typeParameters !== null && alias.typeParameters !== undefined) - ) { - return false; - } + const name = unshadowedAliasName(type, shadowedAliases, visited); + if (name === null) return false; + const alias = nonGenericAliasType(aliases.get(name)); + if (alias === undefined) return false; const nextVisited = new Set(visited); nextVisited.add(name); - return resolvesToUnknown(alias.typeAnnotation, shadowedAliases, nextVisited); + return resolvesToUnknown(alias, shadowedAliases, nextVisited); }; const checkReturnType = (node: FunctionWithReturnType) => { diff --git a/app/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts b/app/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts index c5e07f7fc..5f092801c 100644 --- a/app/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts +++ b/app/tools/oxlint/anti-slop/rules/no-widen-then-assert.ts @@ -1,79 +1,81 @@ -import { defineRule } from "@oxlint/plugins"; -import type { ESTree, Variable } from "@oxlint/plugins"; +import { defineRule } from '@oxlint/plugins'; +import type { ESTree, Variable } from '@oxlint/plugins'; -type BroadTypeKind = "top" | "object" | "record"; +type BroadTypeKind = 'top' | 'object' | 'record'; type KnownValueEvidence = { readonly type: ESTree.TSType | null; }; const functionBoundaryTypes = new Set([ - "ArrowFunctionExpression", - "FunctionDeclaration", - "FunctionExpression", - "TSDeclareFunction", - "TSEmptyBodyFunctionExpression", + 'ArrowFunctionExpression', + 'FunctionDeclaration', + 'FunctionExpression', + 'TSDeclareFunction', + 'TSEmptyBodyFunctionExpression', ]); function unwrapExpressionParentheses(expression: ESTree.Expression): ESTree.Expression { let current = expression; - while (current.type === "ParenthesizedExpression") current = current.expression; + while (current.type === 'ParenthesizedExpression') current = current.expression; return current; } function unwrapTypeParentheses(type: ESTree.TSType): ESTree.TSType { let current = type; - while (current.type === "TSParenthesizedType") current = current.typeAnnotation; + while (current.type === 'TSParenthesizedType') current = current.typeAnnotation; return current; } function typeReferenceName(type: ESTree.TSTypeReference): string | null { - return type.typeName.type === "Identifier" ? type.typeName.name : null; + return type.typeName.type === 'Identifier' ? type.typeName.name : null; } function isUnknownOrAnyType(type: ESTree.TSType): boolean { const unwrapped = unwrapTypeParentheses(type); - return unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword"; + return unwrapped.type === 'TSUnknownKeyword' || unwrapped.type === 'TSAnyKeyword'; } function isBroadRecordKeyType(type: ESTree.TSType): boolean { const unwrapped = unwrapTypeParentheses(type); if ( - unwrapped.type === "TSStringKeyword" || - unwrapped.type === "TSNumberKeyword" || - unwrapped.type === "TSSymbolKeyword" + unwrapped.type === 'TSStringKeyword' || + unwrapped.type === 'TSNumberKeyword' || + unwrapped.type === 'TSSymbolKeyword' ) { return true; } - if (unwrapped.type === "TSUnionType") return unwrapped.types.every(isBroadRecordKeyType); - return unwrapped.type === "TSTypeReference" && typeReferenceName(unwrapped) === "PropertyKey"; + if (unwrapped.type === 'TSUnionType') return unwrapped.types.every(isBroadRecordKeyType); + return unwrapped.type === 'TSTypeReference' && typeReferenceName(unwrapped) === 'PropertyKey'; } -function isBroadRecordType(type: ESTree.TSType): boolean { - const unwrapped = unwrapTypeParentheses(type); - - if (unwrapped.type === "TSTypeReference") { - if (typeReferenceName(unwrapped) === "Readonly") { - const [inner] = unwrapped.typeArguments?.params ?? []; - return inner !== undefined && isBroadRecordType(inner); - } - - if (typeReferenceName(unwrapped) !== "Record") return false; - const parameters = unwrapped.typeArguments?.params ?? []; - return ( - parameters.length === 2 && - parameters[0] !== undefined && - parameters[1] !== undefined && - isBroadRecordKeyType(parameters[0]) && - isUnknownOrAnyType(parameters[1]) - ); +function isBroadRecordReference(type: ESTree.TSTypeReference): boolean { + if (typeReferenceName(type) === 'Readonly') { + const [inner] = type.typeArguments?.params ?? []; + return inner !== undefined && isBroadRecordType(inner); } + if (typeReferenceName(type) !== 'Record') return false; + return hasBroadRecordArguments(type); +} - if (unwrapped.type !== "TSTypeLiteral" || unwrapped.members.length !== 1) return false; - const [member] = unwrapped.members; - const [parameter] = member?.type === "TSIndexSignature" ? member.parameters : []; +function hasBroadRecordArguments(type: ESTree.TSTypeReference): boolean { + const parameters = type.typeArguments?.params ?? []; + const [key, value] = parameters; + return ( + parameters.length === 2 && + key !== undefined && + value !== undefined && + isBroadRecordKeyType(key) && + isUnknownOrAnyType(value) + ); +} + +function isBroadRecordLiteral(type: ESTree.TSTypeLiteral): boolean { + if (type.members.length !== 1) return false; + const [member] = type.members; + if (member?.type !== 'TSIndexSignature') return false; + const [parameter] = member.parameters; return ( - member?.type === "TSIndexSignature" && member.parameters.length === 1 && parameter !== undefined && isBroadRecordKeyType(parameter.typeAnnotation.typeAnnotation) && @@ -81,11 +83,17 @@ function isBroadRecordType(type: ESTree.TSType): boolean { ); } +function isBroadRecordType(type: ESTree.TSType): boolean { + const unwrapped = unwrapTypeParentheses(type); + if (unwrapped.type === 'TSTypeReference') return isBroadRecordReference(unwrapped); + return unwrapped.type === 'TSTypeLiteral' && isBroadRecordLiteral(unwrapped); +} + function broadTypeKind(type: ESTree.TSType): BroadTypeKind | null { const unwrapped = unwrapTypeParentheses(type); - if (unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword") return "top"; - if (unwrapped.type === "TSObjectKeyword") return "object"; - return isBroadRecordType(unwrapped) ? "record" : null; + if (unwrapped.type === 'TSUnknownKeyword' || unwrapped.type === 'TSAnyKeyword') return 'top'; + if (unwrapped.type === 'TSObjectKeyword') return 'object'; + return isBroadRecordType(unwrapped) ? 'record' : null; } function assertedExpression( @@ -98,13 +106,13 @@ function assertionFromExpression( expression: ESTree.Expression, ): ESTree.TSAsExpression | ESTree.TSTypeAssertion | null { const unwrapped = unwrapExpressionParentheses(expression); - return unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion" + return unwrapped.type === 'TSAsExpression' || unwrapped.type === 'TSTypeAssertion' ? unwrapped : null; } function normalizedTypeText(sourceText: string, type: ESTree.TSType): string { - return sourceText.slice(type.start, type.end).replaceAll(/\s+/gu, ""); + return sourceText.slice(type.start, type.end).replaceAll(/\s+/gu, ''); } function typesHaveSameSyntax( @@ -119,22 +127,25 @@ function typesHaveSameSyntax( ); } +const definiteObjectTypes = new Set([ + 'TSArrayType', + 'TSConstructorType', + 'TSFunctionType', + 'TSMappedType', + 'TSObjectKeyword', + 'TSTupleType', +]); + function isDefinitelyObjectType(type: ESTree.TSType): boolean { const unwrapped = unwrapTypeParentheses(type); + if (definiteObjectTypes.has(unwrapped.type)) return true; switch (unwrapped.type) { - case "TSArrayType": - case "TSConstructorType": - case "TSFunctionType": - case "TSMappedType": - case "TSObjectKeyword": - case "TSTupleType": - return true; - case "TSTypeLiteral": + case 'TSTypeLiteral': return unwrapped.members.length > 0; - case "TSIntersectionType": + case 'TSIntersectionType': return unwrapped.types.every(isDefinitelyObjectType); - case "TSTypeOperator": - return unwrapped.operator === "readonly" && isDefinitelyObjectType(unwrapped.typeAnnotation); + case 'TSTypeOperator': + return unwrapped.operator === 'readonly' && isDefinitelyObjectType(unwrapped.typeAnnotation); default: return false; } @@ -142,16 +153,19 @@ function isDefinitelyObjectType(type: ESTree.TSType): boolean { function isDefinitelyNarrowerRecordType(type: ESTree.TSType): boolean { const unwrapped = unwrapTypeParentheses(type); - if (unwrapped.type === "TSTypeLiteral") { - return unwrapped.members.some((member) => member.type !== "TSIndexSignature"); + if (unwrapped.type === 'TSTypeLiteral') { + return unwrapped.members.some((member) => member.type !== 'TSIndexSignature'); } - if (unwrapped.type !== "TSTypeReference") return false; - if (typeReferenceName(unwrapped) === "Readonly") { + return unwrapped.type === 'TSTypeReference' && isNarrowerRecordReference(unwrapped); +} + +function isNarrowerRecordReference(unwrapped: ESTree.TSTypeReference): boolean { + if (typeReferenceName(unwrapped) === 'Readonly') { const [inner] = unwrapped.typeArguments?.params ?? []; return inner !== undefined && isDefinitelyNarrowerRecordType(inner); } - if (typeReferenceName(unwrapped) !== "Record") return false; + if (typeReferenceName(unwrapped) !== 'Record') return false; const parameters = unwrapped.typeArguments?.params ?? []; return ( @@ -161,7 +175,7 @@ function isDefinitelyNarrowerRecordType(type: ESTree.TSType): boolean { function functionBoundary(node: ESTree.Node): ESTree.Node | null { let current = node.parent; - while (current !== null && current.type !== "Program") { + while (current !== null && current.type !== 'Program') { if (functionBoundaryTypes.has(current.type)) return current; current = current.parent; } @@ -190,7 +204,7 @@ function resolvedVariableForIdentifier( function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null { for (const definition of variable.defs) { - if (definition.type === "Variable" && definition.node.type === "VariableDeclarator") { + if (definition.type === 'Variable' && definition.node.type === 'VariableDeclarator') { return definition.node; } } @@ -205,27 +219,33 @@ function knownValueEvidence( ): KnownValueEvidence | null { const unwrapped = unwrapExpressionParentheses(expression); - if (unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion") { + if (unwrapped.type === 'TSAsExpression' || unwrapped.type === 'TSTypeAssertion') { if (broadTypeKind(unwrapped.typeAnnotation) !== null) return null; return { type: unwrapped.typeAnnotation }; } - if (unwrapped.type === "Literal" || unwrapped.type === "TemplateLiteral") { - return { type: null }; - } + if (knownExpressionTypes.has(unwrapped.type)) return { type: null }; + if (unwrapped.type !== 'Identifier') return null; + return identifierEvidence(unwrapped, scopes, boundary, visitedVariables); +} - if ( - unwrapped.type === "ArrayExpression" || - unwrapped.type === "ArrowFunctionExpression" || - unwrapped.type === "ClassExpression" || - unwrapped.type === "FunctionExpression" || - unwrapped.type === "NewExpression" || - unwrapped.type === "ObjectExpression" - ) { - return { type: null }; - } +const knownExpressionTypes = new Set([ + 'Literal', + 'TemplateLiteral', + 'ArrayExpression', + 'ArrowFunctionExpression', + 'ClassExpression', + 'FunctionExpression', + 'NewExpression', + 'ObjectExpression', +]); - if (unwrapped.type !== "Identifier") return null; +function identifierEvidence( + unwrapped: ESTree.IdentifierReference, + scopes: Parameters[0], + boundary: ESTree.Node | null, + visitedVariables: ReadonlySet, +): KnownValueEvidence | null { const variable = resolvedVariableForIdentifier(scopes, unwrapped); if (variable === null || visitedVariables.has(variable)) return null; @@ -234,23 +254,11 @@ function knownValueEvidence( ); const annotation = annotatedIdentifier?.typeAnnotation?.typeAnnotation; if (annotation !== undefined && annotatedIdentifier !== undefined) { - if (functionBoundary(annotatedIdentifier) !== boundary || broadTypeKind(annotation) !== null) { - return null; - } - return { type: annotation }; + return annotationEvidence(annotatedIdentifier, annotation, boundary); } - const declarator = variableDeclarator(variable); - if ( - declarator === null || - declarator.parent.type !== "VariableDeclaration" || - declarator.parent.kind !== "const" || - declarator.init === null || - variable.references.some((reference) => reference.isWrite() && !reference.init) || - functionBoundary(declarator) !== boundary - ) { - return null; - } + const declarator = immutableInitializedDeclarator(variable); + if (declarator === null || functionBoundary(declarator) !== boundary) return null; return knownValueEvidence( declarator.init, @@ -260,6 +268,38 @@ function knownValueEvidence( ); } +function annotationEvidence( + identifier: ESTree.Node, + annotation: ESTree.TSType, + boundary: ESTree.Node | null, +): KnownValueEvidence | null { + if (functionBoundary(identifier) !== boundary || broadTypeKind(annotation) !== null) { + return null; + } + return { type: annotation }; +} + +function hasInitializer( + declarator: ESTree.VariableDeclarator, +): declarator is ESTree.VariableDeclarator & { init: ESTree.Expression } { + return declarator.init !== null; +} + +function immutableInitializedDeclarator( + variable: Variable, +): (ESTree.VariableDeclarator & { init: ESTree.Expression }) | null { + const declarator = variableDeclarator(variable); + if (declarator === null || !hasInitializer(declarator)) return null; + if (declarator.parent.type !== 'VariableDeclaration' || declarator.parent.kind !== 'const') + return null; + if (variable.references.some((reference) => reference.isWrite() && !reference.init)) return null; + return declarator; +} + +function optionalBroadTypeKind(type: ESTree.TSType | undefined): BroadTypeKind | null { + return type === undefined ? null : broadTypeKind(type); +} + function widenedBinding( variable: Variable, scopes: Parameters[0], @@ -269,24 +309,15 @@ function widenedBinding( readonly declaredAt: number; readonly boundary: ESTree.Node | null; } | null { - const declarator = variableDeclarator(variable); - if ( - declarator === null || - declarator.parent.type !== "VariableDeclaration" || - declarator.parent.kind !== "const" || - declarator.id.type !== "Identifier" || - declarator.init === null || - variable.references.some((reference) => reference.isWrite() && !reference.init) - ) { - return null; - } + const declarator = immutableInitializedDeclarator(variable); + if (declarator === null || declarator.id.type !== 'Identifier') return null; const boundary = functionBoundary(declarator); const declaredType = declarator.id.typeAnnotation?.typeAnnotation; const initializerAssertion = assertionFromExpression(declarator.init); const initializerBroadKind = initializerAssertion === null ? null : broadTypeKind(initializerAssertion.typeAnnotation); - const declaredBroadKind = declaredType === undefined ? null : broadTypeKind(declaredType); + const declaredBroadKind = optionalBroadTypeKind(declaredType); const broadKind = declaredBroadKind ?? initializerBroadKind; if (broadKind === null) return null; @@ -305,19 +336,19 @@ function assertionIsNarrower( assertedType: ESTree.TSType, ): boolean { if (broadTypeKind(assertedType) !== null) return false; - if (broadKind === "top") return true; + if (broadKind === 'top') return true; if (typesHaveSameSyntax(sourceText, evidence.type, assertedType)) return true; - if (broadKind === "object") return isDefinitelyObjectType(assertedType); + if (broadKind === 'object') return isDefinitelyObjectType(assertedType); return isDefinitelyNarrowerRecordType(assertedType); } /** Detect immutable local bindings that erase a known type and are later asserted back to a narrower type. */ export const noWidenThenAssertRule = defineRule({ meta: { - type: "problem", + type: 'problem', docs: { description: - "Disallow local const flows that explicitly widen a known value before asserting the widened binding to a narrower type.", + 'Disallow local const flows that explicitly widen a known value before asserting the widened binding to a narrower type.', }, messages: { widenThenAssert: @@ -329,7 +360,7 @@ export const noWidenThenAssertRule = defineRule({ const checkAssertion = (node: ESTree.TSAsExpression | ESTree.TSTypeAssertion) => { const expression = assertedExpression(node); - if (expression.type !== "Identifier") return; + if (expression.type !== 'Identifier') return; const variable = resolvedVariableForIdentifier(scopes, expression); if (variable === null) return; @@ -350,7 +381,7 @@ export const noWidenThenAssertRule = defineRule({ context.report({ node, - messageId: "widenThenAssert", + messageId: 'widenThenAssert', data: { name: expression.name }, }); }; diff --git a/app/tools/oxlint/anti-slop/shared/dictionary-types.ts b/app/tools/oxlint/anti-slop/shared/dictionary-types.ts index 865170047..c43e08569 100644 --- a/app/tools/oxlint/anti-slop/shared/dictionary-types.ts +++ b/app/tools/oxlint/anti-slop/shared/dictionary-types.ts @@ -1,502 +1,597 @@ -import type { ESTree } from "@oxlint/plugins"; +import type { ESTree } from '@oxlint/plugins'; const BUILT_INS = new Set([ - "Record", - "Readonly", - "Partial", - "Required", - "Pick", - "Omit", - "PropertyKey", - "NonNullable", + 'Record', + 'Readonly', + 'Partial', + 'Required', + 'Pick', + 'Omit', + 'PropertyKey', + 'NonNullable', ]); -const TRANSPARENT_WRAPPERS = new Set(["Readonly", "Partial", "Required", "NonNullable"]); +const DICTIONARY_WRAPPERS = new Set([ + 'Readonly', + 'Partial', + 'Required', + 'NonNullable', + 'Pick', + 'Omit', +]); +const TRANSPARENT_WRAPPERS = new Set(['Readonly', 'Partial', 'Required', 'NonNullable']); type TypeAliasEnvironment = ReadonlyMap; type ResolvedType = { - readonly type: ESTree.TSType; - readonly substitutions: TypeAliasEnvironment; + readonly type: ESTree.TSType; + readonly substitutions: TypeAliasEnvironment; }; export type UnsafeDictionary = { - readonly kind: "unsafe-dictionary"; - readonly unsafeValue: "any" | "empty-object" | "object" | "union" | "unknown"; + readonly kind: 'unsafe-dictionary'; + readonly unsafeValue: 'any' | 'empty-object' | 'object' | 'union' | 'unknown'; }; -export type WideningTargetKind = - | "anonymous object" - | "generic container" - | "object" - | "open dictionary" - | "unknown"; +type WideningTargetKind = + | 'anonymous object' + | 'generic container' + | 'object' + | 'open dictionary' + | 'unknown'; export type WideningTarget = { - readonly kind: WideningTargetKind; + readonly kind: WideningTargetKind; }; export type TypeEnvironment = { - readonly aliases: ReadonlyMap; - readonly interfaces: ReadonlyMap; - readonly shadowedBuiltIns: ReadonlySet; + readonly aliases: ReadonlyMap; + readonly interfaces: ReadonlyMap; + readonly shadowedBuiltIns: ReadonlySet; }; function declaredStatement(statement: ESTree.Statement): ESTree.Node | null { - return statement.type === "ExportNamedDeclaration" || - statement.type === "ExportDefaultDeclaration" - ? (statement.declaration ?? null) - : statement; + return statement.type === 'ExportNamedDeclaration' || + statement.type === 'ExportDefaultDeclaration' + ? (statement.declaration ?? null) + : statement; } export function createTypeEnvironment(program: ESTree.Program): TypeEnvironment { - const aliases = new Map(); - const interfaces = new Map(); - const shadowedBuiltIns = new Set(); - - for (const statement of program.body) { - const declaration = declaredStatement(statement); - if (declaration?.type === "ImportDeclaration") { - for (const specifier of declaration.specifiers) { - if (BUILT_INS.has(specifier.local.name)) shadowedBuiltIns.add(specifier.local.name); - } - continue; - } - - if (declaration?.type === "TSTypeAliasDeclaration") { - const existing = aliases.get(declaration.id.name); - if (existing === undefined) aliases.set(declaration.id.name, declaration); - else shadowedBuiltIns.add(declaration.id.name); - if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); - continue; - } - - if (declaration?.type === "TSInterfaceDeclaration") { - const declarations = interfaces.get(declaration.id.name) ?? []; - declarations.push(declaration); - interfaces.set(declaration.id.name, declarations); - if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); - continue; - } - - if (declaration?.type === "TSEnumDeclaration") { - if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); - continue; - } - - if ( - (declaration?.type === "ClassDeclaration" || - declaration?.type === "FunctionDeclaration") && - declaration.id !== null - ) { - if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name); - } - } - - return { aliases, interfaces, shadowedBuiltIns }; + const aliases = new Map(); + const interfaces = new Map(); + const shadowedBuiltIns = new Set(); + + for (const statement of program.body) { + recordDeclaration(declaredStatement(statement), aliases, interfaces, shadowedBuiltIns); + } + + return { aliases, interfaces, shadowedBuiltIns }; +} + +function recordShadowedName(name: string, shadowed: Set): void { + if (BUILT_INS.has(name)) shadowed.add(name); +} +function recordDeclaration( + declaration: ESTree.Node | null, + aliases: Map, + interfaces: Map, + shadowed: Set, +): void { + if (declaration === null) return; + switch (declaration.type) { + case 'ImportDeclaration': + for (const specifier of declaration.specifiers) + recordShadowedName(specifier.local.name, shadowed); + return; + case 'TSTypeAliasDeclaration': + if (aliases.has(declaration.id.name)) shadowed.add(declaration.id.name); + else aliases.set(declaration.id.name, declaration); + recordShadowedName(declaration.id.name, shadowed); + return; + case 'TSInterfaceDeclaration': { + const declarations = interfaces.get(declaration.id.name) ?? []; + declarations.push(declaration); + interfaces.set(declaration.id.name, declarations); + recordShadowedName(declaration.id.name, shadowed); + return; + } + default: + recordValueDeclaration(declaration, shadowed); + } +} + +function recordValueDeclaration(declaration: ESTree.Node, shadowed: Set): void { + switch (declaration.type) { + case 'TSEnumDeclaration': + case 'ClassDeclaration': + case 'FunctionDeclaration': + if (declaration.id !== null) recordShadowedName(declaration.id.name, shadowed); + } } function typeReferenceName(type: ESTree.TSTypeReference): string | null { - return type.typeName.type === "Identifier" ? type.typeName.name : null; + return type.typeName.type === 'Identifier' ? type.typeName.name : null; } function isBuiltIn(name: string, environment: TypeEnvironment): boolean { - return BUILT_INS.has(name) && !environment.shadowedBuiltIns.has(name); + return BUILT_INS.has(name) && !environment.shadowedBuiltIns.has(name); } function isUnappliedReferenceTo(type: ESTree.TSType, name: string): boolean { - const unwrapped = unwrapTransparentType(type); - return ( - unwrapped.type === "TSTypeReference" && - typeReferenceName(unwrapped) === name && - (unwrapped.typeArguments === null || - unwrapped.typeArguments === undefined || - unwrapped.typeArguments.params.length === 0) - ); + const unwrapped = unwrapTransparentType(type); + return ( + unwrapped.type === 'TSTypeReference' && + typeReferenceName(unwrapped) === name && + (unwrapped.typeArguments === null || + unwrapped.typeArguments === undefined || + unwrapped.typeArguments.params.length === 0) + ); } function unwrapTransparentType(type: ESTree.TSType): ESTree.TSType { - let current = type; - while ( - current.type === "TSParenthesizedType" || - (current.type === "TSTypeOperator" && current.operator === "readonly") - ) { - current = current.typeAnnotation; - } - return current; + let current = type; + while ( + current.type === 'TSParenthesizedType' || + (current.type === 'TSTypeOperator' && current.operator === 'readonly') + ) { + current = current.typeAnnotation; + } + return current; } function isNeverType(type: ESTree.TSType): boolean { - return unwrapTransparentType(type).type === "TSNeverKeyword"; + return unwrapTransparentType(type).type === 'TSNeverKeyword'; } function isEffectivelyEmptyMember(member: ESTree.TSSignature): boolean { - return ( - member.type === "TSPropertySignature" && - member.optional === true && - member.typeAnnotation !== null && - member.typeAnnotation !== undefined && - isNeverType(member.typeAnnotation.typeAnnotation) - ); + return ( + member.type === 'TSPropertySignature' && + member.optional === true && + member.typeAnnotation !== null && + member.typeAnnotation !== undefined && + isNeverType(member.typeAnnotation.typeAnnotation) + ); } function isEffectivelyEmptyTypeLiteral(type: ESTree.TSTypeLiteral): boolean { - return type.members.length === 0 || type.members.every(isEffectivelyEmptyMember); + return type.members.length === 0 || type.members.every(isEffectivelyEmptyMember); } function isEffectivelyEmptyInterface( - declarations: readonly ESTree.TSInterfaceDeclaration[], + declarations: readonly ESTree.TSInterfaceDeclaration[], ): boolean { - if (declarations.length !== 1) return false; - const [type] = declarations; - return ( - type !== undefined && - type.extends.length === 0 && - (type.body.body.length === 0 || type.body.body.every(isEffectivelyEmptyMember)) - ); + if (declarations.length !== 1) return false; + const [type] = declarations; + return ( + type !== undefined && + type.extends.length === 0 && + (type.body.body.length === 0 || type.body.body.every(isEffectivelyEmptyMember)) + ); } function resolvedSubstitutionArgument( - type: ESTree.TSType, - base: TypeAliasEnvironment, - resolving: ReadonlySet = new Set(), + type: ESTree.TSType, + base: TypeAliasEnvironment, + resolving: ReadonlySet = new Set(), ): ESTree.TSType { - const unwrapped = unwrapTransparentType(type); - if (unwrapped.type !== "TSTypeReference") return type; - const name = typeReferenceName(unwrapped); - if (name === null || resolving.has(name)) return type; - const substitution = base.get(name); - if (substitution === undefined) return type; - const nextResolving = new Set(resolving); - nextResolving.add(name); - return resolvedSubstitutionArgument(substitution, base, nextResolving); + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type !== 'TSTypeReference') return type; + const name = typeReferenceName(unwrapped); + if (name === null || resolving.has(name)) return type; + const substitution = base.get(name); + if (substitution === undefined) return type; + const nextResolving = new Set(resolving); + nextResolving.add(name); + return resolvedSubstitutionArgument(substitution, base, nextResolving); } function aliasSubstitution( - alias: ESTree.TSTypeAliasDeclaration, - type: ESTree.TSTypeReference, - base: TypeAliasEnvironment, + alias: ESTree.TSTypeAliasDeclaration, + type: ESTree.TSTypeReference, + base: TypeAliasEnvironment, ): TypeAliasEnvironment | null { - const parameters = alias.typeParameters?.params ?? []; - const arguments_ = type.typeArguments?.params ?? []; - const next = new Map(base); - for (const [index, parameter] of parameters.entries()) { - const argument = arguments_[index] ?? parameter.default; - if (argument === null || argument === undefined) return null; - next.set(parameter.name.name, resolvedSubstitutionArgument(argument, next)); - } - return next; + const parameters = alias.typeParameters?.params ?? []; + const arguments_ = type.typeArguments?.params ?? []; + const next = new Map(base); + for (const [index, parameter] of parameters.entries()) { + const argument = arguments_[index] ?? parameter.default; + if (argument === null || argument === undefined) return null; + next.set(parameter.name.name, resolvedSubstitutionArgument(argument, next)); + } + return next; } +function resolveAliasReference( + type: ESTree.TSTypeReference, + name: string, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): (ResolvedType & { readonly resolvingAliases: ReadonlySet }) | null { + const alias = environment.aliases.get(name); + if (alias === undefined || resolvingAliases.has(name)) return null; + const nextSubstitutions = aliasSubstitution(alias, type, substitutions); + if (nextSubstitutions === null) return null; + return { + type: alias.typeAnnotation, + substitutions: nextSubstitutions, + resolvingAliases: new Set([...resolvingAliases, name]), + }; +} function unsafeDirectValue( - type: ESTree.TSType, - environment: TypeEnvironment, - substitutions: TypeAliasEnvironment, - resolvingAliases: ReadonlySet, -): UnsafeDictionary["unsafeValue"] | null { - const unwrapped = unwrapTransparentType(type); - if (unwrapped.type === "TSUnknownKeyword") return "unknown"; - if (unwrapped.type === "TSAnyKeyword") return "any"; - if (unwrapped.type === "TSObjectKeyword") return "object"; - if (unwrapped.type === "TSTypeLiteral" && isEffectivelyEmptyTypeLiteral(unwrapped)) - return "empty-object"; - if (unwrapped.type === "TSUnionType") { - return unwrapped.types.some( - (member) => unsafeDirectValue(member, environment, substitutions, resolvingAliases) !== null, - ) - ? "union" - : null; - } - if (unwrapped.type === "TSIntersectionType") { - const unsafeMembers = unwrapped.types.map((member) => - unsafeDirectValue(member, environment, substitutions, resolvingAliases), - ); - if (unsafeMembers.includes("any")) return "any"; - return unsafeMembers.length > 0 && unsafeMembers.every((member) => member !== null) - ? unsafeMembers[0] - : null; - } - if (unwrapped.type !== "TSTypeReference") return null; - const name = typeReferenceName(unwrapped); - if (name === null) return null; - if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { - const wrapped = unwrapped.typeArguments?.params[0]; - return wrapped === undefined - ? null - : unsafeDirectValue(wrapped, environment, substitutions, resolvingAliases); - } - const substitution = substitutions.get(name); - if (substitution !== undefined) { - return isUnappliedReferenceTo(substitution, name) - ? null - : unsafeDirectValue(substitution, environment, substitutions, resolvingAliases); - } - const interfaceDeclarations = environment.interfaces.get(name); - if (interfaceDeclarations !== undefined) { - return isEffectivelyEmptyInterface(interfaceDeclarations) ? "empty-object" : null; - } - const alias = environment.aliases.get(name); - if (alias === undefined || resolvingAliases.has(name)) return null; - const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); - if (nextSubstitutions === null) return null; - const nextResolving = new Set(resolvingAliases); - nextResolving.add(name); - return unsafeDirectValue(alias.typeAnnotation, environment, nextSubstitutions, nextResolving); + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): UnsafeDictionary['unsafeValue'] | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === 'TSUnknownKeyword') return 'unknown'; + if (unwrapped.type === 'TSAnyKeyword') return 'any'; + if (unwrapped.type === 'TSObjectKeyword') return 'object'; + if (unwrapped.type === 'TSTypeLiteral' && isEffectivelyEmptyTypeLiteral(unwrapped)) + return 'empty-object'; + if (unwrapped.type === 'TSUnionType') { + return unwrapped.types.some( + (member) => unsafeDirectValue(member, environment, substitutions, resolvingAliases) !== null, + ) + ? 'union' + : null; + } + if (unwrapped.type === 'TSIntersectionType') { + return unsafeIntersectionValue(unwrapped, environment, substitutions, resolvingAliases); + } + if (unwrapped.type !== 'TSTypeReference') return null; + return unsafeDirectValueReference(unwrapped, environment, substitutions, resolvingAliases); +} +function unsafeIntersectionValue( + unwrapped: ESTree.TSIntersectionType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): UnsafeDictionary['unsafeValue'] | null { + const unsafeMembers = unwrapped.types.map((member) => + unsafeDirectValue(member, environment, substitutions, resolvingAliases), + ); + if (unsafeMembers.includes('any')) return 'any'; + return unsafeMembers.length > 0 && unsafeMembers.every((member) => member !== null) + ? unsafeMembers[0] + : null; +} +function unsafeDirectValueReference( + unwrapped: ESTree.TSTypeReference, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): UnsafeDictionary['unsafeValue'] | null { + const name = typeReferenceName(unwrapped); + if (name === null) return null; + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? null + : unsafeDirectValue(wrapped, environment, substitutions, resolvingAliases); + } + return unsafeNamedValue(unwrapped, name, environment, substitutions, resolvingAliases); +} +function unsafeNamedValue( + unwrapped: ESTree.TSTypeReference, + name: string, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): UnsafeDictionary['unsafeValue'] | null { + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? null + : unsafeDirectValue(substitution, environment, substitutions, resolvingAliases); + } + const interfaceDeclarations = environment.interfaces.get(name); + if (interfaceDeclarations !== undefined) { + return isEffectivelyEmptyInterface(interfaceDeclarations) ? 'empty-object' : null; + } + const resolved = resolveAliasReference( + unwrapped, + name, + environment, + substitutions, + resolvingAliases, + ); + return resolved === null + ? null + : unsafeDirectValue( + resolved.type, + environment, + resolved.substitutions, + resolved.resolvingAliases, + ); } function dictionaryValueTypes( - type: ESTree.TSType, - environment: TypeEnvironment, - substitutions: TypeAliasEnvironment, - resolvingAliases: ReadonlySet, + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): readonly ResolvedType[] { + const unwrapped = unwrapTransparentType(type); + + if (unwrapped.type === 'TSTypeLiteral') { + return unwrapped.members.flatMap((member): readonly ResolvedType[] => + member.type === 'TSIndexSignature' && member.typeAnnotation !== null + ? [{ type: member.typeAnnotation.typeAnnotation, substitutions }] + : [], + ); + } + + if (unwrapped.type === 'TSMappedType') { + return unwrapped.typeAnnotation === null + ? [] + : [{ type: unwrapped.typeAnnotation, substitutions }]; + } + + if (unwrapped.type !== 'TSTypeReference') return []; + return dictionaryValueTypesReference(unwrapped, environment, substitutions, resolvingAliases); +} +function dictionaryValueTypesReference( + unwrapped: ESTree.TSTypeReference, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, ): readonly ResolvedType[] { - const unwrapped = unwrapTransparentType(type); - - if (unwrapped.type === "TSTypeLiteral") { - return unwrapped.members.flatMap((member): readonly ResolvedType[] => - member.type === "TSIndexSignature" && member.typeAnnotation !== null - ? [{ type: member.typeAnnotation.typeAnnotation, substitutions }] - : [], - ); - } - - if (unwrapped.type === "TSMappedType") { - return unwrapped.typeAnnotation === null - ? [] - : [{ type: unwrapped.typeAnnotation, substitutions }]; - } - - if (unwrapped.type !== "TSTypeReference") return []; - const name = typeReferenceName(unwrapped); - if (name === null) return []; - - const substitution = substitutions.get(name); - if (substitution !== undefined) { - return isUnappliedReferenceTo(substitution, name) - ? [] - : dictionaryValueTypes(substitution, environment, substitutions, resolvingAliases); - } - - if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { - const wrapped = unwrapped.typeArguments?.params[0]; - return wrapped === undefined - ? [] - : dictionaryValueTypes(wrapped, environment, substitutions, resolvingAliases); - } - - if (name === "Record" && isBuiltIn(name, environment)) { - const value = unwrapped.typeArguments?.params[1] ?? null; - return value === null ? [] : [{ type: value, substitutions }]; - } - - if ((name === "Pick" || name === "Omit") && isBuiltIn(name, environment)) { - const source = unwrapped.typeArguments?.params[0]; - return source === undefined - ? [] - : dictionaryValueTypes(source, environment, substitutions, resolvingAliases); - } - - const alias = environment.aliases.get(name); - if (alias === undefined || resolvingAliases.has(name)) return []; - const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); - if (nextSubstitutions === null) return []; - const nextResolving = new Set(resolvingAliases); - nextResolving.add(name); - return dictionaryValueTypes(alias.typeAnnotation, environment, nextSubstitutions, nextResolving); + const name = typeReferenceName(unwrapped); + if (name === null) return []; + + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? [] + : dictionaryValueTypes(substitution, environment, substitutions, resolvingAliases); + } + + return dictionaryNamedValueTypes(unwrapped, name, environment, substitutions, resolvingAliases); +} +function dictionaryNamedValueTypes( + unwrapped: ESTree.TSTypeReference, + name: string, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): readonly ResolvedType[] { + if (DICTIONARY_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? [] + : dictionaryValueTypes(wrapped, environment, substitutions, resolvingAliases); + } + + if (name === 'Record' && isBuiltIn(name, environment)) { + const value = unwrapped.typeArguments?.params[1]; + return value === undefined ? [] : [{ type: value, substitutions }]; + } + + const resolved = resolveAliasReference( + unwrapped, + name, + environment, + substitutions, + resolvingAliases, + ); + return resolved === null + ? [] + : dictionaryValueTypes( + resolved.type, + environment, + resolved.substitutions, + resolved.resolvingAliases, + ); } export function classifyUnsafeDictionaryValue( - valueType: ESTree.TSType, - environment: TypeEnvironment, + valueType: ESTree.TSType, + environment: TypeEnvironment, ): UnsafeDictionary | null { - const unsafeValue = unsafeDirectValue(valueType, environment, new Map(), new Set()); - return unsafeValue === null ? null : { kind: "unsafe-dictionary", unsafeValue }; + const unsafeValue = unsafeDirectValue(valueType, environment, new Map(), new Set()); + return unsafeValue === null ? null : { kind: 'unsafe-dictionary', unsafeValue }; } export function classifyUnsafeDictionary( - type: ESTree.TSType, - environment: TypeEnvironment, + type: ESTree.TSType, + environment: TypeEnvironment, ): UnsafeDictionary | null { - for (const valueType of dictionaryValueTypes(type, environment, new Map(), new Set())) { - const unsafeValue = unsafeDirectValue( - valueType.type, - environment, - valueType.substitutions, - new Set(), - ); - if (unsafeValue !== null) return { kind: "unsafe-dictionary", unsafeValue }; - } - return null; + for (const valueType of dictionaryValueTypes(type, environment, new Map(), new Set())) { + const unsafeValue = unsafeDirectValue( + valueType.type, + environment, + valueType.substitutions, + new Set(), + ); + if (unsafeValue !== null) return { kind: 'unsafe-dictionary', unsafeValue }; + } + return null; } function resolvesToDictionary( - type: ESTree.TSType, - environment: TypeEnvironment, - substitutions: TypeAliasEnvironment, - resolvingAliases: ReadonlySet, + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, ): boolean { - return dictionaryValueTypes(type, environment, substitutions, resolvingAliases).length > 0; + return dictionaryValueTypes(type, environment, substitutions, resolvingAliases).length > 0; } export function classifyWideningTarget( - type: ESTree.TSType, - environment: TypeEnvironment, + type: ESTree.TSType, + environment: TypeEnvironment, +): WideningTarget | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === 'TSUnknownKeyword') return { kind: 'unknown' }; + if (unwrapped.type === 'TSObjectKeyword') return { kind: 'object' }; + if (unwrapped.type === 'TSTypeLiteral') return classifyLiteralWideningTarget(unwrapped); + if (unwrapped.type === 'TSMappedType') return { kind: 'open dictionary' }; + if (unwrapped.type !== 'TSTypeReference') return null; + return classifyReferenceWideningTarget(unwrapped, environment); +} +function classifyLiteralWideningTarget(type: ESTree.TSTypeLiteral): WideningTarget | null { + if (type.members.some((member) => member.type === 'TSIndexSignature')) + return { kind: 'open dictionary' }; + return type.members.length > 0 ? { kind: 'anonymous object' } : null; +} +function classifyReferenceWideningTarget( + unwrapped: ESTree.TSTypeReference, + environment: TypeEnvironment, ): WideningTarget | null { - const unwrapped = unwrapTransparentType(type); - if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" }; - if (unwrapped.type === "TSObjectKeyword") return { kind: "object" }; - if (unwrapped.type === "TSTypeLiteral") { - return unwrapped.members.some((member) => member.type === "TSIndexSignature") - ? { kind: "open dictionary" } - : unwrapped.members.length > 0 - ? { kind: "anonymous object" } - : null; - } - if (unwrapped.type === "TSMappedType") return { kind: "open dictionary" }; - if (unwrapped.type !== "TSTypeReference") return null; - const name = typeReferenceName(unwrapped); - if (name === null) return null; - if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { - const wrapped = unwrapped.typeArguments?.params[0]; - return wrapped === undefined ? null : classifyWideningTarget(wrapped, environment); - } - if (name === "Record" && isBuiltIn(name, environment)) return { kind: "open dictionary" }; - const alias = environment.aliases.get(name); - if (alias === undefined) return null; - if ((alias.typeParameters?.params.length ?? 0) > 0) { - const substitutions = aliasSubstitution(alias, unwrapped, new Map()); - return substitutions !== null && - resolvesToDictionary(alias.typeAnnotation, environment, substitutions, new Set([name])) - ? { kind: "generic container" } - : null; - } - const substitutions = aliasSubstitution(alias, unwrapped, new Map()); - if (substitutions === null) return null; - const resolved = classifyAliasBroadTarget( - alias.typeAnnotation, - environment, - substitutions, - new Set([name]), - ); - return resolved; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined ? null : classifyWideningTarget(wrapped, environment); + } + if (name === 'Record' && isBuiltIn(name, environment)) return { kind: 'open dictionary' }; + return classifyNamedAliasTarget(unwrapped, name, environment); +} +function classifyNamedAliasTarget( + unwrapped: ESTree.TSTypeReference, + name: string, + environment: TypeEnvironment, +): WideningTarget | null { + const alias = environment.aliases.get(name); + if (alias === undefined) return null; + if ((alias.typeParameters?.params.length ?? 0) > 0) { + const substitutions = aliasSubstitution(alias, unwrapped, new Map()); + return substitutions !== null && + resolvesToDictionary(alias.typeAnnotation, environment, substitutions, new Set([name])) + ? { kind: 'generic container' } + : null; + } + const substitutions = aliasSubstitution(alias, unwrapped, new Map()); + if (substitutions === null) return null; + const resolved = classifyAliasBroadTarget( + alias.typeAnnotation, + environment, + substitutions, + new Set([name]), + ); + return resolved; } function isBroadMappedKey( - type: ESTree.TSType, - environment: TypeEnvironment, - substitutions: TypeAliasEnvironment, + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, ): boolean { - const unwrapped = unwrapTransparentType(type); - if ( - unwrapped.type === "TSStringKeyword" || - unwrapped.type === "TSNumberKeyword" || - unwrapped.type === "TSSymbolKeyword" - ) { - return true; - } - if (unwrapped.type === "TSUnionType") { - return unwrapped.types.every((member) => - isBroadMappedKey(member, environment, substitutions), - ); - } - if (unwrapped.type !== "TSTypeReference") return false; - const name = typeReferenceName(unwrapped); - if (name === null) return false; - const substitution = substitutions.get(name); - if (substitution !== undefined && !isUnappliedReferenceTo(substitution, name)) { - return isBroadMappedKey(substitution, environment, substitutions); - } - return name === "PropertyKey" && isBuiltIn(name, environment); + const unwrapped = unwrapTransparentType(type); + if ( + unwrapped.type === 'TSStringKeyword' || + unwrapped.type === 'TSNumberKeyword' || + unwrapped.type === 'TSSymbolKeyword' + ) { + return true; + } + if (unwrapped.type === 'TSUnionType') { + return unwrapped.types.every((member) => isBroadMappedKey(member, environment, substitutions)); + } + if (unwrapped.type !== 'TSTypeReference') return false; + const name = typeReferenceName(unwrapped); + if (name === null) return false; + const substitution = substitutions.get(name); + if (substitution !== undefined && !isUnappliedReferenceTo(substitution, name)) { + return isBroadMappedKey(substitution, environment, substitutions); + } + return name === 'PropertyKey' && isBuiltIn(name, environment); } function classifyAliasBroadTarget( - type: ESTree.TSType, - environment: TypeEnvironment, - substitutions: TypeAliasEnvironment, - resolvingAliases: ReadonlySet, + type: ESTree.TSType, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): WideningTarget | null { + const unwrapped = unwrapTransparentType(type); + if (unwrapped.type === 'TSUnknownKeyword') return { kind: 'unknown' }; + if (unwrapped.type === 'TSObjectKeyword') return { kind: 'object' }; + if (unwrapped.type === 'TSTypeLiteral') { + return unwrapped.members.some((member) => member.type === 'TSIndexSignature') + ? { kind: 'open dictionary' } + : null; + } + if (unwrapped.type === 'TSMappedType') { + return isBroadMappedKey(unwrapped.constraint, environment, substitutions) + ? { kind: 'open dictionary' } + : null; + } + if (unwrapped.type !== 'TSTypeReference') return null; + return classifyAliasBroadTargetReference(unwrapped, environment, substitutions, resolvingAliases); +} +function classifyAliasBroadTargetReference( + unwrapped: ESTree.TSTypeReference, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, ): WideningTarget | null { - const unwrapped = unwrapTransparentType(type); - if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" }; - if (unwrapped.type === "TSObjectKeyword") return { kind: "object" }; - if (unwrapped.type === "TSTypeLiteral") { - return unwrapped.members.some((member) => member.type === "TSIndexSignature") - ? { kind: "open dictionary" } - : null; - } - if (unwrapped.type === "TSMappedType") { - return isBroadMappedKey(unwrapped.constraint, environment, substitutions) - ? { kind: "open dictionary" } - : null; - } - if (unwrapped.type !== "TSTypeReference") return null; - const name = typeReferenceName(unwrapped); - if (name === null) return null; - const substitution = substitutions.get(name); - if (substitution !== undefined) { - return isUnappliedReferenceTo(substitution, name) - ? null - : classifyAliasBroadTarget( - substitution, - environment, - substitutions, - resolvingAliases, - ); - } - if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { - const wrapped = unwrapped.typeArguments?.params[0]; - return wrapped === undefined - ? null - : classifyAliasBroadTarget(wrapped, environment, substitutions, resolvingAliases); - } - if (name === "Record" && isBuiltIn(name, environment)) { - return { kind: "open dictionary" }; - } - const alias = environment.aliases.get(name); - if (alias === undefined || resolvingAliases.has(name)) return null; - const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions); - if (nextSubstitutions === null) return null; - const nextResolving = new Set(resolvingAliases); - nextResolving.add(name); - return classifyAliasBroadTarget( - alias.typeAnnotation, - environment, - nextSubstitutions, - nextResolving, - ); -} - -export function isPopulatedObjectExpression(expression: ESTree.Expression): boolean { - let current = expression; - while ( - current.type === "ParenthesizedExpression" || - current.type === "TSAsExpression" || - current.type === "TSTypeAssertion" || - current.type === "TSNonNullExpression" - ) { - current = current.expression; - } - return current.type === "ObjectExpression" && current.properties.length > 0; + const name = typeReferenceName(unwrapped); + if (name === null) return null; + const substitution = substitutions.get(name); + if (substitution !== undefined) { + return isUnappliedReferenceTo(substitution, name) + ? null + : classifyAliasBroadTarget(substitution, environment, substitutions, resolvingAliases); + } + return classifyNamedBroadTarget(unwrapped, name, environment, substitutions, resolvingAliases); +} +function classifyNamedBroadTarget( + unwrapped: ESTree.TSTypeReference, + name: string, + environment: TypeEnvironment, + substitutions: TypeAliasEnvironment, + resolvingAliases: ReadonlySet, +): WideningTarget | null { + if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) { + const wrapped = unwrapped.typeArguments?.params[0]; + return wrapped === undefined + ? null + : classifyAliasBroadTarget(wrapped, environment, substitutions, resolvingAliases); + } + if (name === 'Record' && isBuiltIn(name, environment)) { + return { kind: 'open dictionary' }; + } + const resolved = resolveAliasReference( + unwrapped, + name, + environment, + substitutions, + resolvingAliases, + ); + return resolved === null + ? null + : classifyAliasBroadTarget( + resolved.type, + environment, + resolved.substitutions, + resolved.resolvingAliases, + ); } export function isKnownEvidenceExpression(expression: ESTree.Expression): boolean { - let current = expression; - while ( - current.type === "ParenthesizedExpression" || - current.type === "TSAsExpression" || - current.type === "TSTypeAssertion" || - current.type === "TSNonNullExpression" || - current.type === "TSSatisfiesExpression" - ) { - current = current.expression; - } - if (current.type === "ObjectExpression") return true; - return ( - current.type === "ArrayExpression" || - current.type === "ArrowFunctionExpression" || - current.type === "ClassExpression" || - current.type === "FunctionExpression" || - current.type === "NewExpression" || - current.type === "Literal" || - current.type === "TemplateLiteral" || - current.type === "UnaryExpression" - ); + let current = expression; + while ( + current.type === 'ParenthesizedExpression' || + current.type === 'TSAsExpression' || + current.type === 'TSTypeAssertion' || + current.type === 'TSNonNullExpression' || + current.type === 'TSSatisfiesExpression' + ) { + current = current.expression; + } + return knownEvidenceTypes.has(current.type); } +const knownEvidenceTypes = new Set([ + 'ObjectExpression', + 'ArrayExpression', + 'ArrowFunctionExpression', + 'ClassExpression', + 'FunctionExpression', + 'NewExpression', + 'Literal', + 'TemplateLiteral', + 'UnaryExpression', +]); diff --git a/app/tools/oxlint/anti-slop/shared/function-parameters.ts b/app/tools/oxlint/anti-slop/shared/function-parameters.ts new file mode 100644 index 000000000..ccb5b544d --- /dev/null +++ b/app/tools/oxlint/anti-slop/shared/function-parameters.ts @@ -0,0 +1,26 @@ +import type { ESTree } from "@oxlint/plugins"; + +export type Parameter = ESTree.ParamPattern; +export type ParameterOwner = + | ESTree.ArrowFunctionExpression + | ESTree.Function + | ESTree.TSCallSignatureDeclaration + | ESTree.TSConstructSignatureDeclaration + | ESTree.TSConstructorType + | ESTree.TSFunctionType + | ESTree.TSMethodSignature; + +export function parameterAnnotation( + parameter: Parameter, +): ESTree.TSTypeAnnotation | null | undefined { + if (parameter.type === "TSParameterProperty") { + return parameterAnnotation(parameter.parameter); + } + if (parameter.type === "RestElement") { + return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument); + } + if (parameter.type === "AssignmentPattern") { + return parameter.typeAnnotation ?? parameter.left.typeAnnotation; + } + return parameter.typeAnnotation; +} diff --git a/app/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts b/app/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts index 7cdb18c91..c58bcb14f 100644 --- a/app/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts +++ b/app/tools/oxlint/anti-slop/shared/lexical-type-parameters.ts @@ -3,59 +3,65 @@ import type { ESTree } from "@oxlint/plugins"; type VisitorKeys = Readonly>; function isNode(value: unknown): value is ESTree.Node { - return ( - typeof value === "object" && - value !== null && - "type" in value && - typeof value.type === "string" - ); + return ( + typeof value === "object" && value !== null && "type" in value && typeof value.type === "string" + ); } function collectInferTypeParameterNames( - node: ESTree.Node, - visitorKeys: VisitorKeys, - names: Set, + node: ESTree.Node, + visitorKeys: VisitorKeys, + names: Set, ): void { - if (node.type === "TSInferType") names.add(node.typeParameter.name.name); - const record = node as unknown as Readonly>; - for (const key of visitorKeys[node.type] ?? []) { - const value = record[key]; - if (isNode(value)) { - collectInferTypeParameterNames(value, visitorKeys, names); - continue; - } - if (!Array.isArray(value)) continue; - for (const child of value) { - if (isNode(child)) collectInferTypeParameterNames(child, visitorKeys, names); - } - } + if (node.type === "TSInferType") names.add(node.typeParameter.name.name); + const record = node as unknown as Readonly>; + for (const key of visitorKeys[node.type] ?? []) { + const value = record[key]; + if (isNode(value)) { + collectInferTypeParameterNames(value, visitorKeys, names); + continue; + } + if (!Array.isArray(value)) continue; + for (const child of value) { + if (isNode(child)) collectInferTypeParameterNames(child, visitorKeys, names); + } + } +} + +function collectScopedTypeParameterNames( + node: ESTree.Node, + descendant: ESTree.Node, + visitorKeys: VisitorKeys, + names: Set, +): void { + if ("typeParameters" in node) { + for (const parameter of node.typeParameters?.params ?? []) { + names.add(parameter.name.name); + } + } + if ( + node.type === "TSMappedType" && + (descendant === node.nameType || descendant === node.typeAnnotation) + ) { + names.add(node.key.name); + } + if (node.type === "TSConditionalType" && descendant === node.trueType) { + collectInferTypeParameterNames(node.extendsType, visitorKeys, names); + } } /** Collect type binders that are in scope at a node and can shadow module aliases. */ export function lexicalTypeParameterNames( - node: ESTree.Node, - visitorKeys: VisitorKeys, + node: ESTree.Node, + visitorKeys: VisitorKeys, ): ReadonlySet { - const names = new Set(); - let descendant: ESTree.Node = node; - let current: ESTree.Node | null = node; - while (current !== null && current.type !== "Program") { - if ("typeParameters" in current) { - for (const parameter of current.typeParameters?.params ?? []) { - names.add(parameter.name.name); - } - } - if ( - current.type === "TSMappedType" && - (descendant === current.nameType || descendant === current.typeAnnotation) - ) { - names.add(current.key.name); - } - if (current.type === "TSConditionalType" && descendant === current.trueType) { - collectInferTypeParameterNames(current.extendsType, visitorKeys, names); - } - descendant = current; - current = current.parent; - } - return names; + const names = new Set(); + let descendant: ESTree.Node = node; + let current: ESTree.Node | null = node; + while (current !== null && current.type !== "Program") { + collectScopedTypeParameterNames(current, descendant, visitorKeys, names); + descendant = current; + current = current.parent; + } + return names; } diff --git a/app/tools/oxlint/anti-slop/shared/type-alias-reference.ts b/app/tools/oxlint/anti-slop/shared/type-alias-reference.ts new file mode 100644 index 000000000..de655ed37 --- /dev/null +++ b/app/tools/oxlint/anti-slop/shared/type-alias-reference.ts @@ -0,0 +1,13 @@ +import type { ESTree } from "@oxlint/plugins"; + +/** Resolve an unapplied local alias reference, excluding cycles and lexical binders. */ +export function unshadowedAliasName( + type: ESTree.TSType, + shadowedAliases: ReadonlySet, + visited: ReadonlySet, +): string | null { + if (type.type !== "TSTypeReference" || type.typeName.type !== "Identifier") return null; + if ((type.typeArguments?.params.length ?? 0) > 0) return null; + const name = type.typeName.name; + return visited.has(name) || shadowedAliases.has(name) ? null : name; +} diff --git a/app/tools/oxlint/effect-native/rules/no-ad-hoc-argv-in-scripts.ts b/app/tools/oxlint/effect-native/rules/no-ad-hoc-argv-in-scripts.ts index 47d5afdcf..8cdaa6047 100644 --- a/app/tools/oxlint/effect-native/rules/no-ad-hoc-argv-in-scripts.ts +++ b/app/tools/oxlint/effect-native/rules/no-ad-hoc-argv-in-scripts.ts @@ -12,57 +12,21 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { ESTree } from '@oxlint/plugins'; -import { - globToRegExp, - isScriptFile, - isTestFile, - matchesAny, - normalisePath, -} from '../shared/paths.ts'; +import { literalText, propertyText, staticString, unwrap } from '../shared/ast.ts'; +import { importedName } from '../shared/imports.ts'; +import { stringList } from '../shared/options.ts'; +import { globToRegExp, inScriptScope, scriptScope } from '../shared/paths.ts'; +import { provenance } from '../shared/provenance.ts'; type AnyNode = ESTree.Node; -const WORKSPACE_MARKERS: readonly string[] = ['/apps/', '/verticals/', '/packages/', '/scripts/']; - -/** - * Absolute filename → the workspace-relative path the scope globs are written against. - * - * The *last* workspace marker wins so real sources (`/scripts/x.mts`) and this plugin's own - * fixtures (`tools/.../tests/fixtures//invalid/scripts/x.mts`) classify identically; - * `normalisePath` alone would stop at the enclosing `tools/` segment. - */ -function workspacePath(filename: string): string { - const unified = filename.replaceAll('\\', '/'); - let best = -1; - for (const marker of WORKSPACE_MARKERS) best = Math.max(best, unified.lastIndexOf(marker)); - return best === -1 ? normalisePath(unified) : unified.slice(best + 1); -} - -/** Modules whose default/namespace export *is* the process object. */ +/** Modules whose default/namespace export is the process object. */ const PROCESS_MODULES = new Set(['process', 'node:process']); - /** Modules exposing Node's own argument parser. */ const UTIL_MODULES = new Set(['util', 'node:util']); -/** Globals that own an argv array. */ -const ARGV_HOSTS = new Set(['process', 'Bun']); - -/** Globals that can be used to reach the process object indirectly (`globalThis.process.argv`). */ -const CONTAINER_GLOBALS = new Set(['globalThis', 'global', 'window', 'self']); - -/** Wrappers that do not change "is this expression the object / initialiser of its parent". */ -const TRANSPARENT = new Set([ - 'ParenthesizedExpression', - 'ChainExpression', - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSInstantiationExpression', - 'TSTypeAssertion', -]); - /** * Third-party CLI argument parsers. The spec's six (`yargs`, `commander`, `minimist`, `cac`, `arg`, * `meow`) plus the other common ones in the same class, so a future script cannot sidestep the rule @@ -100,12 +64,6 @@ const DEFAULTS: RuleOptions = { forbiddenCliModules: [...DEFAULT_FORBIDDEN_CLI_MODULES], }; -function stringList(value: unknown, fallback: readonly string[]): readonly string[] { - return Array.isArray(value) && value.every((entry) => typeof entry === 'string') - ? (value as readonly string[]) - : fallback; -} - function numberList(value: unknown, fallback: readonly number[]): readonly number[] { return Array.isArray(value) && value.every((entry) => typeof entry === 'number') ? (value as readonly number[]) @@ -127,37 +85,10 @@ function readOptions(raw: unknown): RuleOptions { }; } -function parentOf(node: AnyNode): AnyNode | null { - return (node as { parent?: AnyNode | null }).parent ?? null; -} - -/** Strip `(...)`, `as`, `satisfies`, `!`, `` and `a?.b` chain wrappers from an expression. */ -function unwrap(node: AnyNode | null | undefined): AnyNode | null { - let current = node ?? null; - for (let depth = 0; current !== null && depth < 8; depth += 1) { - if (!TRANSPARENT.has(current.type)) return current; - const inner = (current as { expression?: AnyNode | null }).expression ?? null; - if (inner === null) return current; - current = inner; - } - return current; -} - -/** `process.argv` / `process["argv"]` → `"argv"`; a dynamic key → `null`. */ -function staticPropertyName(node: ESTree.MemberExpression): string | null { - const property = syntax(node.property) as AnyNode; - if (!node.computed) - return property.type === 'Identifier' ? (property as ESTree.IdentifierName).name : null; - if (property.type === 'TemplateLiteral') return literalText(property); - if (property.type !== 'Literal') return null; - const value = (property as { value?: unknown }).value; - return typeof value === 'string' ? value : null; -} - /** The integer index of a computed member (`argv[2]`, `argv["2"]`), or `null` when it is dynamic. */ function staticIndex(node: ESTree.MemberExpression): number | null { if (!node.computed) return null; - const property = unwrap(node.property as AnyNode); + const property = unwrap(node.property, { maxDepth: 8 }); if (property === null) return null; const value = property.type === 'Literal' ? (property as { value?: unknown }).value : literalText(property); @@ -167,24 +98,6 @@ function staticIndex(node: ESTree.MemberExpression): number | null { return Number.isInteger(parsed) ? parsed : null; } -function resolveVariable(context: Context, name: string, from: AnyNode): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(from); - while (scope !== null) { - const variable = scope.set.get(name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; -} - -/** `true` when `node` is the global `name` — not a local, parameter, class or imported binding. */ -function isUnshadowedGlobal(context: Context, node: AnyNode, name: string): boolean { - if (node.type !== 'Identifier') return false; - if ((node as ESTree.IdentifierReference).name !== name) return false; - const variable = resolveVariable(context, name, node); - return variable === null || variable.defs.length === 0; -} - /** `"yargs/helpers"` → `"yargs"`, `"@commander-js/extra-typings/x"` → `"@commander-js/extra-typings"`. */ function packageName(specifier: string): string { if (specifier.startsWith('.') || specifier.startsWith('/')) return specifier; @@ -195,18 +108,7 @@ function packageName(specifier: string): string { /** The static string value of an `import(...)` / `require(...)` argument, when there is one. */ function staticStringValue(node: AnyNode | null | undefined): string | null { - const inner = unwrap(node); - if (inner === null) return null; - if (inner.type === 'Literal') { - const value = (inner as { value?: unknown }).value; - return typeof value === 'string' ? value : null; - } - if (inner.type === 'TemplateLiteral') { - const template = inner as ESTree.TemplateLiteral; - if (template.expressions.length !== 0 || template.quasis.length !== 1) return null; - return template.quasis[0]?.value.cooked ?? null; - } - return null; + return staticString(node, { unwrap: { maxDepth: 8 }, singleQuasi: true }); } /** @@ -321,13 +223,6 @@ export const rule = defineRule({ const allowedIndices = new Set(options.allowEntryGuardIndices); const forbiddenModules = new Set(options.forbiddenCliModules); - /** Locals bound to the process module itself (`import process from "node:process"`). */ - const processLocals = new Set(); - /** Locals bound to `node:util` (`import util from "node:util"`), for `util.parseArgs`. */ - const utilLocals = new Set(); - /** Locals bound to the argv array itself (`import { argv as nodeArgv } from "node:process"`). */ - const argvLocals = new Set(); - const printed = (node: AnyNode): string => { const text = context.sourceCode.getText(node).replace(/\s+/gu, ' ').trim(); return text.length > 72 ? `${text.slice(0, 69)}...` : text; @@ -337,8 +232,6 @@ export const rule = defineRule({ context.report({ node, messageId, data }); }; - const isArgvHost = (node: AnyNode | null): boolean => - ['process', 'Bun'].includes(provenance(context, node) ?? ''); const isArgvSource = (node: AnyNode | null): boolean => ['process.argv', 'Bun.argv'].includes(provenance(context, node) ?? ''); @@ -350,53 +243,22 @@ export const rule = defineRule({ report(whole, 'argvDestructuring', { expression: printed(whole) }); }; + const reportParseArgsImports = (node: ESTree.ImportDeclaration): void => { + for (const specifier of node.specifiers) { + if (specifier.type !== 'ImportSpecifier' || specifier.importKind === 'type') continue; + if (importedName(specifier) === 'parseArgs') + report(specifier, 'parseArgsImport', { module: node.source.value }); + } + }; + return { ImportDeclaration(node) { const module = node.source.value; - const isTypeOnly = node.importKind === 'type'; - - if (PROCESS_MODULES.has(module) && !isTypeOnly) { - for (const specifier of node.specifiers) { - if ( - specifier.type === 'ImportDefaultSpecifier' || - specifier.type === 'ImportNamespaceSpecifier' - ) { - processLocals.add(specifier.local.name); - continue; - } - if (specifier.type !== 'ImportSpecifier' || specifier.importKind === 'type') continue; - const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; - // `import { argv } from "node:process"` binds the argv array itself. - if (imported === 'argv') argvLocals.add(specifier.local.name); - } - return; - } - - if (UTIL_MODULES.has(module) && !isTypeOnly) { - for (const specifier of node.specifiers) { - if ( - specifier.type === 'ImportDefaultSpecifier' || - specifier.type === 'ImportNamespaceSpecifier' - ) { - utilLocals.add(specifier.local.name); - continue; - } - if (specifier.type !== 'ImportSpecifier' || specifier.importKind === 'type') continue; - const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; - if (imported === 'parseArgs') { - report(specifier as unknown as AnyNode, 'parseArgsImport', { module }); - } - } + if (node.importKind === 'type' || PROCESS_MODULES.has(module)) return; + if (UTIL_MODULES.has(module)) { + reportParseArgsImports(node); return; } - - if (isTypeOnly) return; if (!forbiddenModules.has(packageName(module))) return; const valueSpecifiers = node.specifiers.filter( (specifier) => !(specifier.type === 'ImportSpecifier' && specifier.importKind === 'type'), @@ -515,213 +377,3 @@ export const rule = defineRule({ }; }, }); - -/** Bounded, lexical provenance only; no type checker or interprocedural/data-flow inference. */ -type Syntax = ESTree.Node & Record; -function syntax(node: unknown): Syntax | null { - let n = node as Syntax | null; - while ( - n && - [ - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSTypeAssertion', - 'TSInstantiationExpression', - 'ParenthesizedExpression', - 'ChainExpression', - 'AwaitExpression', - ].includes(n.type) - ) - n = n.expression ?? n.argument; - return n; -} -function lexicalVariable(context: Context, node: Syntax): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(node); - while (scope) { - const v = scope.set.get(node.name); - if (v) return v; - scope = scope.upper; - } - return null; -} -function literalText(node: unknown): string | null { - const n = syntax(node); - if (n?.type === 'Literal' && typeof n.value === 'string') return n.value; - if (n?.type === 'TemplateLiteral' && n.expressions.length === 0) - return n.quasis[0]?.value.cooked ?? null; - return null; -} -function propertyText(node: unknown): string | null { - const n = node as Syntax; - const key = syntax(n.property ?? n.key); - return !n.computed && key?.type === 'Identifier' ? key.name : literalText(key); -} -function moduleIdentity(source: string): string { - if (/^(?:node:)?(?:process|console|util|module)$/.test(source)) - return source.replace(/^node:/, ''); - if (source === 'effect/Effect') return 'Effect'; - if (source === 'effect/ManagedRuntime') return 'ManagedRuntime'; - return source; -} -function bindingPath(pattern: Syntax, name: string): string[] | null { - if (pattern.type === 'Identifier') return pattern.name === name ? [] : null; - if (pattern.type === 'AssignmentPattern') return bindingPath(pattern.left, name); - if (pattern.type !== 'ObjectPattern') return null; - for (const p of pattern.properties) { - if (p.type !== 'Property') continue; - const key = propertyText(p), - tail = bindingPath(p.value, name); - if (key !== null && tail !== null) return [key, ...tail]; - } - return null; -} -function provenance(context: Context, node: unknown, seen = new Set()): string | null { - const n = syntax(node); - if (!n) return null; - if (n.type === 'Identifier') { - const v = lexicalVariable(context, n); - if (!v || v.defs.length === 0) - return [ - 'process', - 'console', - 'Bun', - 'globalThis', - 'global', - 'window', - 'self', - 'require', - 'Array', - 'Set', - ].includes(n.name) - ? n.name - : null; - if (seen.has(v) || v.defs.length !== 1) return null; - const next = new Set(seen); - next.add(v); - const def = v.defs[0] as any; - if (def.type === 'ImportBinding') { - const spec = def.node as Syntax; - const decl = (def.parent ?? spec.parent) as Syntax; - if (decl.importKind === 'type' || spec.importKind === 'type') return null; - const source = literalText(decl.source); - if (!source) return null; - const base = moduleIdentity(source); - if (spec.type === 'ImportNamespaceSpecifier' || spec.type === 'ImportDefaultSpecifier') - return base; - const name = spec.imported?.name ?? spec.imported?.value; - if (name === 'default') return base; - if (base === 'effect') return name; - return `${base}.${name}`; - } - if (def.type !== 'Variable' || def.node.type !== 'VariableDeclarator') return null; - // A declaration is not a reaching-definition analysis: reassigned aliases are unknown. - if (v.references.some((r: any) => r.init !== true && r.isWrite())) return null; - const d = def.node as Syntax; - const base = provenance(context, d.init, next), - path = bindingPath(d.id, n.name); - return base !== null && path !== null ? [base, ...path].join('.') : null; - } - if (n.type === 'MemberExpression') { - const base = provenance(context, n.object, seen), - key = propertyText(n); - if (base === null || key === null) return null; - if ( - ['globalThis', 'global', 'window', 'self'].includes(base) && - ['process', 'console', 'Bun'].includes(key) - ) - return key; - if (['process', 'console', 'util', 'module'].includes(base) && key === 'default') return base; - if (base === 'effect') return key; - return `${base}.${key}`; - } - if (n.type === 'ImportExpression') { - const text = literalText(n.source); - return text === null ? null : moduleIdentity(text); - } - if (n.type === 'CallExpression') { - const callee = provenance(context, n.callee, seen); - if (callee === 'require') { - const text = literalText(n.arguments[0]); - return text === null ? null : moduleIdentity(text); - } - if (callee === 'module.createRequire') return 'require'; - if (callee === 'ManagedRuntime.make') return 'Runtime'; - } - return null; -} -/** Only value references, never property names, bindings or TS-only identifiers. */ -function valueReference(context: Context, node: unknown): boolean { - const n = node as Syntax, - p = n.parent as Syntax | undefined; - if (!p) return false; - if (p.type.startsWith('Import') || p.type === 'ExportSpecifier') return false; - if (p.type === 'MemberExpression' && p.property === n && !p.computed) return false; - if ( - [ - 'Property', - 'PropertyDefinition', - 'MethodDefinition', - 'TSPropertySignature', - 'TSMethodSignature', - ].includes(p.type) && - p.key === n && - !p.computed && - !(p.shorthand && p.value === n) - ) - return false; - if (['LabeledStatement', 'BreakStatement', 'ContinueStatement'].includes(p.type)) return false; - let child: Syntax = n; - let parent: Syntax | null = p; - while (parent) { - if ( - parent.type.startsWith('TS') && - !( - [ - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSTypeAssertion', - 'TSInstantiationExpression', - ].includes(parent.type) && parent.expression === child - ) - ) - return false; - if ( - parent.type.endsWith('Statement') || - parent.type.endsWith('Declaration') || - parent.type.includes('Function') - ) - break; - child = parent; - parent = parent.parent as Syntax | null; - } - const v = lexicalVariable(context, n); - return ( - !v || - v.references.some( - (r: any) => - r.identifier === n && - r.isRead() && - (typeof r.isValueReference !== 'function' || r.isValueReference()), - ) - ); -} -/** Strip fixture scaffolding first; do not renormalise a relative script path around inner markers. */ -function scriptScope(filename: string): string { - const unified = filename.replaceAll('\\', '/'); - const fixture = unified.match( - /(?:^|\/)tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\/(.*)$/u, - ); - if (fixture) return fixture[1]; - if (!unified.startsWith('/') && !/^[A-Za-z]:\//u.test(unified)) - return unified.replace(/^\.\//, ''); - const match = unified.match(/(?:^|\/)((?:apps|packages|verticals|scripts|tools)\/.*)$/u); - return match?.[1] ?? unified; -} -function inScriptScope(path: string): boolean { - return ( - /(?:^|\/)scripts\//u.test(path) && - !/(?:^|\/)(?:tests?|__tests__)\/|\.(?:test|spec|test-d|spec-d)\.[cm]?[jt]sx?$/u.test(path) - ); -} diff --git a/app/tools/oxlint/effect-native/rules/no-ambient-date.ts b/app/tools/oxlint/effect-native/rules/no-ambient-date.ts index eb1a2938b..89d3fb632 100644 --- a/app/tools/oxlint/effect-native/rules/no-ambient-date.ts +++ b/app/tools/oxlint/effect-native/rules/no-ambient-date.ts @@ -78,8 +78,17 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; - +import type { Context, ESTree } from '@oxlint/plugins'; + +import { + parentOf, + skipWrappers, + unwrapNode, + memberName, + keyName as staticKeyName, +} from '../shared/ast.ts'; +import { resolveVariable } from '../shared/bindings.ts'; +import { stringList } from '../shared/options.ts'; import { collectEffectBindings } from '../shared/effect-imports.ts'; import type { EffectBindings } from '../shared/effect-imports.ts'; import { isScriptFile, isTestFile, matchesAny, normalisePath } from '../shared/paths.ts'; @@ -201,12 +210,6 @@ const DEFAULTS: RuleOptions = { browserEvaluatedMethods: DEFAULT_BROWSER_EVALUATED_METHODS, }; -function stringList(value: unknown, fallback: readonly string[]): readonly string[] { - return Array.isArray(value) && value.every((entry) => typeof entry === 'string') - ? (value as readonly string[]) - : fallback; -} - function readOptions(raw: unknown): RuleOptions { const given = (raw ?? {}) as Partial>; const includePaths = stringList(given.includePaths, DEFAULTS.includePaths); @@ -234,53 +237,26 @@ function readOptions(raw: unknown): RuleOptions { }; } -function parentOf(node: AnyNode): AnyNode | null { - return (node as { parent?: AnyNode | null }).parent ?? null; -} - -/** Strip parentheses / `as` / `!` / optional-chain wrappers from an expression. */ function unwrap(node: AnyNode, depth: number): AnyNode { - if (depth > 8 || !TRANSPARENT_PARENTS.has(node.type)) return node; - const inner = (node as { expression?: AnyNode }).expression; - return inner === undefined ? node : unwrap(inner, depth + 1); + return unwrapNode(node, { wrappers: TRANSPARENT_PARENTS, maxDepth: Math.max(0, 9 - depth) }); } -/** Climb through parentheses/type wrappers; returns the outermost equivalent node and its parent. */ -function skipWrappers(node: AnyNode): { readonly node: AnyNode; readonly parent: AnyNode | null } { - let current = node; - let parent = parentOf(current); - while (parent !== null && TRANSPARENT_PARENTS.has(parent.type)) { - current = parent; - parent = parentOf(current); - } - return { node: current, parent }; -} - -function resolveVariable(context: Context, name: string, from: AnyNode): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(from); - while (scope !== null) { - const variable = scope.set.get(name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; +function staticPropertyName(node: ESTree.MemberExpression): string | null { + return memberName(node, { templates: true, singleQuasi: true }); } -function staticPropertyName(node: ESTree.MemberExpression): string | null { - const property = node.property as AnyNode; - if (!node.computed) - return property.type === 'Identifier' ? (property as ESTree.IdentifierName).name : null; - if (property.type === 'Literal') { - const value = (property as { value?: unknown }).value; - return typeof value === 'string' ? value : null; - } - // `at[`toISOString`]()` — a template literal with no interpolation is still a static key. - if (property.type === 'TemplateLiteral') { - const template = property as ESTree.TemplateLiteral; - if (template.expressions.length !== 0 || template.quasis.length !== 1) return null; - return template.quasis[0]?.value.cooked ?? null; - } - return null; +function aliasInitializer( + context: Context, + node: Extract, +): AnyNode | null { + const variable = resolveVariable(context, node.name, node); + if (variable?.defs.length !== 1) return null; + if (variable.references.some((reference) => reference.isWrite() && !reference.init)) return null; + const definition = variable.defs[0]; + if (definition === undefined) return null; + const declaration = definition.node; + if (declaration.type !== 'VariableDeclarator') return null; + return declaration.init ?? null; } /** @@ -291,25 +267,28 @@ function staticPropertyName(node: ESTree.MemberExpression): string | null { * - `const AmbientDate = Date` (a `const` whose sole initialiser is the global), * while a plain property of a user object (`registry.Date`) stays `null`. */ +function identifierGlobalName( + context: Context, + node: Extract, + depth: number, +): string | null { + const name = (node as ESTree.IdentifierReference).name; + const variable = resolveVariable(context, name, node); + if (variable === null || variable.defs.length === 0) return name; + const definition = variable.defs.length === 1 ? variable.defs[0] : undefined; + if (definition?.type !== 'Variable') return null; + if (definition.node.type !== 'VariableDeclarator' || definition.node.id.type !== 'Identifier') + return null; + const init = aliasInitializer(context, node as ESTree.IdentifierReference); + if (init === null) return null; + return resolveGlobalName(context, init, depth + 1); +} + function resolveGlobalName(context: Context, raw: AnyNode, depth = 0): string | null { if (depth > 6) return null; const node = unwrap(raw, 0); if (node.type === 'Identifier') { - const name = (node as ESTree.IdentifierReference).name; - const variable = resolveVariable(context, name, node); - if (variable === null || variable.defs.length === 0) return name; - if (variable.defs.length !== 1) return null; - if (variable.references.some((reference) => reference.isWrite() && !reference.init)) - return null; - const def = variable.defs[0]; - if (def === undefined || def.type !== 'Variable') return null; - const declarator = def.node as AnyNode; - if (declarator.type !== 'VariableDeclarator') return null; - const id = (declarator as ESTree.VariableDeclarator).id as AnyNode; - if (id.type !== 'Identifier') return null; - const init = (declarator as ESTree.VariableDeclarator).init as AnyNode | null | undefined; - if (init === null || init === undefined) return null; - return resolveGlobalName(context, init, depth + 1); + return identifierGlobalName(context, node, depth); } if (node.type === 'MemberExpression') { const member = node as ESTree.MemberExpression; @@ -374,30 +353,31 @@ function isInsideDurationChain(node: AnyNode): boolean { } function keyName(key: AnyNode): string | null { - if (key.type === 'Identifier') return (key as ESTree.IdentifierName).name; - if (key.type === 'Literal') { - const value = (key as { value?: unknown }).value; - return typeof value === 'string' ? value : null; - } - return null; + return staticKeyName(key, false, { templates: false }); +} + +function durationName(name: string | null): string | null { + return name !== null && DURATION_NAME.test(name) ? name : null; } +const DURATION_WRAPPERS = new Set([ + 'ParenthesizedExpression', + 'TSAsExpression', + 'TSNonNullExpression', +]); + /** Duration-suffixed identifier / property name appearing as a factor of the chain. */ function operandDurationName(node: AnyNode, depth: number): string | null { if (depth > 8) return null; if (node.type === 'Identifier') { const name = (node as ESTree.IdentifierReference).name; - return DURATION_NAME.test(name) ? name : null; + return durationName(name); } if (node.type === 'MemberExpression') { const name = staticPropertyName(node as ESTree.MemberExpression); - return name !== null && DURATION_NAME.test(name) ? name : null; + return durationName(name); } - if ( - node.type === 'ParenthesizedExpression' || - node.type === 'TSAsExpression' || - node.type === 'TSNonNullExpression' - ) { + if (DURATION_WRAPPERS.has(node.type)) { const inner = (node as { expression?: AnyNode }).expression; return inner === undefined ? null : operandDurationName(inner, depth + 1); } @@ -435,58 +415,106 @@ function isEffectCallArgument(node: AnyNode, bindings: EffectBindings): boolean } /** Name of the binding / property / assignment target / enclosing function this expression flows into. */ +const OWNER_PARENTS = new Set([ + 'ArrowFunctionExpression', + 'ReturnStatement', + 'BlockStatement', + 'BinaryExpression', + 'LogicalExpression', + 'ConditionalExpression', + 'UnaryExpression', + 'ParenthesizedExpression', + 'TSAsExpression', + 'TSSatisfiesExpression', + 'TSNonNullExpression', +]); + +function identifierName(node: AnyNode | null | undefined): string | null { + return node?.type === 'Identifier' ? node.name : null; +} + +function assignmentName(node: ESTree.AssignmentExpression): string | null { + return node.left.type === 'MemberExpression' + ? staticPropertyName(node.left) + : identifierName(node.left); +} + +const OWNER_NAMES: ReadonlyMap string | null> = new Map([ + ['VariableDeclarator', (node) => identifierName((node as ESTree.VariableDeclarator).id)], + ...['Property', 'PropertyDefinition', 'MethodDefinition'].map( + (kind): [string, (node: AnyNode) => string | null] => [ + kind, + (node) => keyName((node as { key: AnyNode }).key), + ], + ), + ['AssignmentExpression', (node) => assignmentName(node as ESTree.AssignmentExpression)], + ['AssignmentPattern', (node) => identifierName((node as ESTree.AssignmentPattern).left)], +]); + function ownerName(node: AnyNode): string | null { let current: AnyNode | null = parentOf(node); for (let depth = 0; current !== null && depth < 12; depth += 1) { - switch (current.type) { - case 'VariableDeclarator': { - const id = (current as ESTree.VariableDeclarator).id as AnyNode; - return id.type === 'Identifier' ? (id as ESTree.BindingIdentifier).name : null; - } - case 'Property': - case 'PropertyDefinition': - case 'MethodDefinition': - return keyName((current as { key: AnyNode }).key); - case 'AssignmentExpression': { - const left = (current as ESTree.AssignmentExpression).left as AnyNode; - if (left.type === 'Identifier') return (left as ESTree.IdentifierReference).name; - if (left.type === 'MemberExpression') - return staticPropertyName(left as ESTree.MemberExpression); - return null; - } - case 'AssignmentPattern': { - const left = (current as { left: AnyNode }).left; - return left.type === 'Identifier' ? (left as ESTree.BindingIdentifier).name : null; - } - // `export const leaseMs = (): number => 5 * 60 * 1000` / `function claimTimeoutMs() { return … }`: - // the duration name sits on the function, not on the initialiser. - case 'FunctionDeclaration': - case 'FunctionExpression': { - const id = (current as { id?: AnyNode | null }).id ?? null; - if (id !== null && id.type === 'Identifier') return (id as ESTree.BindingIdentifier).name; - current = parentOf(current); - continue; - } - case 'ArrowFunctionExpression': - case 'ReturnStatement': - case 'BlockStatement': - case 'BinaryExpression': - case 'LogicalExpression': - case 'ConditionalExpression': - case 'UnaryExpression': - case 'ParenthesizedExpression': - case 'TSAsExpression': - case 'TSSatisfiesExpression': - case 'TSNonNullExpression': - current = parentOf(current); - continue; - default: - return null; - } + const resolve = OWNER_NAMES.get(current.type); + if (resolve !== undefined) return resolve(current); + if (current.type === 'FunctionDeclaration' || current.type === 'FunctionExpression') { + const name = identifierName(current.id); + if (name !== null) return name; + } else if (!OWNER_PARENTS.has(current.type)) return null; + current = parentOf(current); } return null; } +function fileIsTest(filename: string, options: RuleOptions): boolean { + if (matchesAny(filename, options.testPaths)) return true; + if (matchesAny(filename, options.productionPaths)) return false; + return isTestFile(filename); +} + +function browserFunction(node: AnyNode, methods: ReadonlySet): boolean { + if (!FUNCTION_TYPES.has(node.type)) return false; + const parent = parentOf(node); + if (parent?.type !== 'CallExpression' || !(parent.arguments as readonly AnyNode[]).includes(node)) + return false; + const callee = unwrap(parent.callee, 0); + if (callee.type !== 'MemberExpression') return false; + const name = staticPropertyName(callee); + return name !== null && methods.has(name); +} + +function typeMembers(type: AnyNode | null) { + if (type?.type === 'TSTypeLiteral') return type.members; + if (type?.type === 'TSInterfaceBody') return type.body; + return []; +} + +function clockSite(node: ESTree.MemberExpression, global: string): AnyNode { + if (global !== 'process') return node; + const outer = skipWrappers(node); + const parent = outer.parent; + if (parent?.type !== 'MemberExpression' || parent.object !== outer.node) return node; + return staticPropertyName(parent) === 'bigint' ? parent : node; +} + +function ignoredReceiver( + node: AnyNode, + ignored: ReadonlySet, + bindings: EffectBindings, +): boolean { + if (node.type === 'ThisExpression') return ignored.has('this'); + if (node.type === 'Super') return ignored.has('super'); + return ( + node.type === 'Identifier' && (ignored.has(node.name) || bindings.namespaces.has(node.name)) + ); +} + +function durationIsNamed(node: ESTree.BinaryExpression): boolean { + return ( + durationName(ownerName(node)) !== null || + (node.operator === '*' && operandDurationName(node, 0) !== null) + ); +} + /** Effect-native rule: instants come from `DateTime`/`Clock`, intervals from `Duration`. */ export const rule = defineRule({ meta: { @@ -592,11 +620,7 @@ export const rule = defineRule({ if (matchesAny(filename, options.ignore)) return {}; if (options.ignoreScripts && isScriptFile(filename)) return {}; - const inTest = matchesAny(filename, options.testPaths) - ? true - : matchesAny(filename, options.productionPaths) - ? false - : isTestFile(filename); + const inTest = fileIsTest(filename, options); if (inTest && options.testMode === 'off') return {}; /** `clock-only`: report just the wall-clock reads `TestClock` must own. */ const clockOnly = inTest && options.testMode === 'clock-only'; @@ -622,18 +646,7 @@ export const rule = defineRule({ const isBrowserEvaluated = (node: AnyNode): boolean => { let current: AnyNode | null = node; for (let depth = 0; current !== null && depth < 40; depth += 1) { - if (FUNCTION_TYPES.has(current.type)) { - const parent = parentOf(current); - if (parent !== null && parent.type === 'CallExpression') { - const call = parent as ESTree.CallExpression; - const isArgument = (call.arguments as readonly AnyNode[]).includes(current); - const callee = unwrap(call.callee as AnyNode, 0); - if (isArgument && callee.type === 'MemberExpression') { - const name = staticPropertyName(callee as ESTree.MemberExpression); - if (name !== null && browserEvaluated.has(name)) return true; - } - } - } + if (browserFunction(current, browserEvaluated)) return true; current = parentOf(current); } return false; @@ -662,6 +675,42 @@ export const rule = defineRule({ return null; }; + const identifierType = ( + node: Extract, + depth: number, + ): ESTree.TSType | null => { + const variable = resolveVariable(context, node.name, node); + if (variable?.references.some((reference) => reference.isWrite() && !reference.init)) + return null; + const definition = variable?.defs.length === 1 ? variable.defs[0] : undefined; + if (definition === undefined) return null; + const declared = (definition.name as { typeAnnotation?: ESTree.TSTypeAnnotation }) + .typeAnnotation; + if (declared != null) return declared.typeAnnotation; + if (definition.node.type === 'VariableDeclarator' && definition.node.init !== null) + return declaredType(definition.node.init, depth + 1); + return null; + }; + + const memberType = (node: ESTree.MemberExpression, depth: number): ESTree.TSType | null => { + const name = staticPropertyName(node); + const owner = declaredType(node.object, depth + 1); + const resolved = owner === null ? null : resolveType(owner); + for (const member of typeMembers(resolved)) { + if (member.type === 'TSPropertySignature' && keyName(member.key) === name) + return member.typeAnnotation?.typeAnnotation ?? null; + } + return null; + }; + + const dateAnnotation = (raw: AnyNode): ESTree.IdentifierReference | null => { + const type = declaredType(raw); + const resolved = type === null ? null : resolveType(type); + if (resolved?.type !== 'TSTypeReference' || resolved.typeName.type !== 'Identifier') + return null; + return resolved.typeName.name === 'Date' ? resolved.typeName : null; + }; + const declaredType = (raw: AnyNode, depth = 0): ESTree.TSType | null => { if (depth > 12) return null; if ( @@ -673,61 +722,22 @@ export const rule = defineRule({ const node = unwrap(raw, 0); const annotation = (node as { typeAnnotation?: ESTree.TSTypeAnnotation }).typeAnnotation; if (annotation?.type === 'TSTypeAnnotation') return annotation.typeAnnotation; - if (node.type === 'Identifier') { - const variable = resolveVariable(context, node.name, node); - if (variable?.references.some((reference) => reference.isWrite() && !reference.init)) - return null; - const definition = variable?.defs.length === 1 ? variable.defs[0] : undefined; - if (definition === undefined) return null; - const declared = (definition.name as { typeAnnotation?: ESTree.TSTypeAnnotation }) - .typeAnnotation; - if (declared != null) return declared.typeAnnotation; - if (definition.node.type === 'VariableDeclarator' && definition.node.init !== null) - return declaredType(definition.node.init, depth + 1); - } - if (node.type === 'MemberExpression') { - const name = staticPropertyName(node); - const owner = declaredType(node.object, depth + 1); - const resolved = owner === null ? null : resolveType(owner); - const members = - resolved?.type === 'TSTypeLiteral' - ? resolved.members - : resolved?.type === 'TSInterfaceBody' - ? resolved.body - : []; - for (const member of members) { - if (member.type === 'TSPropertySignature' && keyName(member.key) === name) - return member.typeAnnotation?.typeAnnotation ?? null; - } - } + if (node.type === 'Identifier') return identifierType(node, depth); + if (node.type === 'MemberExpression') return memberType(node, depth); return null; }; const isDateReceiver = (raw: AnyNode, depth = 0): boolean => { if (depth > 12) return false; - const type = declaredType(raw); - const resolved = type === null ? null : resolveType(type); - if ( - resolved?.type === 'TSTypeReference' && - resolved.typeName.type === 'Identifier' && - resolved.typeName.name === 'Date' - ) { - return resolveGlobalName(context, resolved.typeName) === 'Date'; - } + const annotation = dateAnnotation(raw); + if (annotation !== null) return resolveGlobalName(context, annotation) === 'Date'; const node = unwrap(raw, 0); if (node.type === 'NewExpression') return resolveGlobalName(context, node.callee) === 'Date'; if (node.type === 'MemberExpression' && staticPropertyName(node) === 'prototype') return resolveGlobalName(context, node.object) === 'Date'; if (node.type !== 'Identifier') return false; - const variable = resolveVariable(context, node.name, node); - if (variable?.references.some((reference) => reference.isWrite() && !reference.init)) - return false; - const definition = variable?.defs.length === 1 ? variable.defs[0] : undefined; - return ( - definition?.node.type === 'VariableDeclarator' && - definition.node.init !== null && - isDateReceiver(definition.node.init, depth + 1) - ); + const init = aliasInitializer(context, node); + return init !== null && isDateReceiver(init, depth + 1); }; /** @@ -744,6 +754,15 @@ export const rule = defineRule({ return name !== null && dateMethods.has(name); }; + const reportClockProperty = ( + property: ESTree.ObjectPattern['properties'][number], + members: ReadonlySet, + ): void => { + if (property.type !== 'Property' || property.computed) return; + const name = keyName(property.key); + if (name !== null && members.has(name)) report(property, 'ambientClockRead'); + }; + return { Program(node) { bindings = collectEffectBindings(node); @@ -767,39 +786,16 @@ export const rule = defineRule({ // (2) `Date.now` / `Date.parse` / `Date.UTC` / `performance.now` / `process.hrtime[.bigint]`, // including `globalThis.`-qualified and locally aliased forms. const global = resolveGlobalName(context, node.object as AnyNode); - if (global !== null) { - const members = clockTable.get(global); - if (members !== undefined && members.has(member)) { - // `process.hrtime.bigint()` reports once, on the outer call. - let site: AnyNode = node as unknown as AnyNode; - if (global === 'process') { - const outer = skipWrappers(site); - if ( - outer.parent !== null && - outer.parent.type === 'MemberExpression' && - (outer.parent as ESTree.MemberExpression).object === outer.node && - staticPropertyName(outer.parent as ESTree.MemberExpression) === 'bigint' - ) { - site = outer.parent; - } - } - report(callSiteOf(site), 'ambientClockRead'); - return; - } + if (global !== null && clockTable.get(global)?.has(member)) { + report(callSiteOf(clockSite(node, global)), 'ambientClockRead'); + return; } // (3) hand serialisation / hand calendar arithmetic on a `Date` receiver. if (clockOnly) return; if (!dateMethods.has(member)) return; const receiver = unwrap(node.object as AnyNode, 0); - if (receiver.type === 'ThisExpression' && ignoreReceivers.has('this')) return; - if (receiver.type === 'Super' && ignoreReceivers.has('super')) return; - if (receiver.type === 'Identifier') { - const receiverName = (receiver as ESTree.IdentifierReference).name; - if (ignoreReceivers.has(receiverName)) return; - // Effect's own temporal/schema API is never a hand-rolled `Date` call. - if (bindings.namespaces.has(receiverName)) return; - } + if (ignoredReceiver(receiver, ignoreReceivers, bindings)) return; if (!isDateReceiver(node.object)) return; report(callSiteOf(node as unknown as AnyNode), 'dateMethodCall'); }, @@ -815,12 +811,8 @@ export const rule = defineRule({ if (global === null) return; const members = clockTable.get(global); if (members === undefined) return; - for (const property of (id as ESTree.ObjectPattern).properties as readonly AnyNode[]) { - if (property.type !== 'Property') continue; - const key = (property as { key: AnyNode; computed: boolean }).key; - const name = (property as { computed: boolean }).computed ? null : keyName(key); - if (name !== null && members.has(name)) report(property, 'ambientClockRead'); - } + for (const property of (id as ESTree.ObjectPattern).properties) + reportClockProperty(property, members); }, // (4) a Duration spelled out as magic millisecond arithmetic. @@ -831,12 +823,7 @@ export const rule = defineRule({ if (isInsideDurationChain(site)) return; if (!containsDurationLiteral(site, 0)) return; if (isEffectCallArgument(site, bindings)) return; - const owner = ownerName(site); - const owned = owner !== null && DURATION_NAME.test(owner); - // Without an owning name, only a multiplication *up* to milliseconds is a hand-rolled - // Duration; a division is normally a unit conversion at an edge (`Math.floor(ms / 1000)`). - const named = owned || (node.operator === '*' && operandDurationName(site, 0) !== null); - if (!named) return; + if (!durationIsNamed(node)) return; report(site, 'handDurationArithmetic'); }, }; diff --git a/app/tools/oxlint/effect-native/rules/no-ambient-process-env.ts b/app/tools/oxlint/effect-native/rules/no-ambient-process-env.ts index 7f14f711f..5bcc50286 100644 --- a/app/tools/oxlint/effect-native/rules/no-ambient-process-env.ts +++ b/app/tools/oxlint/effect-native/rules/no-ambient-process-env.ts @@ -1,3 +1,4 @@ +import { snippet } from '../shared/reporting.ts'; /** * effect-native/no-ambient-process-env * @@ -56,20 +57,21 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree } from '@oxlint/plugins'; -import { isTestFile, matchesAny, normalisePath } from '../shared/paths.ts'; +import { includesRuleFile } from '../shared/paths.ts'; +import { + keyName as sharedKeyName, + memberName, + parentOf, + skipWrappers, + unwrapNode as unwrap, +} from '../shared/ast.ts'; +import { isUnshadowedGlobal, resolveVariable } from '../shared/bindings.ts'; +import { booleanOption, stringList } from '../shared/options.ts'; type AnyNode = ESTree.Node; -/** Normalize real paths and remove only the fixture prefix, preserving nested workspace directories. */ -function workspacePath(filename: string): string { - return normalisePath(filename).replace( - /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u, - '', - ); -} - /** Modules whose default/namespace export *is* the process object. */ const PROCESS_MODULES = new Set(['process', 'node:process']); @@ -79,17 +81,6 @@ const ENV_HOSTS = new Set(['process', 'Bun', 'Deno']); /** Globals that can be used to reach an env host indirectly (`globalThis.process.env`). */ const CONTAINER_GLOBALS = new Set(['globalThis', 'global', 'window', 'self']); -/** Wrappers that do not change "is this expression the target / object of its parent". */ -const TRANSPARENT_PARENTS = new Set([ - 'ParenthesizedExpression', - 'ChainExpression', - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSInstantiationExpression', - 'TSTypeAssertion', -]); - /** `.(target, ...)` forms that mutate their first argument. */ const MUTATING_CALLS: ReadonlyMap> = new Map([ ['Object', new Set(['assign', 'defineProperty', 'defineProperties'])], @@ -115,83 +106,108 @@ const DEFAULTS: RuleOptions = { includePaths: [...DEFAULT_INCLUDE_PATHS], }; -function stringList(value: unknown, fallback: readonly string[]): readonly string[] { - return Array.isArray(value) && value.every((entry) => typeof entry === 'string') - ? (value as readonly string[]) - : fallback; -} - function readOptions(raw: unknown): RuleOptions { const given = (raw ?? {}) as Partial>; const includePaths = stringList(given.includePaths, DEFAULTS.includePaths); return { allowPaths: stringList(given.allowPaths, DEFAULTS.allowPaths), - ignoreTestFiles: - typeof given.ignoreTestFiles === 'boolean' ? given.ignoreTestFiles : DEFAULTS.ignoreTestFiles, + ignoreTestFiles: booleanOption(given.ignoreTestFiles, DEFAULTS.ignoreTestFiles), includePaths: includePaths.length > 0 ? includePaths : DEFAULTS.includePaths, }; } -function parentOf(node: AnyNode): AnyNode | null { - return (node as { parent?: AnyNode | null }).parent ?? null; +function staticPropertyName(node: ESTree.MemberExpression): string | null { + return memberName(node, { templates: true, unwrap: {} }); } -/** Climb through parentheses/type wrappers; returns the outermost equivalent node and its parent. */ -function skipWrappers(node: AnyNode): { readonly node: AnyNode; readonly parent: AnyNode | null } { - let current = node; - let parent = parentOf(current); - while (parent !== null && TRANSPARENT_PARENTS.has(parent.type)) { - current = parent; - parent = parentOf(current); - } - return { node: current, parent }; +function keyName(key: AnyNode | undefined): string | null { + return sharedKeyName(key, false, { templates: true, unwrap: {} }); } -/** `process.env` / `process["env"]` → `"env"`; a dynamic key → `null`. */ -function staticPropertyName(node: ESTree.MemberExpression): string | null { - const property = node.property as AnyNode; - if (!node.computed) - return property.type === 'Identifier' ? (property as ESTree.IdentifierName).name : null; - const key = unwrap(property); - return key.type === 'Identifier' ? null : keyName(key); +function isProcessSource(node: AnyNode | undefined): boolean { + if (!node) return false; + const source = unwrap(node); + return source.type !== 'Identifier' && PROCESS_MODULES.has(keyName(source) ?? ''); } -function resolveVariable(context: Context, name: string, from: AnyNode): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(from); - while (scope !== null) { - const variable = scope.set.get(name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; +function isProcessImport(specifier: ESTree.ImportDeclaration['specifiers'][number]): boolean { + const declaration = parentOf(specifier); + if (declaration?.type !== 'ImportDeclaration' || declaration.importKind === 'type') return false; + if (!PROCESS_MODULES.has(declaration.source.value)) return false; + if (specifier.type !== 'ImportSpecifier') return true; + return specifier.importKind !== 'type' && keyName(specifier.imported) === 'default'; } -/** `true` when `node` is the global `name` — not a local, parameter, class or imported binding. */ -function isUnshadowedGlobal(context: Context, node: AnyNode, name: string): boolean { - if (node.type !== 'Identifier') return false; - if ((node as ESTree.IdentifierReference).name !== name) return false; - const variable = resolveVariable(context, name, node); - return variable === null || variable.defs.length === 0; +function immutableInitializer(declaration: ESTree.VariableDeclarator): AnyNode | null { + if (declaration.id.type !== 'Identifier' || parentOf(declaration)?.kind !== 'const') return null; + return declaration.init; } -function unwrap(node: AnyNode): AnyNode { - let current = node; - while (TRANSPARENT_PARENTS.has(current.type)) - current = (current as { expression: AnyNode }).expression; - return current; +function isImportMeta(node: ESTree.MetaProperty): boolean { + return node.meta.name === 'import' && node.property.name === 'meta'; } -function keyName(key: AnyNode | undefined): string | null { - if (!key) return null; - key = unwrap(key); - if (key.type === 'TemplateLiteral' && key.expressions.length === 0) - return key.quasis[0]?.value.cooked ?? null; - if (key.type === 'Identifier') return (key as ESTree.IdentifierName).name; - if (key.type === 'Literal') { - const value = (key as { value?: unknown }).value; - return typeof value === 'string' ? value : null; +function isContainerHost(context: Context, member: ESTree.MemberExpression): boolean { + const hostName = staticPropertyName(member); + if (hostName === null || !ENV_HOSTS.has(hostName)) return false; + const container = unwrap(member.object); + return ( + container.type === 'Identifier' && + CONTAINER_GLOBALS.has(container.name) && + isUnshadowedGlobal(context, container, container.name) + ); +} + +function isMutatingCall( + context: Context, + call: ESTree.CallExpression, + reference: AnyNode, +): boolean { + if (call.arguments[0] !== reference || call.callee.type !== 'MemberExpression') return false; + const member = call.callee; + if (member.object.type !== 'Identifier') return false; + const namespace = member.object; + const members = MUTATING_CALLS.get(namespace.name); + const name = staticPropertyName(member); + return ( + members !== undefined && + name !== null && + members.has(name) && + isUnshadowedGlobal(context, namespace, namespace.name) + ); +} + +function isMutation(context: Context, parent: AnyNode | null, reference: AnyNode): boolean { + switch (parent?.type) { + case 'AssignmentExpression': + return parent.left === reference; + case 'UnaryExpression': + return parent.operator === 'delete'; + case 'UpdateExpression': + return true; + case 'CallExpression': + return isMutatingCall(context, parent, reference); + default: + return false; + } +} + +function patternSource(node: AnyNode): AnyNode | null { + const parent = parentOf(node); + switch (parent?.type) { + case 'VariableDeclarator': + return parent.init; + case 'AssignmentExpression': + case 'AssignmentPattern': + return parent.right; + default: + return null; } - return null; +} + +function isEnvProperty(property: ESTree.ObjectPattern['properties'][number]): boolean { + if (property.type !== 'Property') return false; + return sharedKeyName(property.key, property.computed, { templates: true, unwrap: {} }) === 'env'; } /** Effect-native rule: configuration is declared with `Config` and provided by one `ConfigProvider`. */ @@ -240,136 +256,65 @@ export const rule = defineRule({ }, create(context) { const options = readOptions(context.options[0]); - const path = workspacePath(context.filename); - if (!matchesAny(`/${path}`, options.includePaths)) return {}; - if (matchesAny(`/${path}`, options.allowPaths)) return {}; - if (options.ignoreTestFiles && isTestFile(`/${path}`)) return {}; - - const printed = (node: AnyNode): string => { - const text = context.sourceCode.getText(node).replace(/\s+/gu, ' ').trim(); - return text.length > 72 ? `${text.slice(0, 69)}...` : text; - }; + if (!includesRuleFile(context.filename, options)) return {}; + + const printed = (node: AnyNode): string => + snippet(context.sourceCode.getText(node), 72, 69, '...'); const report = (node: AnyNode, messageId: string): void => { context.report({ node, messageId, data: { expression: printed(node) } }); }; - /** `true` when this expression evaluates to an environment-owning host object. */ - // Bounded, scope-resolved immutable aliases only; no cross-module value flow or reassignment inference. + const identifierHost = (inner: AnyNode & { readonly name: string }, depth: number): boolean => { + const variable = resolveVariable(context, inner.name, inner); + const definition = variable?.defs.length === 1 ? variable.defs[0] : undefined; + if (definition?.type === 'ImportBinding') { + return isProcessImport(definition.node as ESTree.ImportDeclaration['specifiers'][number]); + } + if (definition?.type === 'Variable') { + const initializer = immutableInitializer(definition.node as ESTree.VariableDeclarator); + if (initializer) return isEnvHost(initializer, depth + 1); + } + return ENV_HOSTS.has(inner.name) && isUnshadowedGlobal(context, inner, inner.name); + }; + + // Bounded immutable aliases only; no cross-module flow or reassignment inference. const isEnvHost = (node: AnyNode, depth = 0): boolean => { if (depth > 16) return false; const inner = unwrap(node); - if (inner.type === 'AwaitExpression') return isEnvHost(inner.argument as AnyNode, depth + 1); - if (inner.type === 'ImportExpression') { - const source = unwrap(inner.source as AnyNode); - return source.type !== 'Identifier' && PROCESS_MODULES.has(keyName(source) ?? ''); - } - if ( - inner.type === 'CallExpression' && - isUnshadowedGlobal(context, unwrap(inner.callee as AnyNode), 'require') - ) { - const argument = inner.arguments[0]; - if (!argument) return false; - const source = unwrap(argument as AnyNode); - return source.type !== 'Identifier' && PROCESS_MODULES.has(keyName(source) ?? ''); - } - if (inner.type === 'MetaProperty') { - const meta = inner as ESTree.MetaProperty; - return meta.meta.name === 'import' && meta.property.name === 'meta'; - } - if (inner.type === 'Identifier') { - const name = (inner as ESTree.IdentifierReference).name; - const variable = resolveVariable(context, name, inner); - const definition = variable?.defs.length === 1 ? variable.defs[0] : undefined; - if (definition?.type === 'ImportBinding') { - const specifier = definition.node as ESTree.ImportDeclaration['specifiers'][number]; - const declaration = parentOf(specifier as AnyNode) as ESTree.ImportDeclaration; + switch (inner.type) { + case 'AwaitExpression': + return isEnvHost(inner.argument, depth + 1); + case 'ImportExpression': + return isProcessSource(inner.source); + case 'CallExpression': return ( - declaration?.type === 'ImportDeclaration' && - declaration.importKind !== 'type' && - PROCESS_MODULES.has(declaration.source.value) && - (specifier.type === 'ImportDefaultSpecifier' || - specifier.type === 'ImportNamespaceSpecifier' || - (specifier.importKind !== 'type' && - keyName(specifier.imported as AnyNode) === 'default')) + isUnshadowedGlobal(context, unwrap(inner.callee), 'require') && + isProcessSource(inner.arguments[0]) ); - } - if (definition?.type === 'Variable') { - const declaration = definition.node as ESTree.VariableDeclarator; - if ( - declaration.id.type === 'Identifier' && - declaration.init && - (parentOf(declaration as AnyNode) as ESTree.VariableDeclaration)?.kind === 'const' - ) { - return isEnvHost(declaration.init as AnyNode, depth + 1); - } - } - return ENV_HOSTS.has(name) && isUnshadowedGlobal(context, inner, name); - } - if (inner.type === 'MemberExpression') { - // `globalThis.process`, `window.Deno`, `global["process"]`. - const member = inner as ESTree.MemberExpression; - const hostName = staticPropertyName(member); - if (hostName === null || !ENV_HOSTS.has(hostName)) return false; - const container = unwrap(member.object as AnyNode); - if (container.type !== 'Identifier') return false; - const containerName = (container as ESTree.IdentifierReference).name; - return ( - CONTAINER_GLOBALS.has(containerName) && - isUnshadowedGlobal(context, container, containerName) - ); + case 'MetaProperty': + return isImportMeta(inner); + case 'Identifier': + return identifierHost(inner, depth); + case 'MemberExpression': + return isContainerHost(context, inner); + default: + return false; } - return false; }; - /** - * `read` or `mutation` for an `.env` node: climb the member chain the access continues - * into (`process.env` → `process.env.X` → `process.env.X.y`) and inspect what consumes it. - */ + /** Climb the continued member chain before classifying its consumer. */ const classify = (envNode: AnyNode): string => { - let current = envNode; - while (true) { - const { node: reference, parent } = skipWrappers(current); - if (parent === null) return 'ambientEnvRead'; - if ( - parent.type === 'MemberExpression' && - (parent as ESTree.MemberExpression).object === reference - ) { - current = parent; - continue; - } - if (parent.type === 'AssignmentExpression') { - return (parent as ESTree.AssignmentExpression).left === (reference as never) - ? 'ambientEnvMutation' - : 'ambientEnvRead'; - } - if ( - parent.type === 'UnaryExpression' && - (parent as ESTree.UnaryExpression).operator === 'delete' - ) { - return 'ambientEnvMutation'; - } - if (parent.type === 'UpdateExpression') return 'ambientEnvMutation'; - if (parent.type === 'CallExpression') { - const call = parent as ESTree.CallExpression; - if ((call.arguments[0] as AnyNode | undefined) !== reference) return 'ambientEnvRead'; - const callee = call.callee as AnyNode; - if (callee.type !== 'MemberExpression') return 'ambientEnvRead'; - const member = callee as ESTree.MemberExpression; - const namespace = member.object as AnyNode; - if (namespace.type !== 'Identifier') return 'ambientEnvRead'; - const namespaceName = (namespace as ESTree.IdentifierReference).name; - const members = MUTATING_CALLS.get(namespaceName); - const memberName = staticPropertyName(member); - if (members === undefined || memberName === null || !members.has(memberName)) - return 'ambientEnvRead'; - return isUnshadowedGlobal(context, namespace, namespaceName) - ? 'ambientEnvMutation' - : 'ambientEnvRead'; - } - return 'ambientEnvRead'; + let current = skipWrappers(envNode); + while ( + current.parent?.type === 'MemberExpression' && + current.parent.object === current.node + ) { + current = skipWrappers(current.parent); } - return 'ambientEnvRead'; + return isMutation(context, current.parent, current.node) + ? 'ambientEnvMutation' + : 'ambientEnvRead'; }; return { @@ -415,21 +360,10 @@ export const rule = defineRule({ // `const { env } = process` / `const { env: environment } = globalThis.process`. ObjectPattern(node) { - const parent = parentOf(node as unknown as AnyNode); - if (parent === null) return; - const source = - parent.type === 'VariableDeclarator' - ? ((parent as ESTree.VariableDeclarator).init as AnyNode | null) - : parent.type === 'AssignmentExpression' || parent.type === 'AssignmentPattern' - ? ((parent as ESTree.AssignmentExpression).right as AnyNode) - : null; + const source = patternSource(node); if (source === null || !isEnvHost(source)) return; for (const property of node.properties) { - if (property.type !== 'Property') continue; - const key = unwrap(property.key as AnyNode); - if (property.computed && key.type === 'Identifier') continue; - if (keyName(key) !== 'env') continue; - report(property as unknown as AnyNode, 'ambientEnvRead'); + if (isEnvProperty(property)) report(property, 'ambientEnvRead'); } }, }; diff --git a/app/tools/oxlint/effect-native/rules/no-async-script-program.ts b/app/tools/oxlint/effect-native/rules/no-async-script-program.ts index c789e65df..0b08f0f55 100644 --- a/app/tools/oxlint/effect-native/rules/no-async-script-program.ts +++ b/app/tools/oxlint/effect-native/rules/no-async-script-program.ts @@ -53,17 +53,26 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Ranged, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree, Ranged } from '@oxlint/plugins'; -import { collectEffectBindings, effectMember } from '../shared/effect-imports.ts'; +import { collectEffectBindings } from '../shared/effect-imports.ts'; import type { EffectBindings as ImportedEffectBindings } from '../shared/effect-imports.ts'; type EffectBindings = ImportedEffectBindings & { context: Context }; -import { globToRegExp, isScriptFile, isTestFile, matchesAny } from '../shared/paths.ts'; +import { globToRegExp, inScriptScope, scriptScope, matchesAny } from '../shared/paths.ts'; -type AnyNode = ESTree.Node; +import { + parentOf, + skipWrappers as climbWrappers, + unwrapNode, + nearestFunction as enclosingFunction, + syntax, + literalText, + propertyText, +} from '../shared/ast.ts'; +import { provenance } from '../shared/provenance.ts'; +import { stringList, booleanOption } from '../shared/options.ts'; -/** Run adapters that legitimately sit at the executable edge (`Effect.*` or a `ManagedRuntime`). */ -const RUN_ADAPTER = /^run(?:Promise|Sync|Fork|Callback)(?:Exit)?(?:With)?$/u; +type AnyNode = ESTree.Node; /** Wrappers that do not change "is this expression the callee / argument of its parent". */ const TRANSPARENT_PARENTS = new Set([ @@ -101,64 +110,24 @@ const DEFAULTS: RuleOptions = { function readOptions(raw: unknown): RuleOptions { const given = (raw ?? {}) as Partial>; - const strings = (value: unknown, fallback: readonly string[]): readonly string[] => - Array.isArray(value) && value.every((entry) => typeof entry === 'string') - ? (value as readonly string[]) - : fallback; return { - allowPaths: strings(given.allowPaths, DEFAULTS.allowPaths), - driverEdgeCallees: strings(given.driverEdgeCallees, DEFAULTS.driverEdgeCallees), - reportTopLevelAwait: - typeof given.reportTopLevelAwait === 'boolean' - ? given.reportTopLevelAwait - : DEFAULTS.reportTopLevelAwait, - scriptPaths: strings(given.scriptPaths, DEFAULTS.scriptPaths), + allowPaths: stringList(given.allowPaths, DEFAULTS.allowPaths), + driverEdgeCallees: stringList(given.driverEdgeCallees, DEFAULTS.driverEdgeCallees), + reportTopLevelAwait: booleanOption(given.reportTopLevelAwait, DEFAULTS.reportTopLevelAwait), + scriptPaths: stringList(given.scriptPaths, DEFAULTS.scriptPaths), }; } -function parentOf(node: AnyNode): AnyNode | null { - return (node as { parent?: AnyNode | null }).parent ?? null; -} - -/** Climb through parentheses/type wrappers; returns the outermost equivalent node and its parent. */ -function skipWrappers(node: AnyNode): { readonly node: AnyNode; readonly parent: AnyNode | null } { - let current = node; - let parent = parentOf(current); - while (parent !== null && TRANSPARENT_PARENTS.has(parent.type)) { - current = parent; - parent = parentOf(current); - } - return { node: current, parent }; +function skipWrappers(node: AnyNode) { + return climbWrappers(node, TRANSPARENT_PARENTS); } -/** Peel wrappers *downwards*, e.g. `(await x)` / `x as Promise` around an expression. */ function unwrap(node: AnyNode): AnyNode { - let current = node; - while (TRANSPARENT_PARENTS.has(current.type)) { - const inner = (current as { expression?: AnyNode }).expression; - if (inner === undefined || inner === null) return current; - current = inner; - } - return current; + return unwrapNode(node, { wrappers: TRANSPARENT_PARENTS }); } function nearestFunction(node: AnyNode): AnyNode | null { - let current = parentOf(node); - while (current !== null) { - if (FUNCTION_LIKE.has(current.type)) return current; - current = parentOf(current); - } - return null; -} - -/** Static property name of a member expression, including `x["name"]`. */ -function staticPropertyName(node: ESTree.MemberExpression): string | null { - const property = syntax(node.property) as AnyNode; - if (!node.computed) - return property.type === 'Identifier' ? (property as ESTree.IdentifierName).name : null; - if (property.type !== 'Literal') return null; - const value = (property as { value?: unknown }).value; - return typeof value === 'string' ? value : null; + return enclosingFunction(node, FUNCTION_LIKE); } /** Static key name of an object property / class member, including `{ ["try"]: … }`. */ @@ -251,65 +220,64 @@ function insideDriverEdge( * `Effect.runPromise(main())`, `Effect.runPromiseExit(main())`, `runtime.runPromise(main())` * (a captured `ManagedRuntime`, the A1 target) or a `pipe`/`.pipe` chain ending in such a member. */ +function isRunAdapter(context: Context, node: unknown): boolean { + return /^(?:Effect|Runtime)\.run(?:Promise|Sync|Fork|Callback)(?:Exit)?(?:With)?$/u.test( + provenance(context, node) ?? '', + ); +} + +function isPipeCall(context: Context, callee: unknown): boolean { + if (provenance(context, callee) === 'pipe') return true; + const member = syntax(callee); + return member?.type === 'MemberExpression' && propertyText(member) === 'pipe'; +} + function isRunAdapterExpression(node: AnyNode, context: Context): boolean { const expression = syntax(node); if (expression?.type !== 'CallExpression') return false; const callee = syntax(expression.callee); - const identity = provenance(context, callee); - if ( - identity && - /^(?:Effect|Runtime)\.run(?:Promise|Sync|Fork|Callback)(?:Exit)?(?:With)?$/u.test(identity) - ) - return true; + if (isRunAdapter(context, callee)) return true; if ( callee?.type === 'MemberExpression' && ['then', 'catch', 'finally'].includes(propertyText(callee) ?? '') ) return isRunAdapterExpression(callee.object, context); - const isPipe = - identity === 'pipe' || (callee?.type === 'MemberExpression' && propertyText(callee) === 'pipe'); return ( - isPipe && - expression.arguments.some((arg: AnyNode) => - /^(?:Effect|Runtime)\.run(?:Promise|Sync|Fork|Callback)(?:Exit)?(?:With)?$/u.test( - provenance(context, arg) ?? '', - ), - ) + isPipeCall(context, callee) && + expression.arguments.some((arg: AnyNode) => isRunAdapter(context, arg)) ); } +const MEMBER_PARENTS = new Set([ + 'Property', + 'MethodDefinition', + 'PropertyDefinition', + 'TSAbstractMethodDefinition', +]); + +function variableFunctionName(fn: AnyNode): Extract | null { + const parent = parentOf(fn); + return parent?.type === 'VariableDeclarator' && + parent.init === fn && + parent.id?.type === 'Identifier' + ? parent.id + : null; +} + +function declaredFunctionName(fn: AnyNode): Extract | null { + const declared = (fn as { id?: AnyNode | null }).id; + return declared?.type === 'Identifier' ? declared : null; +} + /** A tight report anchor: the declared name, the member key, or the `async` keyword itself. */ function functionAnchor(fn: AnyNode): Ranged { const parent = parentOf(fn); - if (parent !== null) { - const keyed = parent as { key?: AnyNode; value?: AnyNode; id?: AnyNode; init?: AnyNode }; - if ( - (parent.type === 'Property' || - parent.type === 'MethodDefinition' || - parent.type === 'PropertyDefinition' || - parent.type === 'TSAbstractMethodDefinition') && - keyed.value === fn && - keyed.key !== undefined && - keyed.key !== null - ) { - return { range: [...(keyed.key as ESTree.Span).range] }; - } - if ( - parent.type === 'VariableDeclarator' && - keyed.init === fn && - keyed.id?.type === 'Identifier' - ) { - return { range: [...(keyed.id as ESTree.Span).range] }; - } - } - const declared = (fn as { id?: AnyNode | null }).id; - if (declared !== undefined && declared !== null && declared.type === 'Identifier') { - return { range: [...(declared as ESTree.Span).range] }; - } - return keywordAnchor(fn, KEYWORD_LENGTH); + const memberKey = + parent && MEMBER_PARENTS.has(parent.type) && parent.value === fn ? parent.key : null; + const anchor = memberKey ?? variableFunctionName(fn) ?? declaredFunctionName(fn); + return anchor ? { range: [...(anchor as ESTree.Span).range] } : keywordAnchor(fn, KEYWORD_LENGTH); } -/** Anchor a diagnostic on the leading keyword (`async`, `await`, `for`) instead of a whole body. */ function keywordAnchor(node: AnyNode, length: number): Ranged { const span = node as ESTree.Span; return { range: [span.start, Math.min(span.start + length, span.end)] }; @@ -318,25 +286,8 @@ function keywordAnchor(node: AnyNode, length: number): Ranged { /** A readable name for the reported function, used in the diagnostic text. */ function functionLabel(fn: AnyNode): string { const parent = parentOf(fn); - if (parent !== null) { - const keyed = parent as { key?: AnyNode; value?: AnyNode; id?: AnyNode; init?: AnyNode }; - if (keyed.value === fn) { - const key = staticKeyName(parent); - if (key !== null) return key; - } - if ( - parent.type === 'VariableDeclarator' && - keyed.init === fn && - keyed.id?.type === 'Identifier' - ) { - return (keyed.id as ESTree.IdentifierName).name; - } - } - const declared = (fn as { id?: AnyNode | null }).id; - if (declared !== undefined && declared !== null && declared.type === 'Identifier') { - return (declared as ESTree.IdentifierName).name; - } - return 'this callback'; + const key = parent?.value === fn ? staticKeyName(parent) : null; + return key ?? variableFunctionName(fn)?.name ?? declaredFunctionName(fn)?.name ?? 'this callback'; } export const rule = defineRule({ @@ -457,213 +408,3 @@ export const rule = defineRule({ }; }, }); - -/** Bounded, lexical provenance only; no type checker or interprocedural/data-flow inference. */ -type Syntax = ESTree.Node & Record; -function syntax(node: unknown): Syntax | null { - let n = node as Syntax | null; - while ( - n && - [ - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSTypeAssertion', - 'TSInstantiationExpression', - 'ParenthesizedExpression', - 'ChainExpression', - 'AwaitExpression', - ].includes(n.type) - ) - n = n.expression ?? n.argument; - return n; -} -function lexicalVariable(context: Context, node: Syntax): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(node); - while (scope) { - const v = scope.set.get(node.name); - if (v) return v; - scope = scope.upper; - } - return null; -} -function literalText(node: unknown): string | null { - const n = syntax(node); - if (n?.type === 'Literal' && typeof n.value === 'string') return n.value; - if (n?.type === 'TemplateLiteral' && n.expressions.length === 0) - return n.quasis[0]?.value.cooked ?? null; - return null; -} -function propertyText(node: unknown): string | null { - const n = node as Syntax; - const key = syntax(n.property ?? n.key); - return !n.computed && key?.type === 'Identifier' ? key.name : literalText(key); -} -function moduleIdentity(source: string): string { - if (/^(?:node:)?(?:process|console|util|module)$/.test(source)) - return source.replace(/^node:/, ''); - if (source === 'effect/Effect') return 'Effect'; - if (source === 'effect/ManagedRuntime') return 'ManagedRuntime'; - return source; -} -function bindingPath(pattern: Syntax, name: string): string[] | null { - if (pattern.type === 'Identifier') return pattern.name === name ? [] : null; - if (pattern.type === 'AssignmentPattern') return bindingPath(pattern.left, name); - if (pattern.type !== 'ObjectPattern') return null; - for (const p of pattern.properties) { - if (p.type !== 'Property') continue; - const key = propertyText(p), - tail = bindingPath(p.value, name); - if (key !== null && tail !== null) return [key, ...tail]; - } - return null; -} -function provenance(context: Context, node: unknown, seen = new Set()): string | null { - const n = syntax(node); - if (!n) return null; - if (n.type === 'Identifier') { - const v = lexicalVariable(context, n); - if (!v || v.defs.length === 0) - return [ - 'process', - 'console', - 'Bun', - 'globalThis', - 'global', - 'window', - 'self', - 'require', - 'Array', - 'Set', - ].includes(n.name) - ? n.name - : null; - if (seen.has(v) || v.defs.length !== 1) return null; - const next = new Set(seen); - next.add(v); - const def = v.defs[0] as any; - if (def.type === 'ImportBinding') { - const spec = def.node as Syntax; - const decl = (def.parent ?? spec.parent) as Syntax; - if (decl.importKind === 'type' || spec.importKind === 'type') return null; - const source = literalText(decl.source); - if (!source) return null; - const base = moduleIdentity(source); - if (spec.type === 'ImportNamespaceSpecifier' || spec.type === 'ImportDefaultSpecifier') - return base; - const name = spec.imported?.name ?? spec.imported?.value; - if (name === 'default') return base; - if (base === 'effect') return name; - return `${base}.${name}`; - } - if (def.type !== 'Variable' || def.node.type !== 'VariableDeclarator') return null; - // A declaration is not a reaching-definition analysis: reassigned aliases are unknown. - if (v.references.some((r: any) => r.init !== true && r.isWrite())) return null; - const d = def.node as Syntax; - const base = provenance(context, d.init, next), - path = bindingPath(d.id, n.name); - return base !== null && path !== null ? [base, ...path].join('.') : null; - } - if (n.type === 'MemberExpression') { - const base = provenance(context, n.object, seen), - key = propertyText(n); - if (base === null || key === null) return null; - if ( - ['globalThis', 'global', 'window', 'self'].includes(base) && - ['process', 'console', 'Bun'].includes(key) - ) - return key; - if (['process', 'console', 'util', 'module'].includes(base) && key === 'default') return base; - if (base === 'effect') return key; - return `${base}.${key}`; - } - if (n.type === 'ImportExpression') { - const text = literalText(n.source); - return text === null ? null : moduleIdentity(text); - } - if (n.type === 'CallExpression') { - const callee = provenance(context, n.callee, seen); - if (callee === 'require') { - const text = literalText(n.arguments[0]); - return text === null ? null : moduleIdentity(text); - } - if (callee === 'module.createRequire') return 'require'; - if (callee === 'ManagedRuntime.make') return 'Runtime'; - } - return null; -} -/** Only value references, never property names, bindings or TS-only identifiers. */ -function valueReference(context: Context, node: unknown): boolean { - const n = node as Syntax, - p = n.parent as Syntax | undefined; - if (!p) return false; - if (p.type.startsWith('Import') || p.type === 'ExportSpecifier') return false; - if (p.type === 'MemberExpression' && p.property === n && !p.computed) return false; - if ( - [ - 'Property', - 'PropertyDefinition', - 'MethodDefinition', - 'TSPropertySignature', - 'TSMethodSignature', - ].includes(p.type) && - p.key === n && - !p.computed && - !(p.shorthand && p.value === n) - ) - return false; - if (['LabeledStatement', 'BreakStatement', 'ContinueStatement'].includes(p.type)) return false; - let child: Syntax = n; - let parent: Syntax | null = p; - while (parent) { - if ( - parent.type.startsWith('TS') && - !( - [ - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSTypeAssertion', - 'TSInstantiationExpression', - ].includes(parent.type) && parent.expression === child - ) - ) - return false; - if ( - parent.type.endsWith('Statement') || - parent.type.endsWith('Declaration') || - parent.type.includes('Function') - ) - break; - child = parent; - parent = parent.parent as Syntax | null; - } - const v = lexicalVariable(context, n); - return ( - !v || - v.references.some( - (r: any) => - r.identifier === n && - r.isRead() && - (typeof r.isValueReference !== 'function' || r.isValueReference()), - ) - ); -} -/** Strip fixture scaffolding first; do not renormalise a relative script path around inner markers. */ -function scriptScope(filename: string): string { - const unified = filename.replaceAll('\\', '/'); - const fixture = unified.match( - /(?:^|\/)tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\/(.*)$/u, - ); - if (fixture) return fixture[1]; - if (!unified.startsWith('/') && !/^[A-Za-z]:\//u.test(unified)) - return unified.replace(/^\.\//, ''); - const match = unified.match(/(?:^|\/)((?:apps|packages|verticals|scripts|tools)\/.*)$/u); - return match?.[1] ?? unified; -} -function inScriptScope(path: string): boolean { - return ( - /(?:^|\/)scripts\//u.test(path) && - !/(?:^|\/)(?:tests?|__tests__)\/|\.(?:test|spec|test-d|spec-d)\.[cm]?[jt]sx?$/u.test(path) - ); -} diff --git a/app/tools/oxlint/effect-native/rules/no-bare-effect-run.ts b/app/tools/oxlint/effect-native/rules/no-bare-effect-run.ts index b9efc52ef..012a519b8 100644 --- a/app/tools/oxlint/effect-native/rules/no-bare-effect-run.ts +++ b/app/tools/oxlint/effect-native/rules/no-bare-effect-run.ts @@ -39,11 +39,24 @@ * (`export { runPromise }`), none of which start a fiber. */ import { defineRule } from '@oxlint/plugins'; +import { keyName, unwrapNode, walk as walkAst } from '../shared/ast.ts'; +import { isTrackedReference } from '../shared/bindings.ts'; +import { importedName } from '../shared/imports.ts'; +import { + isNonReferencePosition, + isInTypePosition as inTypePosition, +} from '../shared/reference-positions.ts'; import { bindingsFor, effectMember, type EffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isScriptFile, isTestFile, normalisePath } from '../shared/paths.ts'; +import { + globToRegExp, + isScriptFile, + isTestFile, + normalisePath, + matchesGlobs, +} from '../shared/paths.ts'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree } from '@oxlint/plugins'; /** `runPromise`, `runSync`, `runFork`, `run` — but not `runtime`. */ const RUN_MEMBER = /^run(?:[A-Z]|$)/u; @@ -130,11 +143,6 @@ function readOptions(context: Context): RuleOptions { }; } -/** Match a workspace-relative path against globs directly (never re-normalising an already relative path). */ -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - /** * Local bindings that can start a root fiber, tracked precisely enough to survive aliasing, * destructuring, re-binding and type-only imports. @@ -152,24 +160,8 @@ interface RunBindings { readonly tracked: boolean; } -function isNode(value: unknown): value is ESTree.Node { - return ( - typeof value === 'object' && - value !== null && - typeof (value as { type?: unknown }).type === 'string' - ); -} - -/** Depth-first walk over the AST, skipping the circular `parent` links. */ function walk(node: ESTree.Node, visit: (node: ESTree.Node) => void): void { - visit(node); - for (const key of Object.keys(node)) { - if (key === 'parent') continue; - const value: unknown = (node as unknown as Record)[key]; - if (Array.isArray(value)) { - for (const entry of value) if (isNode(entry)) walk(entry, visit); - } else if (isNode(value)) walk(value, visit); - } + walkAst(node, {}, visit, false); } /** TS nodes that still contain runtime expressions; every other `TS*` ancestor means a type position. */ @@ -185,54 +177,22 @@ const TS_EXPRESSION_NODES = new Set([ ]); /** Strip parentheses and expression-level TS wrappers so `(Effect as typeof Effect).runSync` is still seen. */ +const EXPRESSION_WRAPPERS = new Set([ + 'ParenthesizedExpression', + 'TSAsExpression', + 'TSSatisfiesExpression', + 'TSNonNullExpression', + 'TSInstantiationExpression', + 'TSTypeAssertion', +]); function unwrapExpression(node: ESTree.Node): ESTree.Node { - let current = node; - for (;;) { - if ( - current.type === 'ParenthesizedExpression' || - current.type === 'TSAsExpression' || - current.type === 'TSSatisfiesExpression' || - current.type === 'TSNonNullExpression' || - current.type === 'TSInstantiationExpression' || - current.type === 'TSTypeAssertion' - ) { - const inner: unknown = (current as unknown as Record)['expression']; - if (!isNode(inner)) return current; - current = inner; - continue; - } - return current; - } + return unwrapNode(node, { wrappers: EXPRESSION_WRAPPERS }); } - -/** True when the node only ever appears in an erased type position (`typeof X`, interface member, ...). */ function isInTypePosition(node: ESTree.Node): boolean { - let current: ESTree.Node | null = node.parent; - while (current !== null && current.type !== 'Program') { - if (current.type.startsWith('TS') && !TS_EXPRESSION_NODES.has(current.type)) return true; - current = current.parent; - } - return false; + return inTypePosition(node, TS_EXPRESSION_NODES); } - function staticName(key: ESTree.Node, computed: boolean): string | null { - if (!computed) { - if (key.type === 'Identifier') return key.name; - if (key.type === 'Literal' && typeof key.value === 'string') return key.value; - return null; - } - if (key.type === 'Literal' && typeof key.value === 'string') return key.value; - if (key.type === 'TemplateLiteral' && key.expressions.length === 0 && key.quasis.length === 1) { - const quasi = key.quasis[0]; - return quasi === undefined ? null : (quasi.value.cooked ?? quasi.value.raw); - } - return null; -} - -function importedName(specifier: ESTree.ImportSpecifier): string { - return specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; + return keyName(key, computed, { templates: computed, rawTemplates: true, singleQuasi: true }); } /** @@ -242,55 +202,81 @@ function importedName(specifier: ESTree.ImportSpecifier): string { * `run*` named import only counts when it comes from `effect` / `effect/Effect` — `runPromise` imported * from `effect/Runtime` or `effect/ManagedRuntime` is the prescribed A1 replacement, not the smell. */ +type CollectedBindings = Omit; +interface ImportSource { + readonly rootLike: boolean; + readonly effectSubmodule: boolean; + readonly emptySubmodule: boolean; +} +function collectNamedBinding( + specifier: ESTree.ImportSpecifier, + source: ImportSource, + bindings: CollectedBindings, +): void { + if (specifier.importKind === 'type') return; + const imported = importedName(specifier); + const local = specifier.local.name; + if (source.effectSubmodule) bindings.effectSubmoduleImports.set(local, imported); + if (imported === EFFECT_NAMESPACE && source.rootLike) { + bindings.effectNamespaces.set(local, specifier.local); + } else if (RUN_MEMBER.test(imported) && (source.rootLike || source.effectSubmodule)) { + bindings.runLocals.set(local, { declaration: specifier.local, member: imported }); + } +} +function collectImportBindings( + statement: ESTree.ImportDeclaration, + extraMatchers: readonly RegExp[], + bindings: CollectedBindings, +): void { + if (statement.importKind === 'type') return; + const source = statement.source.value; + const effectModule = SHARED_EFFECT_MODULE.test(source); + const extraModule = !effectModule && extraMatchers.some((matcher) => matcher.test(source)); + if (!effectModule && !extraModule) return; + const policy = { + rootLike: extraModule || source === EFFECT_ROOT_MODULE, + effectSubmodule: source === EFFECT_SUBMODULE, + emptySubmodule: source.split('/').slice(1).join('/') === '', + }; + for (const specifier of statement.specifiers) collectImportSpecifier(specifier, policy, bindings); +} +function collectImportSpecifier( + specifier: ESTree.ImportDeclaration['specifiers'][number], + source: ImportSource, + bindings: CollectedBindings, +): void { + if (specifier.type === 'ImportSpecifier') { + collectNamedBinding(specifier, source, bindings); + } else if (specifier.type === 'ImportNamespaceSpecifier') { + if (source.effectSubmodule) + bindings.effectNamespaces.set(specifier.local.name, specifier.local); + else if (source.rootLike || source.emptySubmodule) + bindings.packageNamespaces.set(specifier.local.name, specifier.local); + } +} function collectRunBindings(context: Context, effectModules: readonly string[]): RunBindings { - const effectNamespaces = new Map(); - const packageNamespaces = new Map(); - const runLocals = new Map(); - const effectSubmoduleImports = new Map(); + const bindings: CollectedBindings = { + effectNamespaces: new Map(), + packageNamespaces: new Map(), + runLocals: new Map(), + effectSubmoduleImports: new Map(), + }; const extraMatchers = effectModules .filter((module) => !SHARED_EFFECT_MODULE.test(module)) .map((module) => globToRegExp(module)); - const ast = context.sourceCode.ast; for (const statement of ast.body) { - if (statement.type !== 'ImportDeclaration') continue; - if (statement.importKind === 'type') continue; - const source = statement.source.value; - const isEffectModule = SHARED_EFFECT_MODULE.test(source); - const isExtraModule = !isEffectModule && extraMatchers.some((matcher) => matcher.test(source)); - if (!isEffectModule && !isExtraModule) continue; - const submodule = source.split('/').slice(1).join('/'); - const isRootLike = isExtraModule || source === EFFECT_ROOT_MODULE; - const isEffectSubmodule = source === EFFECT_SUBMODULE; - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportSpecifier') { - if (specifier.importKind === 'type') continue; - const imported = importedName(specifier); - const local = specifier.local.name; - if (isEffectSubmodule) effectSubmoduleImports.set(local, imported); - if (imported === EFFECT_NAMESPACE && isRootLike) { - effectNamespaces.set(local, specifier.local); - } else if (RUN_MEMBER.test(imported) && (isRootLike || isEffectSubmodule)) { - runLocals.set(local, { declaration: specifier.local, member: imported }); - } - } else if (specifier.type === 'ImportNamespaceSpecifier') { - if (isEffectSubmodule) effectNamespaces.set(specifier.local.name, specifier.local); - else if (isRootLike || submodule === '') - packageNamespaces.set(specifier.local.name, specifier.local); - } - } - } - - if (effectNamespaces.size > 0 || packageNamespaces.size > 0) { - propagateLocalAliases(context, ast, effectNamespaces, packageNamespaces, runLocals); + if (statement.type === 'ImportDeclaration') + collectImportBindings(statement, extraMatchers, bindings); } - + if (bindings.effectNamespaces.size > 0 || bindings.packageNamespaces.size > 0) + propagateLocalAliases(context, ast, bindings); return { - effectNamespaces, - effectSubmoduleImports, - packageNamespaces, - runLocals, - tracked: effectNamespaces.size > 0 || packageNamespaces.size > 0 || runLocals.size > 0, + ...bindings, + tracked: + bindings.effectNamespaces.size > 0 || + bindings.packageNamespaces.size > 0 || + bindings.runLocals.size > 0, }; } @@ -298,98 +284,97 @@ function collectRunBindings(context: Context, effectModules: readonly string[]): * Follow `const Fx = Effect;`, `const Fx = Pkg.Effect;`, `const { runSync } = Effect;` and * `const { Effect } = Pkg;` to a fixed point, so a one-line re-binding cannot defeat the rule. */ +function trackedNamespace( + context: Context, + node: ESTree.Node, + namespaces: ReadonlyMap, +): boolean { + if (node.type !== 'Identifier') return false; + const declaration = namespaces.get(node.name); + return declaration !== undefined && isTrackedReference(context, node, declaration); +} +function packageEffectRoot(node: ESTree.Node): ESTree.Node | null { + if (node.type !== 'MemberExpression') return null; + if (staticName(node.property, node.computed) !== EFFECT_NAMESPACE) return null; + return unwrapExpression(node.object); +} +function namespaceKind( + context: Context, + init: ESTree.Node, + bindings: CollectedBindings, +): 'effect' | 'package' | null { + if (init.type === 'Identifier') { + if (trackedNamespace(context, init, bindings.effectNamespaces)) return 'effect'; + return trackedNamespace(context, init, bindings.packageNamespaces) ? 'package' : null; + } + const root = packageEffectRoot(init); + return root !== null && trackedNamespace(context, root, bindings.packageNamespaces) + ? 'effect' + : null; +} +function addNamespace( + map: Map, + target: Extract, +): boolean { + if (map.has(target.name)) return false; + map.set(target.name, target); + return true; +} +function addDestructuredAlias( + property: ESTree.ObjectPattern['properties'][number], + kind: 'effect' | 'package', + bindings: CollectedBindings, +): boolean { + if (property.type !== 'Property') return false; + const name = staticName(property.key, property.computed); + if (name === null) return false; + const value = property.value.type === 'AssignmentPattern' ? property.value.left : property.value; + if (value.type !== 'Identifier') return false; + if (kind === 'package') + return name === EFFECT_NAMESPACE && addNamespace(bindings.effectNamespaces, value); + if (!RUN_MEMBER.test(name) || bindings.runLocals.has(value.name)) return false; + bindings.runLocals.set(value.name, { declaration: value, member: name }); + return true; +} +function propagateDeclarator( + context: Context, + declarator: ESTree.VariableDeclarator, + bindings: CollectedBindings, +): boolean { + if (declarator.init === null) return false; + const kind = namespaceKind(context, unwrapExpression(declarator.init), bindings); + if (kind === null) return false; + const target = declarator.id; + if (target.type === 'Identifier') + return addNamespace( + kind === 'effect' ? bindings.effectNamespaces : bindings.packageNamespaces, + target, + ); + if (target.type !== 'ObjectPattern') return false; + let changed = false; + for (const property of target.properties) { + if (addDestructuredAlias(property, kind, bindings)) changed = true; + } + return changed; +} function propagateLocalAliases( context: Context, ast: ESTree.Program, - effectNamespaces: Map, - packageNamespaces: Map, - runLocals: Map, + bindings: CollectedBindings, ): void { const declarators: ESTree.VariableDeclarator[] = []; walk(ast, (node) => { if (node.type === 'VariableDeclarator' && node.init !== null) declarators.push(node); }); - if (declarators.length === 0) return; - for (let pass = 0; pass < 5; pass += 1) { let changed = false; - const add = (map: Map, name: string, declaration: ESTree.Node): void => { - if (map.has(name)) return; - map.set(name, declaration); - changed = true; - }; for (const declarator of declarators) { - const init = declarator.init === null ? null : unwrapExpression(declarator.init); - if (init === null) continue; - let kind: 'effect' | 'package' | null = null; - if (init.type === 'Identifier') { - const effect = effectNamespaces.get(init.name); - const root = packageNamespaces.get(init.name); - if (effect !== undefined && isTrackedReference(context, init, effect)) kind = 'effect'; - else if (root !== undefined && isTrackedReference(context, init, root)) kind = 'package'; - } else if (init.type === 'MemberExpression') { - const object = unwrapExpression(init.object); - const property = staticName(init.property, init.computed); - if (object.type === 'Identifier' && property === EFFECT_NAMESPACE) { - const declaration = packageNamespaces.get(object.name); - if (declaration !== undefined && isTrackedReference(context, object, declaration)) - kind = 'effect'; - } - } - if (kind === null) continue; - const target = declarator.id; - if (target.type === 'Identifier') { - add(kind === 'effect' ? effectNamespaces : packageNamespaces, target.name, target); - continue; - } - if (target.type !== 'ObjectPattern') continue; - for (const property of target.properties) { - if (property.type !== 'Property') continue; - const name = staticName(property.key, property.computed); - if (name === null) continue; - const value = - property.value.type === 'AssignmentPattern' ? property.value.left : property.value; - if (value.type !== 'Identifier') continue; - if (kind === 'package') { - if (name === EFFECT_NAMESPACE) add(effectNamespaces, value.name, value); - continue; - } - if (!RUN_MEMBER.test(name) || runLocals.has(value.name)) continue; - runLocals.set(value.name, { declaration: value, member: name }); - changed = true; - } + if (propagateDeclarator(context, declarator, bindings)) changed = true; } if (!changed) return; } } -function resolveVariable(context: Context, identifier: ESTree.Node): Variable | null { - if (identifier.type !== 'Identifier') return null; - let scope: Scope | null = context.sourceCode.getScope(identifier); - while (scope !== null) { - const variable = scope.set.get(identifier.name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; -} - -/** - * True when `identifier` really resolves to the tracked declaration, so a shadowing parameter, local or - * destructuring key with the same name is never reported. An unresolvable identifier is trusted (oxlint's - * scope analysis does not model every TS construct), which keeps the rule strict by default. - */ -function isTrackedReference( - context: Context, - identifier: ESTree.Node, - declaration: ESTree.Node, -): boolean { - const variable = resolveVariable(context, identifier); - if (variable === null) return true; - if (variable.defs.length === 0) return false; - return variable.defs.some((definition) => Object.is(definition.name, declaration)); -} - /** `Effect.runPromise` / `E["runSync"]` / ``Fx.Effect[`runFork`]`` → the run member name. */ function runEntryPoint( context: Context, @@ -399,19 +384,12 @@ function runEntryPoint( const member = staticName(node.property, node.computed); if (member === null || !RUN_MEMBER.test(member)) return null; const object = unwrapExpression(node.object); - if (object.type === 'Identifier') { - const declaration = bindings.effectNamespaces.get(object.name); - if (declaration === undefined) return null; - return isTrackedReference(context, object, declaration) ? member : null; - } - if (object.type !== 'MemberExpression') return null; - // `import * as Fx from "effect"` → `Fx.Effect.runSync(...)`. - if (staticName(object.property, object.computed) !== EFFECT_NAMESPACE) return null; - const root = unwrapExpression(object.object); - if (root.type !== 'Identifier') return null; - const declaration = bindings.packageNamespaces.get(root.name); - if (declaration === undefined) return null; - return isTrackedReference(context, root, declaration) ? member : null; + if (object.type === 'Identifier') + return trackedNamespace(context, object, bindings.effectNamespaces) ? member : null; + const root = packageEffectRoot(object); + return root !== null && trackedNamespace(context, root, bindings.packageNamespaces) + ? member + : null; } function isFunctionNode(node: ESTree.Node): node is FunctionNode { @@ -426,6 +404,16 @@ function isFunctionNode(node: ESTree.Node): node is FunctionNode { * The call this function is an argument of, looking through option objects/arrays * (`Effect.tryPromise({ try: async () => ... })`) but never through another function. */ +const OWNERSHIP_WRAPPERS = new Set([ + 'Property', + 'ObjectExpression', + 'ArrayExpression', + 'SpreadElement', + 'ParenthesizedExpression', + 'TSAsExpression', + 'TSSatisfiesExpression', + 'TSNonNullExpression', +]); function owningCall(fn: FunctionNode): ESTree.CallExpression | null { let child: ESTree.Node = fn; let current: ESTree.Node | null = fn.parent; @@ -433,16 +421,7 @@ function owningCall(fn: FunctionNode): ESTree.CallExpression | null { if (current.type === 'CallExpression') { return current.arguments.some((argument) => Object.is(argument, child)) ? current : null; } - if ( - current.type === 'Property' || - current.type === 'ObjectExpression' || - current.type === 'ArrayExpression' || - current.type === 'SpreadElement' || - current.type === 'ParenthesizedExpression' || - current.type === 'TSAsExpression' || - current.type === 'TSSatisfiesExpression' || - current.type === 'TSNonNullExpression' - ) { + if (OWNERSHIP_WRAPPERS.has(current.type)) { child = current; current = current.parent; continue; @@ -472,12 +451,12 @@ function isEffectOwnedFunction( const member = effectMember(callee, shared); if (member !== null) return !(member.namespace === EFFECT_NAMESPACE && RUN_MEMBER.test(member.member)); - // `Fx.Effect.gen(...)` through a whole-package namespace import. - const object = unwrapExpression(callee.object); - if (object.type !== 'MemberExpression') return false; - if (staticName(object.property, object.computed) !== EFFECT_NAMESPACE) return false; - const root = unwrapExpression(object.object); - if (root.type !== 'Identifier' || !bindings.packageNamespaces.has(root.name)) return false; + return isPackageCombinator(callee, bindings); +} +function isPackageCombinator(callee: ESTree.MemberExpression, bindings: RunBindings): boolean { + const root = packageEffectRoot(unwrapExpression(callee.object)); + if (root === null || root.type !== 'Identifier' || !bindings.packageNamespaces.has(root.name)) + return false; const name = staticName(callee.property, callee.computed); return name !== null && !RUN_MEMBER.test(name); } @@ -497,36 +476,26 @@ function isInsideEffectOwnedCode( } /** Parents where an identifier is a declaration key or module-record name, never a value reference. */ +const DECLARATION_KEY_PARENTS = new Set([ + 'PropertyDefinition', + 'TSAbstractPropertyDefinition', + 'MethodDefinition', + 'TSAbstractMethodDefinition', + 'AccessorProperty', + 'TSAbstractAccessorProperty', + 'TSPropertySignature', + 'TSMethodSignature', +]); +const NAME_PARENTS = new Set(['LabeledStatement', 'BreakStatement', 'ContinueStatement']); function isDeclarationPosition(node: ESTree.Node): boolean { - const parent: ESTree.Node | null = node.parent; - if (parent === null) return false; - switch (parent.type) { - case 'ImportSpecifier': - case 'ImportDefaultSpecifier': - case 'ImportNamespaceSpecifier': - case 'ExportSpecifier': - case 'LabeledStatement': - case 'BreakStatement': - case 'ContinueStatement': - return true; - case 'MemberExpression': - return Object.is(parent.property, node) && !parent.computed; - case 'Property': - return Object.is(parent.key, node) && !parent.computed; - case 'PropertyDefinition': - case 'TSAbstractPropertyDefinition': - case 'MethodDefinition': - case 'TSAbstractMethodDefinition': - case 'AccessorProperty': - case 'TSAbstractAccessorProperty': - case 'TSPropertySignature': - case 'TSMethodSignature': - return ( - Object.is((parent as unknown as { key?: unknown }).key, node) && parent.computed !== true - ); - default: - return false; - } + if (node.parent?.type === 'Property') + return Object.is(node.parent.key, node) && !node.parent.computed; + return isNonReferencePosition(node, { + detached: false, + keyParents: DECLARATION_KEY_PARENTS, + nonReferenceParents: NAME_PARENTS, + strictComputed: true, + }); } export const rule = defineRule({ diff --git a/app/tools/oxlint/effect-native/rules/no-console-in-scripts.ts b/app/tools/oxlint/effect-native/rules/no-console-in-scripts.ts index 6381ecc73..872b7a0eb 100644 --- a/app/tools/oxlint/effect-native/rules/no-console-in-scripts.ts +++ b/app/tools/oxlint/effect-native/rules/no-console-in-scripts.ts @@ -12,50 +12,19 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree } from '@oxlint/plugins'; -import { - globToRegExp, - isScriptFile, - isTestFile, - matchesAny, - normalisePath, -} from '../shared/paths.ts'; +import { skipWrappers, syntax } from '../shared/ast.ts'; +import { lookupVariable as lexicalVariable } from '../shared/bindings.ts'; +import { booleanOption, stringList } from '../shared/options.ts'; +import { globToRegExp, inScriptScope, scriptScope } from '../shared/paths.ts'; +import { provenance, valueReference } from '../shared/provenance.ts'; +import { isEntryPosition, nearestFunction } from '../shared/script-entry.ts'; type AnyNode = ESTree.Node; -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets the fixtures exercise the real production defaults instead of forcing - * the fixture config to pass loosened options (which `run-on-repo.mts` reuses verbatim). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; - -/** Modules whose default/namespace export *is* the ambient console object. */ const CONSOLE_MODULES = new Set(['console', 'node:console']); - -/** Modules whose default/namespace export *is* the process object. */ -const PROCESS_MODULES = new Set(['process', 'node:process']); - -/** Globals that can be used to reach `console` / `process` indirectly (`globalThis.console.log`). */ -const CONTAINER_GLOBALS = new Set(['globalThis', 'global', 'window', 'self']); - -/** The two ambient byte sinks reachable from `process`. */ -const STDIO_STREAMS = new Set(['stdout', 'stderr']); - const DEFAULT_METHODS: readonly string[] = ['error', 'warn', 'debug', 'trace']; - -/** Wrappers that do not change "is this expression the callee / object of its parent". */ -const TRANSPARENT_PARENTS = new Set([ - 'ParenthesizedExpression', - 'ChainExpression', - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSInstantiationExpression', - 'TSTypeAssertion', -]); - const FUNCTION_LIKE = new Set([ 'ArrowFunctionExpression', 'FunctionDeclaration', @@ -63,22 +32,6 @@ const FUNCTION_LIKE = new Set([ 'StaticBlock', ]); -/** Parents in which an `Identifier` is a name, not a value reference. */ -const NON_REFERENCE_PARENTS = new Set([ - 'ImportSpecifier', - 'ImportDefaultSpecifier', - 'ImportNamespaceSpecifier', - 'ExportSpecifier', - 'TSTypeReference', - 'TSQualifiedName', - 'TSTypeQuery', - 'TSPropertySignature', - 'TSMethodSignature', - 'LabeledStatement', - 'BreakStatement', - 'ContinueStatement', -]); - interface RuleOptions { readonly allowPaths: readonly string[]; readonly methods: readonly string[]; @@ -95,167 +48,35 @@ const DEFAULTS: RuleOptions = { reportReferences: true, }; -function stringList(value: unknown, fallback: readonly string[]): readonly string[] { - return Array.isArray(value) && value.every((entry) => typeof entry === 'string') - ? (value as readonly string[]) - : fallback; -} - function readOptions(raw: unknown): RuleOptions { const given = (raw ?? {}) as Partial>; const methods = stringList(given.methods, DEFAULTS.methods); return { allowPaths: stringList(given.allowPaths, DEFAULTS.allowPaths), methods: methods.length > 0 ? methods : DEFAULTS.methods, - includeStdio: - typeof given.includeStdio === 'boolean' ? given.includeStdio : DEFAULTS.includeStdio, - allowAtEntry: - typeof given.allowAtEntry === 'boolean' ? given.allowAtEntry : DEFAULTS.allowAtEntry, - reportReferences: - typeof given.reportReferences === 'boolean' - ? given.reportReferences - : DEFAULTS.reportReferences, + includeStdio: booleanOption(given.includeStdio, DEFAULTS.includeStdio), + allowAtEntry: booleanOption(given.allowAtEntry, DEFAULTS.allowAtEntry), + reportReferences: booleanOption(given.reportReferences, DEFAULTS.reportReferences), }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real script paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function parentOf(node: AnyNode): AnyNode | null { - return (node as { parent?: AnyNode | null }).parent ?? null; -} - -/** Climb through parentheses/type/optional-chain wrappers; returns the outermost node and its parent. */ -function skipWrappers(node: AnyNode): { readonly node: AnyNode; readonly parent: AnyNode | null } { - let current = node; - let parent = parentOf(current); - while (parent !== null && TRANSPARENT_PARENTS.has(parent.type)) { - current = parent; - parent = parentOf(current); - } - return { node: current, parent }; +/** Assignment targets and non-emitting unary observations do not use the sink. */ +function observesSink(parent: AnyNode, outer: AnyNode): boolean { + if (parent.type === 'AssignmentExpression') return parent.left === outer; + return parent.type === 'UnaryExpression' && ['void', 'typeof'].includes(parent.operator); } -/** Strip `(...)`, `as`, `satisfies`, `!`, `` and `a?.b` wrappers from an expression. */ -function unwrap(node: AnyNode): AnyNode { - let current = node; - while (TRANSPARENT_PARENTS.has(current.type)) { - const inner = (current as { expression?: AnyNode }).expression; - if (inner === undefined || inner === null) return current; - current = inner; - } - return current; -} - -/** `console.log` / `console["log"]` → `"log"`; a dynamic key → `null`. */ -function staticPropertyName(node: ESTree.MemberExpression): string | null { - const property = node.property as AnyNode; - if (!node.computed) - return property.type === 'Identifier' ? (property as ESTree.IdentifierName).name : null; - if (property.type !== 'Literal') return null; - const value = (property as { value?: unknown }).value; - return typeof value === 'string' ? value : null; -} - -function resolveVariable(context: Context, name: string, from: AnyNode): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(from); - while (scope !== null) { - const variable = scope.set.get(name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; -} - -/** `true` when `node` is the global `name` — not a local, parameter, class or imported binding. */ -function isUnshadowedGlobal(context: Context, node: AnyNode, name: string): boolean { - if (node.type !== 'Identifier') return false; - if ((node as ESTree.IdentifierReference).name !== name) return false; - const variable = resolveVariable(context, name, node); - return variable === null || variable.defs.length === 0; -} - -function nearestFunction(node: AnyNode): AnyNode | null { - let current = parentOf(node); - while (current !== null) { - if (FUNCTION_LIKE.has(current.type)) return current; - current = parentOf(current); - } - return null; -} - -/** `true` when the node is evaluated during module evaluation, not inside any function body. */ -function isTopLevel(node: AnyNode): boolean { - return nearestFunction(node) === null; -} - -/** A declaration/statement directly in `Program`, optionally behind `export` / `export default`. */ -function isProgramLevelStatement(node: AnyNode): boolean { - const parent = parentOf(node); - if (parent === null) return false; - if (parent.type === 'Program') return true; - if (parent.type !== 'ExportNamedDeclaration' && parent.type !== 'ExportDefaultDeclaration') - return false; - return parentOf(parent)?.type === 'Program'; -} - -/** Name of a Program-level `function main() {}` / `const main = () => {}`, else `null`. */ -function programLevelFunctionName(fn: AnyNode): string | null { - if (fn.type === 'FunctionDeclaration') { - if (!isProgramLevelStatement(fn)) return null; - const id = (fn as ESTree.Function).id; - return id === null || id === undefined ? null : id.name; - } - if (fn.type !== 'FunctionExpression' && fn.type !== 'ArrowFunctionExpression') return null; - const declarator = parentOf(fn); - if (declarator === null || declarator.type !== 'VariableDeclarator') return null; - if ((declarator as ESTree.VariableDeclarator).init !== fn) return null; - const id = (declarator as ESTree.VariableDeclarator).id; - if (id.type !== 'Identifier') return null; - const declaration = parentOf(declarator); - if (declaration === null || declaration.type !== 'VariableDeclaration') return null; - return isProgramLevelStatement(declaration) ? id.name : null; -} - -/** `void (async () => { ... })()` / `(function () { ... })()` evaluated during module evaluation. */ -function isTopLevelImmediatelyInvoked(fn: AnyNode): boolean { - const { node, parent } = skipWrappers(fn); - if (parent === null || parent.type !== 'CallExpression') return false; - if ((parent as ESTree.CallExpression).callee !== node) return false; - return isTopLevel(parent); -} - -/** Every use of `main` is a call made during module evaluation (top level or an entry guard). */ -function isOnlyCalledFromTopLevel(context: Context, fn: AnyNode, name: string): boolean { - const variable = resolveVariable(context, name, fn); - if (variable === null) return false; - const bindingOffsets = new Set(variable.identifiers.map((identifier) => identifier.start)); - const uses = variable.references.filter( - (reference) => reference.init !== true && !bindingOffsets.has(reference.identifier.start), +function restoresSink( + context: Context, + parent: AnyNode, + outer: AnyNode, + identity: string, +): boolean { + return ( + parent.type === 'AssignmentExpression' && + parent.right === outer && + provenance(context, parent.left) === identity ); - if (uses.length === 0) return false; - return uses.every((reference) => { - const { node, parent } = skipWrappers(reference.identifier as unknown as AnyNode); - if (parent === null || parent.type !== 'CallExpression') return false; - if ((parent as ESTree.CallExpression).callee !== node) return false; - return isTopLevel(parent); - }); -} - -/** - * The executable edge of a script: module-evaluation code, a top-level IIFE, or a Program-level - * `main` that is only ever invoked from module-evaluation code. - */ -function isEntryPosition(context: Context, site: AnyNode): boolean { - const fn = nearestFunction(site); - if (fn === null) return true; - if (nearestFunction(fn) !== null) return false; - if (isTopLevelImmediatelyInvoked(fn)) return true; - const name = programLevelFunctionName(fn); - if (name === null) return false; - return isOnlyCalledFromTopLevel(context, fn, name); } /** Effect-native rule: scripts log through the Effect runtime, never through the ambient console. */ @@ -334,44 +155,25 @@ export const rule = defineRule({ if (options.allowAtEntry && isEntryPosition(context, node)) return; context.report({ node, messageId: id, data }); }; + const inspectConsole = (node: AnyNode, outer: AnyNode, parent: AnyNode, identity: string) => { + const method = identity.slice(8); + if (!methods.has(method)) return; + if (isRestoredCapture(context, node)) return; + if (restoresSink(context, parent, outer, identity)) return; + const called = parent.type === 'CallExpression' && parent.callee === outer; + if (called || options.reportReferences) + report(node, called ? 'consoleCall' : 'consoleReference', { method }); + }; const inspect = (node: AnyNode) => { const identity = provenance(context, node); const { node: outer, parent } = skipWrappers(node); - if (!parent) return; - // Assigning a sink is not emitting output. Third-party capture is a forced adapter. - if ( - parent.type === 'AssignmentExpression' && - (parent as ESTree.AssignmentExpression).left === outer - ) - return; - // `void sink`/`typeof sink` observes no output, and names/type positions are not references. - if ( - parent.type === 'UnaryExpression' && - ['void', 'typeof'].includes((parent as ESTree.UnaryExpression).operator) - ) - return; - const called = - parent.type === 'CallExpression' && (parent as ESTree.CallExpression).callee === outer; + if (!parent || observesSink(parent, outer)) return; if (identity === 'process.stderr.write' && options.includeStdio) { report(node, 'stdioWrite', { stream: 'stderr' }); return; } - if (identity?.startsWith('console.')) { - const method = identity.slice(8); - if (!methods.has(method)) return; - if (isRestoredCapture(context, node)) return; - if ( - parent.type === 'AssignmentExpression' && - parent.right === outer && - provenance(context, parent.left) === identity - ) - return; - if (called || options.reportReferences) - report(node, called ? 'consoleCall' : 'consoleReference', { method }); - return; - } - // Bare sink handoffs and dynamic method keys do not prove diagnostic output. - // In particular console.log.bind(console) is still successful operational output. + if (identity?.startsWith('console.')) inspectConsole(node, outer, parent, identity); + // Bare sinks and dynamic methods do not prove diagnostic output. }; return { MemberExpression(node) { @@ -394,215 +196,6 @@ export const rule = defineRule({ }); /** Bounded, lexical provenance only; no type checker or interprocedural/data-flow inference. */ -type Syntax = ESTree.Node & Record; -function syntax(node: unknown): Syntax | null { - let n = node as Syntax | null; - while ( - n && - [ - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSTypeAssertion', - 'TSInstantiationExpression', - 'ParenthesizedExpression', - 'ChainExpression', - 'AwaitExpression', - ].includes(n.type) - ) - n = n.expression ?? n.argument; - return n; -} -function lexicalVariable(context: Context, node: Syntax): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(node); - while (scope) { - const v = scope.set.get(node.name); - if (v) return v; - scope = scope.upper; - } - return null; -} -function literalText(node: unknown): string | null { - const n = syntax(node); - if (n?.type === 'Literal' && typeof n.value === 'string') return n.value; - if (n?.type === 'TemplateLiteral' && n.expressions.length === 0) - return n.quasis[0]?.value.cooked ?? null; - return null; -} -function propertyText(node: unknown): string | null { - const n = node as Syntax; - const key = syntax(n.property ?? n.key); - return !n.computed && key?.type === 'Identifier' ? key.name : literalText(key); -} -function moduleIdentity(source: string): string { - if (/^(?:node:)?(?:process|console|util|module)$/.test(source)) - return source.replace(/^node:/, ''); - if (source === 'effect/Effect') return 'Effect'; - if (source === 'effect/ManagedRuntime') return 'ManagedRuntime'; - return source; -} -function bindingPath(pattern: Syntax, name: string): string[] | null { - if (pattern.type === 'Identifier') return pattern.name === name ? [] : null; - if (pattern.type === 'AssignmentPattern') return bindingPath(pattern.left, name); - if (pattern.type !== 'ObjectPattern') return null; - for (const p of pattern.properties) { - if (p.type !== 'Property') continue; - const key = propertyText(p), - tail = bindingPath(p.value, name); - if (key !== null && tail !== null) return [key, ...tail]; - } - return null; -} -function provenance(context: Context, node: unknown, seen = new Set()): string | null { - const n = syntax(node); - if (!n) return null; - if (n.type === 'Identifier') { - const v = lexicalVariable(context, n); - if (!v || v.defs.length === 0) - return [ - 'process', - 'console', - 'Bun', - 'globalThis', - 'global', - 'window', - 'self', - 'require', - 'Array', - 'Set', - ].includes(n.name) - ? n.name - : null; - if (seen.has(v) || v.defs.length !== 1) return null; - const next = new Set(seen); - next.add(v); - const def = v.defs[0] as any; - if (def.type === 'ImportBinding') { - const spec = def.node as Syntax; - const decl = (def.parent ?? spec.parent) as Syntax; - if (decl.importKind === 'type' || spec.importKind === 'type') return null; - const source = literalText(decl.source); - if (!source) return null; - const base = moduleIdentity(source); - if (spec.type === 'ImportNamespaceSpecifier' || spec.type === 'ImportDefaultSpecifier') - return base; - const name = spec.imported?.name ?? spec.imported?.value; - if (name === 'default') return base; - if (base === 'effect') return name; - return `${base}.${name}`; - } - if (def.type !== 'Variable' || def.node.type !== 'VariableDeclarator') return null; - // A declaration is not a reaching-definition analysis: reassigned aliases are unknown. - if (v.references.some((r: any) => r.init !== true && r.isWrite())) return null; - const d = def.node as Syntax; - const base = provenance(context, d.init, next), - path = bindingPath(d.id, n.name); - return base !== null && path !== null ? [base, ...path].join('.') : null; - } - if (n.type === 'MemberExpression') { - const base = provenance(context, n.object, seen), - key = propertyText(n); - if (base === null || key === null) return null; - if ( - ['globalThis', 'global', 'window', 'self'].includes(base) && - ['process', 'console', 'Bun'].includes(key) - ) - return key; - if (['process', 'console', 'util', 'module'].includes(base) && key === 'default') return base; - if (base === 'effect') return key; - return `${base}.${key}`; - } - if (n.type === 'ImportExpression') { - const text = literalText(n.source); - return text === null ? null : moduleIdentity(text); - } - if (n.type === 'CallExpression') { - const callee = provenance(context, n.callee, seen); - if (callee === 'require') { - const text = literalText(n.arguments[0]); - return text === null ? null : moduleIdentity(text); - } - if (callee === 'module.createRequire') return 'require'; - if (callee === 'ManagedRuntime.make') return 'Runtime'; - } - return null; -} -/** Only value references, never property names, bindings or TS-only identifiers. */ -function valueReference(context: Context, node: unknown): boolean { - const n = node as Syntax, - p = n.parent as Syntax | undefined; - if (!p) return false; - if (p.type.startsWith('Import') || p.type === 'ExportSpecifier') return false; - if (p.type === 'MemberExpression' && p.property === n && !p.computed) return false; - if ( - [ - 'Property', - 'PropertyDefinition', - 'MethodDefinition', - 'TSPropertySignature', - 'TSMethodSignature', - ].includes(p.type) && - p.key === n && - !p.computed && - !(p.shorthand && p.value === n) - ) - return false; - if (['LabeledStatement', 'BreakStatement', 'ContinueStatement'].includes(p.type)) return false; - let child: Syntax = n; - let parent: Syntax | null = p; - while (parent) { - if ( - parent.type.startsWith('TS') && - !( - [ - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSTypeAssertion', - 'TSInstantiationExpression', - ].includes(parent.type) && parent.expression === child - ) - ) - return false; - if ( - parent.type.endsWith('Statement') || - parent.type.endsWith('Declaration') || - parent.type.includes('Function') - ) - break; - child = parent; - parent = parent.parent as Syntax | null; - } - const v = lexicalVariable(context, n); - return ( - !v || - v.references.some( - (r: any) => - r.identifier === n && - r.isRead() && - (typeof r.isValueReference !== 'function' || r.isValueReference()), - ) - ); -} -/** Strip fixture scaffolding first; do not renormalise a relative script path around inner markers. */ -function scriptScope(filename: string): string { - const unified = filename.replaceAll('\\', '/'); - const fixture = unified.match( - /(?:^|\/)tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\/(.*)$/u, - ); - if (fixture) return fixture[1]; - if (!unified.startsWith('/') && !/^[A-Za-z]:\//u.test(unified)) - return unified.replace(/^\.\//, ''); - const match = unified.match(/(?:^|\/)((?:apps|packages|verticals|scripts|tools)\/.*)$/u); - return match?.[1] ?? unified; -} -function inScriptScope(path: string): boolean { - return ( - /(?:^|\/)scripts\//u.test(path) && - !/(?:^|\/)(?:tests?|__tests__)\/|\.(?:test|spec|test-d|spec-d)\.[cm]?[jt]sx?$/u.test(path) - ); -} - /** Narrow save/restore-in-finally recognition. No claim about the vendor's implementation. */ function isRestoredCapture(context: Context, node: AnyNode): boolean { const n = syntax(node), diff --git a/app/tools/oxlint/effect-native/rules/no-dependency-parameters.ts b/app/tools/oxlint/effect-native/rules/no-dependency-parameters.ts index f93c4076a..a3b98424c 100644 --- a/app/tools/oxlint/effect-native/rules/no-dependency-parameters.ts +++ b/app/tools/oxlint/effect-native/rules/no-dependency-parameters.ts @@ -15,19 +15,17 @@ import { defineRule } from '@oxlint/plugins'; import type { ESTree } from '@oxlint/plugins'; -import { bindingsFor } from '../shared/effect-imports.ts'; -import type { EffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isScriptFile, isTestFile, normalisePath } from '../shared/paths.ts'; +import { + keyName as staticKeyName, + unwrapBinding, + unwrapType as unwrapSharedType, +} from '../shared/ast.ts'; +import { resolveVariable } from '../shared/bindings.ts'; +import { booleanOption as boolean, compile, stringList } from '../shared/options.ts'; +import { isSourceRuleInScope } from '../shared/source-rule-scope.ts'; type AnyNode = ESTree.Node; -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the production `includePaths` defaults instead of - * forcing the fixture config to loosen them (`run-on-repo.mts` reuses that config verbatim). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; - const DEFAULT_DEPENDENCY_TYPE_PATTERN = '(Service|Repository|Gateway|Resolver|Dependencies|ServiceFactory)$'; const DEFAULT_ALLOW_TYPE_NAMES: readonly string[] = []; @@ -35,11 +33,9 @@ const DEFAULT_SERVICE_INDEX_KEYS: readonly string[] = ['Service']; const DEFAULT_INCLUDE_PATHS: readonly string[] = ['apps/**', 'verticals/**', 'packages/**']; const DEFAULT_IGNORE: readonly string[] = []; -/** Wrappers between a written parameter and the binding it introduces. */ -const PARAMETER_WRAPPERS = new Set(['AssignmentPattern', 'RestElement', 'TSParameterProperty']); - /** Type wrappers that never change what a type annotation ultimately denotes. */ const TYPE_WRAPPERS = new Set([ + 'TSTypeAnnotation', 'TSParenthesizedType', 'TSTypeOperator', 'TSArrayType', @@ -75,26 +71,6 @@ interface RuleOptions { readonly serviceIndexKeys: ReadonlySet; } -function stringList(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - return value.every((entry) => typeof entry === 'string') - ? (value as readonly string[]) - : fallback; -} - -function compile(value: unknown, fallback: string): RegExp { - const source = typeof value === 'string' && value.length > 0 ? value : fallback; - try { - return new RegExp(source, 'u'); - } catch { - return new RegExp(fallback, 'u'); - } -} - -function boolean(value: unknown, fallback: boolean): boolean { - return typeof value === 'boolean' ? value : fallback; -} - function readOptions(raw: unknown): RuleOptions { const given = (raw ?? {}) as Record; const includePaths = stringList(given.includePaths, DEFAULT_INCLUDE_PATHS); @@ -112,56 +88,17 @@ function readOptions(raw: unknown): RuleOptions { }; } -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -/** `{ correlationId: … }` / `{ "id": … }` → the written key; computed keys → `null`. */ +/** Computed option keys are deliberately excluded, including static strings. */ function keyName(key: AnyNode, computed: boolean): string | null { - if (computed) return null; - if (key.type === 'Identifier') return (key as { name: string }).name; - if (key.type === 'Literal') { - const value = (key as { value?: unknown }).value; - return typeof value === 'string' ? value : null; - } - return null; + return computed ? null : staticKeyName(key, false, { templates: false }); } -/** `AssignmentPattern` / `RestElement` / `TSParameterProperty` → the binding they wrap. */ -function unwrapBinding(node: AnyNode): AnyNode { - let current = node; - for (let guard = 0; guard < 4; guard += 1) { - if (!PARAMETER_WRAPPERS.has(current.type)) return current; - const inner = - (current as { left?: AnyNode }).left ?? - (current as { argument?: AnyNode }).argument ?? - (current as { parameter?: AnyNode }).parameter; - if (inner === undefined) return current; - current = inner; - } - return current; -} - -/** `readonly`, parentheses, `T[]` and rest/optional wrappers never change what a type denotes. */ function unwrapType(node: AnyNode): AnyNode { - let current = node; - for (let guard = 0; guard < 8; guard += 1) { - if (current.type === 'TSTypeAnnotation') { - current = (current as unknown as { typeAnnotation: AnyNode }).typeAnnotation; - continue; - } - if (!TYPE_WRAPPERS.has(current.type)) return current; - const inner = - (current as { typeAnnotation?: AnyNode }).typeAnnotation ?? - (current as { elementType?: AnyNode }).elementType; - if (inner === undefined) return current; - current = inner; - } - return current; + return unwrapSharedType(node, { + wrappers: TYPE_WRAPPERS, + maxDepth: 8, + elementTypeFallback: true, + }); } /** Last identifier of a (possibly qualified) type name: `Foo.BarService` → `BarService`. */ @@ -301,24 +238,16 @@ export const rule = defineRule({ }, create(context) { const options = readOptions(context.options[0]); - const path = scopePath(context.filename); - if (!matchesGlobs(path, options.includePaths)) return {}; - if (matchesGlobs(path, options.ignore)) return {}; - if (!options.includeScripts && isScriptFile(path)) return {}; - if (!options.includeTests && isTestFile(path)) return {}; - - const bindings: EffectBindings = bindingsFor(context); - - const variableFor = (node: any, name: string): any => { - for ( - let scope: import('@oxlint/plugins').Scope | null = context.sourceCode.getScope(node); - scope; - scope = scope.upper - ) { - const variable = scope.set.get(name); - if (variable) return variable; - } - return null; + if (!isSourceRuleInScope(context.filename, options)) return {}; + + const variableFor = (node: AnyNode, name: string): any => resolveVariable(context, name, node); + const importSourcePath = (def: any): string | null => { + const source = def.parent?.source?.value; + const isRoot = ['effect', '@modern-js/plugin-bff/effect-edge'].includes(source); + if (!isRoot && !source?.startsWith('effect/')) return null; + const imported = def.node.imported?.name ?? def.node.imported?.value; + if (isRoot) return imported ?? 'root'; + return `${source.split('/').at(-1)}${imported ? `.${imported}` : ''}`; }; const importedPath = (node: any): string | null => { if (node.type === 'TSQualifiedName' || node.type === 'MemberExpression') { @@ -331,17 +260,7 @@ export const rule = defineRule({ const variable = variableFor(node, node.name); const def = variable?.defs.find((d: any) => d.type === 'ImportBinding'); if (!def) return null; - const source = def.parent?.source?.value; - if ( - source !== 'effect' && - source !== '@modern-js/plugin-bff/effect-edge' && - !source?.startsWith('effect/') - ) - return null; - const imported = def.node.imported?.name ?? def.node.imported?.value; - return source === 'effect' || source === '@modern-js/plugin-bff/effect-edge' - ? (imported ?? 'root') - : `${source.split('/').at(-1)}${imported ? `.${imported}` : ''}`; + return importSourcePath(def); }; const localType = (node: any): any => { if (node.type !== 'Identifier') return null; @@ -352,25 +271,6 @@ export const rule = defineRule({ ); }; - /** Same-module `type X = …` / `interface X { … }`, collected up front so order never matters. */ - const localTypes = new Map(); - for (const statement of context.sourceCode.ast.body as readonly AnyNode[]) { - const declaration = - statement.type === 'ExportNamedDeclaration' - ? ((statement as unknown as { declaration?: AnyNode | null }).declaration ?? null) - : statement; - if (declaration === null) continue; - if (declaration.type === 'TSTypeAliasDeclaration') { - const alias = declaration as unknown as { id: AnyNode; typeAnnotation: AnyNode }; - if (alias.id.type === 'Identifier') - localTypes.set((alias.id as { name: string }).name, alias.typeAnnotation); - } else if (declaration.type === 'TSInterfaceDeclaration') { - const declared = declaration as unknown as { id: AnyNode }; - if (declared.id.type === 'Identifier') - localTypes.set((declared.id as { name: string }).name, declaration); - } - } - /** A `TSFunctionType` / `TSMethodSignature` whose return type is `Effect.Effect<…>`. */ const returnsEffect = (annotation: AnyNode | null | undefined): boolean => { if (annotation === null || annotation === undefined) return false; @@ -398,140 +298,141 @@ export const rule = defineRule({ }); }; - /** - * Classify one type annotation. `depth` is 0 for a parameter's own annotation and 1 while - * looking inside an option bag's members, which is as deep as the walk ever goes. - */ - const classify = (annotation: AnyNode | null | undefined, depth: number): Verdict | null => { - if (annotation === null || annotation === undefined || depth > 12) return null; - const node = unwrapType(annotation); - - if (node.type === 'TSUnionType' || node.type === 'TSIntersectionType') { - for (const member of (node as unknown as { types: readonly AnyNode[] }).types) { - const verdict = classify(member, depth); - if (verdict !== null) return verdict; - } - return null; + function classifyIndexed(node: AnyNode): Verdict | null { + const indexed = node as unknown as { objectType: AnyNode; indexType: AnyNode }; + const owner = typeQueryName(unwrapType(indexed.objectType)); + const index = unwrapType(indexed.indexType); + const literal = + index.type === 'TSLiteralType' ? (index as unknown as { literal: AnyNode }).literal : null; + const key = + literal !== null && literal.type === 'Literal' + ? (literal as { value?: unknown }).value + : undefined; + if (owner !== null && typeof key === 'string' && options.serviceIndexKeys.has(key)) { + return { + member: null, + messageId: 'dependencyParameter', + tagName: owner, + type: `(typeof ${owner})['${key}']`, + }; } + return null; + } - // (a) `(typeof CoreDatabaseService)['Service']` — a resolved Context.Service instance. - if (node.type === 'TSIndexedAccessType') { - const indexed = node as unknown as { objectType: AnyNode; indexType: AnyNode }; - const owner = typeQueryName(unwrapType(indexed.objectType)); - const index = unwrapType(indexed.indexType); - const literal = - index.type === 'TSLiteralType' - ? (index as unknown as { literal: AnyNode }).literal - : null; - const key = - literal !== null && literal.type === 'Literal' - ? (literal as { value?: unknown }).value - : undefined; - if (owner !== null && typeof key === 'string' && options.serviceIndexKeys.has(key)) { - return { - member: null, - messageId: 'dependencyParameter', - tagName: owner, - type: `(typeof ${owner})['${key}']`, - }; - } - return null; + function classifyLayer(node: AnyNode, qualifier: string | null): Verdict { + const args = (node as unknown as { typeArguments: AnyNode | null }).typeArguments; + const first = + args === null ? undefined : (args as unknown as { params: readonly AnyNode[] }).params[0]; + const provided = + first === undefined + ? null + : lastTypeName( + unwrapType(first).type === 'TSTypeReference' + ? (unwrapType(first) as unknown as { typeName: AnyNode }).typeName + : unwrapType(first), + ); + return { + member: null, + messageId: 'layerParameter', + tagName: provided ?? 'TheService', + type: qualifier === null ? 'Layer' : `${qualifier}.Layer`, + }; + } + + function isSynchronousResolver(name: string, local: any): boolean { + if (!name.endsWith('Resolver') || local?.type !== 'TSFunctionType') return false; + const result = local.returnType?.typeAnnotation; + if (!result || returnsEffect(local.returnType)) return false; + return !( + result.type === 'TSTypeReference' && + ['Promise', 'PromiseLike'].includes(lastTypeName(result.typeName) ?? '') + ); + } + + function expandDeclaration(declaration: any, depth: number): Verdict | null { + if (!options.expandLocalTypes || !declaration) return null; + if (declaration.typeAnnotation) return classify(declaration.typeAnnotation, depth + 1); + return inspectBag(declaration, depth + 1); + } + + function classifyApplicationReference( + node: AnyNode, + typeName: AnyNode, + name: string, + qualifier: string | null, + depth: number, + ): Verdict | null { + // Transparent built-in utility wrappers and same-scope aliases preserve the dependency. + if ( + qualifier === null && + ['Readonly', 'ReadonlyArray', 'Array', 'NonNullable'].includes(name) && + !variableFor(typeName, name)?.defs.length + ) { + const argument = (node as any).typeArguments?.params?.[0]; + return classify(argument, depth + 1); + } + const declaration = qualifier === null ? localType(typeName) : null; + const local = declaration?.typeAnnotation; + if (isSynchronousResolver(name, local)) return null; + + // (c) `ActionRepositoryService`, `ContactsGateway`, `OperationalScopeResolverService`, … + if (!options.allowTypeNames.has(name) && options.dependencyTypePattern.test(name)) { + return { + member: null, + messageId: 'dependencyParameter', + tagName: tagNameFor(name), + type: name, + }; } - if (node.type === 'TSTypeReference') { - const typeName = (node as unknown as { typeName: AnyNode; typeArguments: AnyNode | null }) - .typeName; - const name = lastTypeName(typeName); - if (name === null) return null; - const qualifier = qualifierName(typeName); - - // (b) `Layer.Layer` / `L.Layer<…>` / `import * as Layer from "effect/Layer"`, - // plus the verbatim `Layer.Layer` spelling that reaches this repository through the - // `@modern-js/plugin-bff/effect-edge` re-export barrel. - if (/^(?:root\.)?Layer(?:\.Layer)?$/u.test(importedPath(typeName) ?? '')) { - const args = (node as unknown as { typeArguments: AnyNode | null }).typeArguments; - const first = - args === null - ? undefined - : (args as unknown as { params: readonly AnyNode[] }).params[0]; - const provided = - first === undefined - ? null - : lastTypeName( - unwrapType(first).type === 'TSTypeReference' - ? (unwrapType(first) as unknown as { typeName: AnyNode }).typeName - : unwrapType(first), - ); - return { - member: null, - messageId: 'layerParameter', - tagName: provided ?? 'TheService', - type: qualifier === null ? 'Layer' : `${qualifier}.Layer`, - }; - } + // (e) `options: ActionRuntimeOptions` — the same graph edge, hidden in an option bag. + return expandDeclaration(declaration, depth); + } - // Effect's own namespaced types (`Effect.Service`, `Context.Tag`, `Schema.Codec`, …) - // are library types, never injected application dependencies. - if (importedPath(typeName) !== null) return null; - - // Transparent built-in utility wrappers and same-scope aliases preserve the dependency. - if ( - qualifier === null && - ['Readonly', 'ReadonlyArray', 'Array', 'NonNullable'].includes(name) && - !variableFor(typeName, name)?.defs.length - ) { - const argument = (node as any).typeArguments?.params?.[0]; - return classify(argument, depth + 1); - } - const declaration = qualifier === null ? localType(typeName) : null; - const local = declaration?.typeAnnotation; - // B4 targets dependency injection, not pure per-definition Resolver callbacks. - // Only exempt an explicitly synchronous local function alias; imported types remain unknown. - if (name.endsWith('Resolver') && local?.type === 'TSFunctionType') { - const result = local.returnType?.typeAnnotation; - if ( - result && - !returnsEffect(local.returnType) && - !( - result.type === 'TSTypeReference' && - ['Promise', 'PromiseLike'].includes(lastTypeName(result.typeName) ?? '') - ) - ) - return null; - } + function classifyReference(node: AnyNode, depth: number): Verdict | null { + const typeName = (node as any).typeName; + const name = lastTypeName(typeName); + if (name === null) return null; + const qualifier = qualifierName(typeName); + const origin = importedPath(typeName); + if (/^(?:root\.)?Layer(?:\.Layer)?$/u.test(origin ?? '')) + return classifyLayer(node, qualifier); + if (origin !== null) return null; + return classifyApplicationReference(node, typeName, name, qualifier, depth); + } - // (c) `ActionRepositoryService`, `ContactsGateway`, `OperationalScopeResolverService`, … - if (!options.allowTypeNames.has(name) && options.dependencyTypePattern.test(name)) { - return { - member: null, - messageId: 'dependencyParameter', - tagName: tagNameFor(name), - type: name, - }; - } + function classifyRecord(node: AnyNode, depth: number): Verdict | null { + if (options.flagInlineServiceRecords && isServiceRecord(node)) { + return { + member: null, + messageId: 'inlineServiceRecord', + tagName: 'TheService', + type: '{ … => Effect.Effect<…> }', + }; + } + return inspectBag(node, depth + 1); + } - // (e) `options: ActionRuntimeOptions` — the same graph edge, hidden in an option bag. - if (options.expandLocalTypes && declaration) { - if (local) return classify(local, depth + 1); - return inspectBag(declaration, depth + 1); - } - return null; + function classifyMembers(members: readonly AnyNode[], depth: number): Verdict | null { + for (const member of members) { + const verdict = classify(member, depth); + if (verdict !== null) return verdict; } + return null; + } - if (node.type === 'TSTypeLiteral') { - // (d) an inline record of Effect-returning operations is a hand-passed service value. - if (options.flagInlineServiceRecords && isServiceRecord(node)) { - return { - member: null, - messageId: 'inlineServiceRecord', - tagName: 'TheService', - type: '{ … => Effect.Effect<…> }', - }; - } - return inspectBag(node, depth + 1); + /** Expand aliases and nested option bags with a bounded recursion depth. */ + function classify(annotation: AnyNode | null | undefined, depth: number): Verdict | null { + if (annotation === null || annotation === undefined || depth > 12) return null; + const node = unwrapType(annotation); + if (node.type === 'TSUnionType' || node.type === 'TSIntersectionType') { + return classifyMembers((node as any).types, depth); } + if (node.type === 'TSIndexedAccessType') return classifyIndexed(node); + if (node.type === 'TSTypeReference') return classifyReference(node, depth); + if (node.type === 'TSTypeLiteral') return classifyRecord(node, depth); return null; - }; + } /** First dependency-typed member of an object type: B4's option bag. */ function inspectBag(container: AnyNode, depth: number): Verdict | null { @@ -551,6 +452,19 @@ export const rule = defineRule({ return null; } + function objectParameterName(binding: AnyNode): string { + const keys: string[] = []; + for (const property of (binding as unknown as { properties: readonly AnyNode[] }) + .properties) { + if (property.type !== 'Property') continue; + const entry = property as unknown as { key: AnyNode; computed: boolean }; + const name = keyName(entry.key, entry.computed); + if (name !== null) keys.push(name); + if (keys.length === 3) break; + } + return keys.length === 0 ? '{ … }' : `{ ${keys.join(', ')} }`; + } + /** How the offending parameter is written, for the message. */ const parameterName = (param: AnyNode, binding: AnyNode): string => { if (binding.type === 'Identifier') { @@ -558,48 +472,51 @@ export const rule = defineRule({ return param.type === 'RestElement' ? `...${name}` : name; } if (binding.type === 'ObjectPattern') { - const keys: string[] = []; - for (const property of (binding as unknown as { properties: readonly AnyNode[] }) - .properties) { - if (property.type !== 'Property') continue; - const entry = property as unknown as { key: AnyNode; computed: boolean }; - const name = keyName(entry.key, entry.computed); - if (name !== null) keys.push(name); - if (keys.length === 3) break; - } - return keys.length === 0 ? '{ … }' : `{ ${keys.join(', ')} }`; + return objectParameterName(binding); } return ''; }; + function isRuntimeInput(use: any): boolean { + const call = use.parent; + return ( + call?.type === 'CallExpression' && + call.arguments[0] === use && + /^(?:root\.)?ManagedRuntime\.make$/u.test(importedPath(call.callee) ?? '') + ); + } + + function isLayerVerdict(verdict: Verdict): boolean { + return ( + verdict.messageId === 'layerParameter' || + (verdict.messageId === 'dependencyOptionBag' && verdict.type.endsWith('Layer')) + ); + } + + function rootInputUsage(identifier: any, verdict: Verdict): 'valid' | 'invalid' | 'skip' { + let use = identifier; + if (verdict.member) { + const parent = use.parent; + if (parent?.type !== 'MemberExpression' || parent.object !== use) return 'invalid'; + const key = parent.property.name ?? parent.property.value; + if (key !== verdict.member) return 'skip'; + use = parent; + } + return isRuntimeInput(use) ? 'valid' : 'invalid'; + } + // A1 explicitly composes a root Layer into ManagedRuntime.make. Exempt only the // precise parameter/member whose every value use is that construction, not a whole root file. const isRootLayerInput = (binding: any, verdict: Verdict): boolean => { - if ( - verdict.messageId !== 'layerParameter' && - !(verdict.messageId === 'dependencyOptionBag' && verdict.type.endsWith('Layer')) - ) - return false; + if (!isLayerVerdict(verdict)) return false; if (binding.type !== 'Identifier') return false; const refs = variableFor(binding, binding.name)?.references ?? []; let uses = 0; for (const ref of refs) { if (!ref.isRead()) continue; - let use = ref.identifier; - if (verdict.member) { - const parent = use.parent; - if (parent?.type !== 'MemberExpression' || parent.object !== use) return false; - const key = parent.property.name ?? parent.property.value; - if (key !== verdict.member) continue; - use = parent; - } - const call = use.parent; - if ( - call?.type !== 'CallExpression' || - call.arguments[0] !== use || - !/^(?:root\.)?ManagedRuntime\.make$/u.test(importedPath(call.callee) ?? '') - ) - return false; + const usage = rootInputUsage(ref.identifier, verdict); + if (usage === 'invalid') return false; + if (usage === 'skip') continue; uses++; } return uses === 1; diff --git a/app/tools/oxlint/effect-native/rules/no-direct-node-io-in-scripts.ts b/app/tools/oxlint/effect-native/rules/no-direct-node-io-in-scripts.ts index 6cdda25f7..b424e780b 100644 --- a/app/tools/oxlint/effect-native/rules/no-direct-node-io-in-scripts.ts +++ b/app/tools/oxlint/effect-native/rules/no-direct-node-io-in-scripts.ts @@ -81,22 +81,13 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { ESTree, Variable } from '@oxlint/plugins'; -import { - globToRegExp, - isScriptFile, - isTestFile, - matchesAny, - normalisePath, -} from '../shared/paths.ts'; - -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets the fixtures exercise the real production defaults instead of forcing - * the fixture config to pass loosened options (which `run-on-repo.mts` reuses verbatim). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; +import { memberName, skipWrappers, staticString, unwrapNode as unwrap } from '../shared/ast.ts'; +import { resolveVariable } from '../shared/bindings.ts'; +import { booleanOption, stringList } from '../shared/options.ts'; +import { inScriptScope, matchesGlobs, scriptScope } from '../shared/paths.ts'; +import { provenance } from '../shared/provenance.ts'; /** * Modules whose exports open a resource nobody owns: the filesystem (sync and promise flavours, @@ -114,17 +105,6 @@ const DEFAULT_MODULES: readonly string[] = [ 'execa', ]; -/** Wrappers that do not change the value of an expression. */ -const TRANSPARENT_TYPES = new Set([ - 'ParenthesizedExpression', - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSInstantiationExpression', - 'TSTypeAssertion', - 'ChainExpression', -]); - type AnyNode = ESTree.Node; interface RuleOptions { @@ -139,30 +119,18 @@ const DEFAULTS: RuleOptions = { reportCalls: false, }; -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - return value.every((entry) => typeof entry === 'string') - ? (value as readonly string[]) - : fallback; -} - function readOptions(raw: unknown): RuleOptions { const given = typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? (raw as Record) : {}; return { - allowPaths: stringArray(given.allowPaths, DEFAULTS.allowPaths), - modules: stringArray(given.modules, DEFAULTS.modules), - reportCalls: typeof given.reportCalls === 'boolean' ? given.reportCalls : DEFAULTS.reportCalls, + allowPaths: stringList(given.allowPaths, DEFAULTS.allowPaths), + modules: stringList(given.modules, DEFAULTS.modules), + reportCalls: booleanOption(given.reportCalls, DEFAULTS.reportCalls), }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - /** `node:fs/promises` → `fs/promises`; leaves package specifiers untouched. */ function withoutNodeProtocol(specifier: string): string { return specifier.startsWith('node:') ? specifier.slice('node:'.length) : specifier; @@ -183,62 +151,13 @@ function isNodeIoSpecifier(specifier: string, modules: readonly string[]): boole /** The static string value of an `import(...)` / `require(...)` argument, when there is one. */ function staticStringValue(node: AnyNode | null | undefined): string | null { - if (node === null || node === undefined) return null; - node = unwrap(node); - if (node.type === 'Literal') { - const value = (node as { value?: unknown }).value; - return typeof value === 'string' ? value : null; - } - if (node.type === 'TemplateLiteral') { - const template = node as ESTree.TemplateLiteral; - if (template.expressions.length !== 0 || template.quasis.length !== 1) return null; - return template.quasis[0]?.value.cooked ?? null; - } - return null; -} - -function unwrap(node: AnyNode): AnyNode { - let current = node; - while (TRANSPARENT_TYPES.has(current.type)) { - const inner = (current as { expression?: AnyNode }).expression ?? null; - if (inner === null) break; - current = inner; - } - return current; + return staticString(node, { unwrap: {}, singleQuasi: true }); } -function parentOf(node: AnyNode): AnyNode | null { - return (node as { parent?: AnyNode | null }).parent ?? null; -} - -/** Climb through parentheses/type wrappers to the outermost equivalent node. */ -function skipWrappers(node: AnyNode): { readonly node: AnyNode; readonly parent: AnyNode | null } { - let current = node; - let parent = parentOf(current); - while (parent !== null && TRANSPARENT_TYPES.has(parent.type)) { - current = parent; - parent = parentOf(current); - } - return { node: current, parent }; -} - -/** Non-computed `.readFileSync`, or computed `["readFileSync"]`. */ -function staticMemberName(node: ESTree.MemberExpression): string | null { - if (!node.computed) { - const property = node.property as AnyNode; - return property.type === 'Identifier' ? (property as ESTree.IdentifierName).name : null; - } - return staticStringValue(node.property as AnyNode); -} - -function lookupVariable(context: Context, node: AnyNode, name: string): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(node); - while (scope !== null) { - const variable = scope.set.get(name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; +function matchesRequiredBinding(variable: Variable, declarators: ReadonlySet): boolean { + return variable.defs.some( + (definition) => definition.type === 'Variable' && declarators.has(definition.node.start), + ); } /** The base identifier of a (possibly nested, possibly optional) member chain: `fs.promises.readFile`. */ @@ -249,7 +168,7 @@ function memberChainRoot( let current: AnyNode = node; while (current.type === 'MemberExpression') { const member = current as ESTree.MemberExpression; - path.unshift(staticMemberName(member) ?? '…'); + path.unshift(memberName(member, { templates: true, singleQuasi: true, unwrap: {} }) ?? '…'); current = unwrap(member.object as AnyNode); } return current.type === 'Identifier' ? { path, root: current } : null; @@ -327,7 +246,7 @@ export const rule = defineRule({ const options = readOptions(context.options?.[0]); const path = scriptScope(context.filename); if (!inScriptScope(path)) return {}; - if (options.allowPaths.some((glob) => globToRegExp(glob).test(path))) return {}; + if (matchesGlobs(path, options.allowPaths)) return {}; /** local name → the Node I/O module it came from, for `import` bindings. */ const importedLocals = new Map(); @@ -342,10 +261,11 @@ export const rule = defineRule({ const resolvesToNodeIo = (node: AnyNode, name: string): string | null => { const fromImport = importedLocals.get(name); const fromRequire = requiredLocals.get(name); - if (fromImport === undefined && fromRequire === undefined) return null; - const variable = lookupVariable(context, node, name); + const recordedModule = fromImport ?? fromRequire ?? null; + if (recordedModule === null) return null; + const variable = resolveVariable(context, name, node); // Unresolved: the module-level declaration already proved the binding exists. - if (variable === null || variable.defs.length === 0) return fromImport ?? fromRequire ?? null; + if (variable === null || variable.defs.length === 0) return recordedModule; if ( fromImport !== undefined && variable.defs.some((definition) => definition.type === 'ImportBinding') @@ -353,22 +273,9 @@ export const rule = defineRule({ return fromImport; } if (fromRequire === undefined) return null; - const matchesDeclarator = variable.defs.some( - (definition) => - definition.type === 'Variable' && - requiredDeclarators.has((definition.node as ESTree.Span).start), - ); - return matchesDeclarator ? fromRequire : null; + return matchesRequiredBinding(variable, requiredDeclarators) ? fromRequire : null; }; - /** - * This callee really is a CommonJS `require`: either the global (an unresolved identifier - * named `require`) or any binding initialised from `createRequire(import.meta.url)`, which - * is how ESM scripts reach `require` — the local name is often `localRequire` / `req`. - */ - const isRequireCallee = (node: AnyNode, _name: string): boolean => - provenance(context, node) === 'require'; - /** Register `const fs = require("node:fs")` / `const { readFile } = require("node:fs/promises")`. */ const registerRequireBinding = (call: ESTree.CallExpression, module: string): void => { const { parent } = skipWrappers(call as unknown as AnyNode); @@ -390,6 +297,16 @@ export const rule = defineRule({ } }; + const reportRequireCall = (node: ESTree.CallExpression, callee: AnyNode): boolean => { + if (callee.type !== 'Identifier') return false; + const required = staticStringValue(node.arguments[0] as AnyNode | undefined); + if (required === null || !isNodeIo(required) || provenance(context, callee) !== 'require') + return false; + registerRequireBinding(node, required); + context.report({ node, messageId: 'nodeIoRequire', data: { module: required } }); + return true; + }; + return { ImportDeclaration(node) { const module = node.source.value; @@ -438,20 +355,7 @@ export const rule = defineRule({ CallExpression(node) { const callee = unwrap(node.callee as AnyNode); - // `require("node:fs")`, and `const localRequire = createRequire(import.meta.url)` calls. - if (callee.type === 'Identifier') { - const calleeName = (callee as ESTree.IdentifierReference).name; - const required = staticStringValue((node.arguments[0] as AnyNode | undefined) ?? null); - if ( - required !== null && - isNodeIo(required) && - isRequireCallee(callee as AnyNode, calleeName) - ) { - registerRequireBinding(node, required); - context.report({ node, messageId: 'nodeIoRequire', data: { module: required } }); - return; - } - } + if (reportRequireCall(node, callee)) return; if (!options.reportCalls) return; @@ -480,213 +384,3 @@ export const rule = defineRule({ }; }, }); - -/** Bounded, lexical provenance only; no type checker or interprocedural/data-flow inference. */ -type Syntax = ESTree.Node & Record; -function syntax(node: unknown): Syntax | null { - let n = node as Syntax | null; - while ( - n && - [ - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSTypeAssertion', - 'TSInstantiationExpression', - 'ParenthesizedExpression', - 'ChainExpression', - 'AwaitExpression', - ].includes(n.type) - ) - n = n.expression ?? n.argument; - return n; -} -function lexicalVariable(context: Context, node: Syntax): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(node); - while (scope) { - const v = scope.set.get(node.name); - if (v) return v; - scope = scope.upper; - } - return null; -} -function literalText(node: unknown): string | null { - const n = syntax(node); - if (n?.type === 'Literal' && typeof n.value === 'string') return n.value; - if (n?.type === 'TemplateLiteral' && n.expressions.length === 0) - return n.quasis[0]?.value.cooked ?? null; - return null; -} -function propertyText(node: unknown): string | null { - const n = node as Syntax; - const key = syntax(n.property ?? n.key); - return !n.computed && key?.type === 'Identifier' ? key.name : literalText(key); -} -function moduleIdentity(source: string): string { - if (/^(?:node:)?(?:process|console|util|module)$/.test(source)) - return source.replace(/^node:/, ''); - if (source === 'effect/Effect') return 'Effect'; - if (source === 'effect/ManagedRuntime') return 'ManagedRuntime'; - return source; -} -function bindingPath(pattern: Syntax, name: string): string[] | null { - if (pattern.type === 'Identifier') return pattern.name === name ? [] : null; - if (pattern.type === 'AssignmentPattern') return bindingPath(pattern.left, name); - if (pattern.type !== 'ObjectPattern') return null; - for (const p of pattern.properties) { - if (p.type !== 'Property') continue; - const key = propertyText(p), - tail = bindingPath(p.value, name); - if (key !== null && tail !== null) return [key, ...tail]; - } - return null; -} -function provenance(context: Context, node: unknown, seen = new Set()): string | null { - const n = syntax(node); - if (!n) return null; - if (n.type === 'Identifier') { - const v = lexicalVariable(context, n); - if (!v || v.defs.length === 0) - return [ - 'process', - 'console', - 'Bun', - 'globalThis', - 'global', - 'window', - 'self', - 'require', - 'Array', - 'Set', - ].includes(n.name) - ? n.name - : null; - if (seen.has(v) || v.defs.length !== 1) return null; - const next = new Set(seen); - next.add(v); - const def = v.defs[0] as any; - if (def.type === 'ImportBinding') { - const spec = def.node as Syntax; - const decl = (def.parent ?? spec.parent) as Syntax; - if (decl.importKind === 'type' || spec.importKind === 'type') return null; - const source = literalText(decl.source); - if (!source) return null; - const base = moduleIdentity(source); - if (spec.type === 'ImportNamespaceSpecifier' || spec.type === 'ImportDefaultSpecifier') - return base; - const name = spec.imported?.name ?? spec.imported?.value; - if (name === 'default') return base; - if (base === 'effect') return name; - return `${base}.${name}`; - } - if (def.type !== 'Variable' || def.node.type !== 'VariableDeclarator') return null; - // A declaration is not a reaching-definition analysis: reassigned aliases are unknown. - if (v.references.some((r: any) => r.init !== true && r.isWrite())) return null; - const d = def.node as Syntax; - const base = provenance(context, d.init, next), - path = bindingPath(d.id, n.name); - return base !== null && path !== null ? [base, ...path].join('.') : null; - } - if (n.type === 'MemberExpression') { - const base = provenance(context, n.object, seen), - key = propertyText(n); - if (base === null || key === null) return null; - if ( - ['globalThis', 'global', 'window', 'self'].includes(base) && - ['process', 'console', 'Bun'].includes(key) - ) - return key; - if (['process', 'console', 'util', 'module'].includes(base) && key === 'default') return base; - if (base === 'effect') return key; - return `${base}.${key}`; - } - if (n.type === 'ImportExpression') { - const text = literalText(n.source); - return text === null ? null : moduleIdentity(text); - } - if (n.type === 'CallExpression') { - const callee = provenance(context, n.callee, seen); - if (callee === 'require') { - const text = literalText(n.arguments[0]); - return text === null ? null : moduleIdentity(text); - } - if (callee === 'module.createRequire') return 'require'; - if (callee === 'ManagedRuntime.make') return 'Runtime'; - } - return null; -} -/** Only value references, never property names, bindings or TS-only identifiers. */ -function valueReference(context: Context, node: unknown): boolean { - const n = node as Syntax, - p = n.parent as Syntax | undefined; - if (!p) return false; - if (p.type.startsWith('Import') || p.type === 'ExportSpecifier') return false; - if (p.type === 'MemberExpression' && p.property === n && !p.computed) return false; - if ( - [ - 'Property', - 'PropertyDefinition', - 'MethodDefinition', - 'TSPropertySignature', - 'TSMethodSignature', - ].includes(p.type) && - p.key === n && - !p.computed && - !(p.shorthand && p.value === n) - ) - return false; - if (['LabeledStatement', 'BreakStatement', 'ContinueStatement'].includes(p.type)) return false; - let child: Syntax = n; - let parent: Syntax | null = p; - while (parent) { - if ( - parent.type.startsWith('TS') && - !( - [ - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSTypeAssertion', - 'TSInstantiationExpression', - ].includes(parent.type) && parent.expression === child - ) - ) - return false; - if ( - parent.type.endsWith('Statement') || - parent.type.endsWith('Declaration') || - parent.type.includes('Function') - ) - break; - child = parent; - parent = parent.parent as Syntax | null; - } - const v = lexicalVariable(context, n); - return ( - !v || - v.references.some( - (r: any) => - r.identifier === n && - r.isRead() && - (typeof r.isValueReference !== 'function' || r.isValueReference()), - ) - ); -} -/** Strip fixture scaffolding first; do not renormalise a relative script path around inner markers. */ -function scriptScope(filename: string): string { - const unified = filename.replaceAll('\\', '/'); - const fixture = unified.match( - /(?:^|\/)tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\/(.*)$/u, - ); - if (fixture) return fixture[1]; - if (!unified.startsWith('/') && !/^[A-Za-z]:\//u.test(unified)) - return unified.replace(/^\.\//, ''); - const match = unified.match(/(?:^|\/)((?:apps|packages|verticals|scripts|tools)\/.*)$/u); - return match?.[1] ?? unified; -} -function inScriptScope(path: string): boolean { - return ( - /(?:^|\/)scripts\//u.test(path) && - !/(?:^|\/)(?:tests?|__tests__)\/|\.(?:test|spec|test-d|spec-d)\.[cm]?[jt]sx?$/u.test(path) - ); -} diff --git a/app/tools/oxlint/effect-native/rules/no-dotenv-loading.ts b/app/tools/oxlint/effect-native/rules/no-dotenv-loading.ts index c59a08298..fe2ee8ed3 100644 --- a/app/tools/oxlint/effect-native/rules/no-dotenv-loading.ts +++ b/app/tools/oxlint/effect-native/rules/no-dotenv-loading.ts @@ -71,9 +71,13 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree } from '@oxlint/plugins'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; +import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; +import { staticString, unwrapNode as unwrap } from '../shared/ast.ts'; +import { resolveVariable } from '../shared/bindings.ts'; +import { importedName } from '../shared/imports.ts'; +import { stringList as stringArray } from '../shared/options.ts'; /** * Any dotenv-family loader package, with or without a subpath (`dotenv/config` is the side-effect @@ -102,24 +106,6 @@ const DEFAULT_SCOPE_PATHS: readonly string[] = [ */ const DEFAULT_ALLOW_PATHS: readonly string[] = ['scripts/initialize-local-development.mts']; -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets the fixtures exercise the real production defaults instead of forcing the - * fixture config to pass loosened options (`run-on-repo.mts` reuses that same fixture config). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; - -/** Wrappers that do not change which expression is actually the callee. */ -const TRANSPARENT = new Set([ - 'ParenthesizedExpression', - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSInstantiationExpression', - 'TSTypeAssertion', - 'ChainExpression', -]); - interface RuleOptions { readonly allowPaths: readonly string[]; readonly ignoreTestFiles: boolean; @@ -134,13 +120,6 @@ const DEFAULTS: RuleOptions = { type AnyNode = ESTree.Node; -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - return value.every((entry) => typeof entry === 'string') - ? (value as readonly string[]) - : fallback; -} - function readOptions(raw: unknown): RuleOptions { const given = typeof raw === 'object' && raw !== null && !Array.isArray(raw) @@ -154,15 +133,6 @@ function readOptions(raw: unknown): RuleOptions { }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - function isDotenvSpecifier(source: string): boolean { return DOTENV_MODULE.test(source); } @@ -175,54 +145,12 @@ function startOf(node: AnyNode | null | undefined): number | null { /** The static string value of an `import(...)` / `require(...)` argument, when there is one. */ function staticStringValue(node: AnyNode | null | undefined): string | null { - if (node === null || node === undefined) return null; - node = unwrap(node); - if (node.type === 'Literal') { - const value = (node as { value?: unknown }).value; - return typeof value === 'string' ? value : null; - } - if (node.type === 'TemplateLiteral') { - const template = node as ESTree.TemplateLiteral; - if (template.expressions.length !== 0 || template.quasis.length !== 1) return null; - return template.quasis[0]?.value.cooked ?? null; - } - return null; -} - -function unwrap(node: AnyNode): AnyNode { - let current = node; - while (TRANSPARENT.has(current.type)) { - const inner = (current as { expression?: AnyNode }).expression ?? null; - if (inner === null) break; - current = inner; - } - return current; + return staticString(node, { unwrap: {}, singleQuasi: true }); } -/** Like {@link unwrap}, but also sees through `await` — `const { config } = await import("dotenv")`. */ +/** Await is transparent when propagating module values, but not callees. */ function unwrapValue(node: AnyNode): AnyNode { - let current = unwrap(node); - while (current.type === 'AwaitExpression') { - const argument = (current as ESTree.AwaitExpression).argument as AnyNode | undefined; - if (argument === undefined) break; - current = unwrap(argument); - } - return current; -} - -function parentOf(node: AnyNode): AnyNode | null { - return (node as { parent?: AnyNode | null }).parent ?? null; -} - -/** Climb through parentheses/type wrappers to the outermost equivalent node. */ -function skipWrappers(node: AnyNode): { readonly node: AnyNode; readonly parent: AnyNode | null } { - let current = node; - let parent = parentOf(current); - while (parent !== null && TRANSPARENT.has(parent.type)) { - current = parent; - parent = parentOf(current); - } - return { node: current, parent }; + return unwrap(node, { await: true }); } /** Non-computed `.config`, or computed `["config"]`. */ @@ -234,16 +162,6 @@ function staticMemberName(node: ESTree.MemberExpression): string | null { return staticStringValue(node.property as AnyNode); } -function lookupVariable(context: Context, node: AnyNode, name: string): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(node); - while (scope !== null) { - const variable = scope.set.get(name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; -} - /** * A set of local names known to hold a particular value (a dotenv module, or a module `require`), * anchored to the exact binding identifiers that introduced them. Resolving a reference re-runs scope @@ -281,7 +199,7 @@ function createTracker(context: Context): Tracker { addImport: add, addDeclared: add, resolve(node, name) { - const variable = lookupVariable(context, node, name); + const variable = resolveVariable(context, name, node); if (!variable || variable.defs.length !== 1) return null; // Do not infer the current value after a reassignment. if (variable.references.some((reference) => reference.isWrite() && !reference.init)) @@ -379,7 +297,7 @@ export const rule = defineRule({ /** `require` is a genuine ambient global here (not a local helper function or parameter). */ const requireIsAmbient = (node: AnyNode): boolean => { - const variable = lookupVariable(context, node, 'require'); + const variable = resolveVariable(context, 'require', node); return variable === null || variable.defs.length === 0; }; @@ -399,14 +317,15 @@ export const rule = defineRule({ moduleNamespace.resolve(object, (object as ESTree.IdentifierReference).name) !== null ); } - // `require("node:module").createRequire(...)` + return isAmbientModuleRequire(object); + }; + + /** Recognize only the ambient loader for the inline node:module factory shape. */ + const isAmbientModuleRequire = (object: AnyNode): boolean => { if (object.type !== 'CallExpression') return false; - const inner = unwrap((object as ESTree.CallExpression).callee as AnyNode); - if (inner.type !== 'Identifier' || (inner as ESTree.IdentifierReference).name !== 'require') - return false; - const specifier = staticStringValue( - ((object as ESTree.CallExpression).arguments[0] as AnyNode) ?? null, - ); + const inner = unwrap(object.callee); + if (inner.type !== 'Identifier' || inner.name !== 'require') return false; + const specifier = staticStringValue(object.arguments[0]); return requireIsAmbient(inner) && specifier !== null && NODE_MODULE_SPECIFIER.test(specifier); }; @@ -424,6 +343,38 @@ export const rule = defineRule({ return callee.type === 'CallExpression' && isCreateRequireCall(callee); }; + const loaderSpecifierOf = (value: AnyNode): string | null => { + if (value.type === 'ImportExpression') return staticStringValue(value.source); + if (value.type !== 'CallExpression') return null; + if (!isModuleLoaderCallee(unwrap(value.callee))) return null; + return staticStringValue(value.arguments[0]); + }; + + const bindModuleImport = (specifier: ESTree.ImportDeclaration['specifiers'][number]): void => { + if (specifier.type === 'ImportNamespaceSpecifier') { + moduleNamespace.addImport(specifier.local.name, 'node:module', specifier.local); + return; + } + if (specifier.type !== 'ImportSpecifier' || specifier.importKind === 'type') return; + if (importedName(specifier) === 'createRequire') { + requireFactory.addImport(specifier.local.name, 'createRequire', specifier.local); + } + }; + + const bindModulePattern = (id: AnyNode, node: ESTree.VariableDeclarator): void => { + if (id.type === 'Identifier') { + moduleNamespace.addDeclared(id.name, 'node:module', id, node); + return; + } + if (id.type !== 'ObjectPattern') return; + for (const property of id.properties) { + if (property.type !== 'Property') continue; + const target = property.value; + if (target.type !== 'Identifier') continue; + requireFactory.addDeclared(target.name, 'createRequire', target, node); + } + }; + /** * The dotenv module an expression evaluates to, for binding propagation: * `require("dotenv")`, `import("dotenv")`, a tracked local, or the `esModuleInterop` @@ -431,14 +382,8 @@ export const rule = defineRule({ */ const dotenvValueOf = (expression: AnyNode): string | null => { const value = unwrapValue(expression); - if (value.type === 'CallExpression') { - const call = value as ESTree.CallExpression; - if (!isModuleLoaderCallee(unwrap(call.callee as AnyNode))) return null; - const module = staticStringValue((call.arguments[0] as AnyNode) ?? null); - return module !== null && isDotenvSpecifier(module) ? module : null; - } - if (value.type === 'ImportExpression') { - const module = staticStringValue((value as ESTree.ImportExpression).source as AnyNode); + if (value.type === 'CallExpression' || value.type === 'ImportExpression') { + const module = loaderSpecifierOf(value); return module !== null && isDotenvSpecifier(module) ? module : null; } if (value.type === 'Identifier') { @@ -492,20 +437,7 @@ export const rule = defineRule({ const module = node.source.value; if (NODE_MODULE_SPECIFIER.test(module)) { - for (const specifier of node.specifiers) { - if (specifier.type === 'ImportNamespaceSpecifier') { - moduleNamespace.addImport(specifier.local.name, 'node:module', specifier.local); - continue; - } - if (specifier.type !== 'ImportSpecifier' || specifier.importKind === 'type') continue; - const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; - if (imported === 'createRequire') { - requireFactory.addImport(specifier.local.name, 'createRequire', specifier.local); - } - } + node.specifiers.forEach(bindModuleImport); return; } @@ -568,34 +500,9 @@ export const rule = defineRule({ const value = unwrapValue(init); // `const nodeModule = require("node:module")` / `const { createRequire } = await import("node:module")` - const loaderSpecifier = - value.type === 'CallExpression' && - isModuleLoaderCallee(unwrap((value as ESTree.CallExpression).callee as AnyNode)) - ? staticStringValue(((value as ESTree.CallExpression).arguments[0] as AnyNode) ?? null) - : value.type === 'ImportExpression' - ? staticStringValue((value as ESTree.ImportExpression).source as AnyNode) - : null; + const loaderSpecifier = loaderSpecifierOf(value); if (loaderSpecifier !== null && NODE_MODULE_SPECIFIER.test(loaderSpecifier)) { - if (id.type === 'Identifier') { - moduleNamespace.addDeclared( - (id as ESTree.BindingIdentifier).name, - 'node:module', - id, - node, - ); - } else if (id.type === 'ObjectPattern') { - for (const property of (id as ESTree.ObjectPattern).properties) { - if (property.type !== 'Property') continue; - const target = property.value as AnyNode; - if (target.type !== 'Identifier') continue; - requireFactory.addDeclared( - (target as ESTree.BindingIdentifier).name, - 'createRequire', - target, - node, - ); - } - } + bindModulePattern(id, node); return; } @@ -634,6 +541,3 @@ export const rule = defineRule({ }; }, }); - -/** Kept for the CommonJS shapes that declare a binding through a wrapper expression. */ -void skipWrappers; diff --git a/app/tools/oxlint/effect-native/rules/no-driver-failure-inspection.ts b/app/tools/oxlint/effect-native/rules/no-driver-failure-inspection.ts index c209fe96e..6ab8b3c15 100644 --- a/app/tools/oxlint/effect-native/rules/no-driver-failure-inspection.ts +++ b/app/tools/oxlint/effect-native/rules/no-driver-failure-inspection.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * Audit finding: **A5** — "Introduce an Effect-shaped persistence seam and typed database failures" * in `docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`: *"PostgreSQL failures are either walked @@ -57,16 +58,17 @@ import { defineRule } from '@oxlint/plugins'; import type { Context, ESTree } from '@oxlint/plugins'; -import { collectEffectBindings, effectMember } from '../shared/effect-imports.ts'; -import type { EffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isScriptFile, isTestFile, normalisePath } from '../shared/paths.ts'; - -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production `include`/`ignore` defaults instead - * of forcing the fixture config to pass loosened options (which `run-on-repo.mts` reuses). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; +import { + isNode, + EXPRESSION_WRAPPERS, + staticString as readStaticString, + keyName, +} from '../shared/ast.ts'; +import { resolveVariable } from '../shared/bindings.ts'; +import { effectOrigin } from '../shared/effect-identity.ts'; +import { compile, stringArray } from '../shared/options.ts'; +import { snippet } from '../shared/reporting.ts'; +import { isScriptFile, isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**']; @@ -178,37 +180,10 @@ interface RuleOptions { readonly detectCauseWalk: boolean; } -type AnyNode = Record & { readonly type: string }; - -function isNode(value: unknown): value is AnyNode { - return ( - typeof value === 'object' && - value !== null && - typeof (value as { type?: unknown }).type === 'string' - ); -} - -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - -function compile(value: unknown, fallback: string, flags: string): RegExp { - const source = typeof value === 'string' && value.length > 0 ? value : fallback; - try { - return new RegExp(source, flags); - } catch { - return new RegExp(fallback, flags); - } -} +type AnyNode = ESTree.Node & Record; function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { include: stringArray(record.include, DEFAULT_INCLUDE), ignore: stringArray(record.ignore, DEFAULT_IGNORE), @@ -231,38 +206,17 @@ function readOptions(context: Context): RuleOptions { }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -/** Non-computed `.x`, or computed `["x"]`. */ function memberPropertyName(node: AnyNode): string | null { - const property = node.property; - if (!isNode(property)) return null; - if (node.computed === true) { - return staticString(property); - } - return property.type === 'Identifier' && typeof property.name === 'string' ? property.name : null; + if (!isNode(node.property)) return null; + if (node.computed === true) return staticString(node.property); + return node.property.type === 'Identifier' ? node.property.name : null; } /** Unwrap parentheses, chains, `!`, `as T` so callee/operand inspection sees the real node. */ function unwrap(node: unknown): AnyNode | null { let current: unknown = node; while (isNode(current)) { - if ( - current.type === 'ChainExpression' || - current.type === 'ParenthesizedExpression' || - current.type === 'TSNonNullExpression' || - current.type === 'TSAsExpression' || - current.type === 'TSSatisfiesExpression' || - current.type === 'TSInstantiationExpression' || - current.type === 'TSTypeAssertion' - ) { + if (EXPRESSION_WRAPPERS.has(current.type)) { current = current.expression ?? current.argument; continue; } @@ -293,12 +247,7 @@ function objectLooksLikeExit(object: unknown, pattern: RegExp): boolean { } /** `Cause.*`, `Exit.*`, `Effect.failCause` — a sink that legitimately consumes an Effect `Cause`. */ -function isCauseSink( - context: Context, - callee: unknown, - bindings: EffectBindings, - sinks: readonly string[], -): boolean { +function isCauseSink(context: Context, callee: unknown, sinks: readonly string[]): boolean { const target = unwrap(callee); if (target === null) return false; const origin = effectOrigin(context, target as unknown as ESTree.Node, [ @@ -314,44 +263,15 @@ function isCauseSink( } /** Walk parents: is `node` (transitively) an argument of a `Cause.*`/`Exit.*`/`Effect.failCause` call? */ -function insideCauseSink( - context: Context, - node: AnyNode, - bindings: EffectBindings, - sinks: readonly string[], -): boolean { +function insideCauseSink(context: Context, node: AnyNode, sinks: readonly string[]): boolean { let current: AnyNode = node; let parent = isNode(current.parent) ? current.parent : null; let depth = 0; while (parent !== null && depth < 12) { if (parent.type === 'CallExpression' || parent.type === 'NewExpression') { - const args = Array.isArray(parent.arguments) ? parent.arguments : []; - if (args.includes(current) && isCauseSink(context, parent.callee, bindings, sinks)) - return true; - const origin = isNode(parent.callee) - ? effectOrigin(context, parent.callee as unknown as ESTree.Node, []) - : null; - // Only the first transformation receives the unchanged value; a later sink is not proof. - if ( - args[0] === current && - (origin?.join('.') === 'pipe' || origin?.join('.') === 'Function.pipe') && - isCauseSink(context, args[1], bindings, sinks) - ) - return true; - return false; - } - // Only transparent wrappers keep the "argument of" relation alive. - if ( - parent.type !== 'ChainExpression' && - parent.type !== 'ParenthesizedExpression' && - parent.type !== 'TSNonNullExpression' && - parent.type !== 'TSAsExpression' && - parent.type !== 'TSSatisfiesExpression' && - parent.type !== 'TSTypeAssertion' && - parent.type !== 'TSInstantiationExpression' - ) { - return false; + return callConsumesCause(context, parent, current, sinks); } + if (!EXPRESSION_WRAPPERS.has(parent.type)) return false; current = parent; parent = isNode(current.parent) ? current.parent : null; depth += 1; @@ -359,6 +279,22 @@ function insideCauseSink( return false; } +function callConsumesCause( + context: Context, + call: AnyNode, + value: AnyNode, + sinks: readonly string[], +): boolean { + const args = Array.isArray(call.arguments) ? call.arguments : []; + if (args.includes(value) && isCauseSink(context, call.callee, sinks)) return true; + const origin = isNode(call.callee) ? effectOrigin(context, call.callee, []) : null; + return ( + args[0] === value && + ['pipe', 'Function.pipe'].includes(origin?.join('.') ?? '') && + isCauseSink(context, args[1], sinks) + ); +} + /** `this.cause = …` / `error.cause = …` — a write to an explicit cause field, not a chain walk. */ function isAssignmentTarget(node: AnyNode): boolean { const parent = isNode(node.parent) ? node.parent : null; @@ -369,44 +305,43 @@ function isAssignmentTarget(node: AnyNode): boolean { ); } +const NON_EXPRESSION_PARENTS = new Set([ + 'ImportDeclaration', + 'ExportNamedDeclaration', + 'ExportAllDeclaration', + 'ImportExpression', + 'ImportAttribute', + 'TSLiteralType', + 'TSModuleDeclaration', + 'TSImportType', + 'TSEnumMember', + 'TSPropertySignature', + 'TSAbstractMethodDefinition', + 'JSXAttribute', + 'Directive', + 'ExpressionStatement', +]); +const PROPERTY_PARENTS = new Set([ + 'Property', + 'PropertyDefinition', + 'MethodDefinition', + 'AccessorProperty', +]); + /** Positions where a string literal is real runtime data rather than a key, type or module specifier. */ function isExpressionContext(node: AnyNode): boolean { const parent = isNode(node.parent) ? node.parent : null; if (parent === null) return false; - switch (parent.type) { - case 'ImportDeclaration': - case 'ExportNamedDeclaration': - case 'ExportAllDeclaration': - case 'ImportExpression': - case 'ImportAttribute': - case 'TSLiteralType': - case 'TSModuleDeclaration': - case 'TSImportType': - case 'TSEnumMember': - case 'TSPropertySignature': - case 'TSAbstractMethodDefinition': - case 'JSXAttribute': - case 'Directive': - return false; - case 'Property': - case 'PropertyDefinition': - case 'MethodDefinition': - case 'AccessorProperty': - return ( - (parent.type === 'Property' && - isNode(parent.parent) && - parent.parent.type === 'ObjectExpression') || - parent.key !== node - ); - case 'ExpressionStatement': - // A bare string statement is a directive prologue, not an inspection. - return false; - case 'MemberExpression': - // `x["23505"]` reads a field named after the code; still an inspection of driver data. - return parent.computed === true; - default: - return true; + if (NON_EXPRESSION_PARENTS.has(parent.type)) return false; + if (PROPERTY_PARENTS.has(parent.type)) { + return ( + (parent.type === 'Property' && + isNode(parent.parent) && + parent.parent.type === 'ObjectExpression') || + (parent as AnyNode).key !== node + ); } + return parent.type !== 'MemberExpression' || parent.computed === true; } /** @@ -421,17 +356,18 @@ function isCodeComparisonPosition(node: AnyNode): boolean { } if (parent.type === 'SwitchCase' && parent.test === node) return true; if (parent.type === 'ArrayExpression') return true; - if (parent.type === 'CallExpression') { - const args = Array.isArray(parent.arguments) ? parent.arguments : []; - if (!args.includes(node)) return false; - const callee = unwrap(parent.callee); - if (callee === null || callee.type !== 'MemberExpression') return false; - const method = memberPropertyName(callee); - return method !== null && MEMBERSHIP_METHODS.has(method); - } + if (parent.type === 'CallExpression') return isMembershipArgument(parent, node); return false; } +function isMembershipArgument(parent: AnyNode, node: AnyNode): boolean { + const args = Array.isArray(parent.arguments) ? parent.arguments : []; + if (!args.includes(node)) return false; + const callee = unwrap(parent.callee); + if (callee?.type !== 'MemberExpression') return false; + return MEMBERSHIP_METHODS.has(memberPropertyName(callee) ?? ''); +} + /** * `'detail' in error`, `'table' in row.cause` → the narrowed subject reads like a failure value; * `'detail' in event` (a DOM `CustomEvent`) does not. Only consulted for `ambiguousKeys`. @@ -450,122 +386,11 @@ function operandLooksLikeFailure(node: unknown, pattern: RegExp): boolean { } function excerpt(context: Context, node: ESTree.Node): string { - const text = context.sourceCode.getText(node).replace(/\s+/gu, ' ').trim(); - return text.length > 80 ? `${text.slice(0, 77)}…` : text; -} - -// Resolve runtime identity, not spelling. Only immutable same-file aliases are followed; -// dynamic imports, mutable rebinding and arbitrary cross-module re-exports remain unknown. -function effectOrigin( - context: Context, - input: ESTree.Node, - barrels: readonly string[], - depth = 0, -): readonly string[] | null { - if (depth > 24) return null; - let node = input; - while ( - [ - 'ParenthesizedExpression', - 'ChainExpression', - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSInstantiationExpression', - 'TSTypeAssertion', - ].includes(node.type) - ) { - node = (node as { expression: ESTree.Node }).expression; - } - const keyOf = (key: ESTree.Node, computed: boolean): string | null => { - if (!computed && key.type === 'Identifier') return key.name; - if (key.type === 'Literal' && typeof key.value === 'string') return key.value; - if (key.type === 'TemplateLiteral' && key.expressions.length === 0) - return key.quasis[0]?.value.cooked ?? null; - return null; - }; - if (node.type === 'MemberExpression') { - const key = keyOf(node.property, node.computed); - const base = effectOrigin(context, node.object, barrels, depth + 1); - return base && key !== null ? [...base, key] : null; - } - if (node.type !== 'Identifier') return null; - let scope: ReturnType | null = - context.sourceCode.getScope(node); - while (scope) { - const variable = scope.set.get(node.name); - const defs = variable?.defs.filter( - (def) => - !['TSInterfaceDeclaration', 'TSTypeAliasDeclaration', 'TSTypeParameter'].includes( - def.node.type, - ), - ); - if (!variable || !defs?.length) { - scope = scope.upper; - continue; - } - if (defs.length !== 1) return null; - const def = defs[0]!; - if (def.type === 'ImportBinding') { - const spec = def.node; - const declaration = def.parent?.type === 'ImportDeclaration' ? def.parent : spec.parent; - if ( - declaration?.type !== 'ImportDeclaration' || - declaration.importKind === 'type' || - (spec as { importKind?: string }).importKind === 'type' - ) - return null; - const source = declaration.source.value; - const root = source === 'effect' || barrels.some((glob) => globToRegExp(glob).test(source)); - if (!root && !source.startsWith('effect/')) return null; - const base = root ? [] : [source.split('/').at(-1)!]; - if (spec.type === 'ImportNamespaceSpecifier' || spec.type === 'ImportDefaultSpecifier') - return base; - if (spec.type !== 'ImportSpecifier') return null; - return [ - ...base, - spec.imported.type === 'Identifier' ? spec.imported.name : spec.imported.value, - ]; - } - const declaration = def.node; - if ( - declaration.type !== 'VariableDeclarator' || - !declaration.init || - declaration.parent?.type !== 'VariableDeclaration' || - declaration.parent.kind !== 'const' - ) - return null; - if (variable.references.some((reference) => reference.isWrite() && !reference.init)) - return null; - const base = effectOrigin(context, declaration.init, barrels, depth + 1); - if (!base) return null; - if (declaration.id.type === 'Identifier') return base; - if (declaration.id.type !== 'ObjectPattern') return null; - for (const property of declaration.id.properties) { - if ( - property.type !== 'Property' || - property.value.type !== 'Identifier' || - property.value.name !== node.name - ) - continue; - const key = keyOf(property.key, property.computed); - return key === null ? null : [...base, key]; - } - return null; - } - return null; + return snippet(context.sourceCode.getText(node), 80, 77); } function staticString(input: unknown): string | null { - const node = unwrap(input); - if (node?.type === 'Literal' && typeof node.value === 'string') return node.value; - if ( - node?.type === 'TemplateLiteral' && - Array.isArray(node.expressions) && - node.expressions.length === 0 - ) - return (node.quasis as { value: { cooked: string } }[])[0]?.value.cooked ?? null; - return null; + return readStaticString(unwrap(input)); } function unshadowedGlobal(context: Context, input: unknown, name: string): boolean { @@ -588,30 +413,26 @@ function unshadowedGlobal(context: Context, input: unknown, name: string): boole return true; } +function isReassignment(reference: { isWrite(): boolean; init?: boolean }): boolean { + return reference.isWrite() && !reference.init; +} +function isConstantDeclaration(node: ESTree.Node | undefined): node is ESTree.VariableDeclarator { + return ( + node?.type === 'VariableDeclarator' && + node.parent?.type === 'VariableDeclaration' && + node.parent.kind === 'const' + ); +} + function constValue(context: Context, input: unknown, depth = 0): AnyNode | null { const node = unwrap(input); if (!node || node.type !== 'Identifier' || depth > 24) return node; - let scope: ReturnType | null = context.sourceCode.getScope( - node as unknown as ESTree.Node, - ); - while (scope) { - const variable = scope.set.get(String(node.name)); - if (!variable) { - scope = scope.upper; - continue; - } - if (variable.defs.length !== 1 || variable.references.some((r) => r.isWrite() && !r.init)) - return node; - const declaration = variable.defs[0]?.node; - if ( - declaration?.type !== 'VariableDeclarator' || - declaration.parent?.type !== 'VariableDeclaration' || - declaration.parent.kind !== 'const' - ) - return node; - return constValue(context, declaration.init, depth + 1); - } - return node; + const variable = resolveVariable(context, String(node.name), node as unknown as ESTree.Node); + if (!variable || variable.defs.length !== 1 || variable.references.some(isReassignment)) + return node; + const declaration = variable.defs[0]?.node; + if (!isConstantDeclaration(declaration)) return node; + return constValue(context, declaration.init, depth + 1); } // Two-digit strings are also months/hours. Require a code-shaped receiver (including @@ -626,17 +447,14 @@ function codePrefixSubject(context: Context, input: unknown, depth = 0): boolean if (node.type === 'CallExpression' && unshadowedGlobal(context, node.callee, 'String')) { return codePrefixSubject(context, (node.arguments as unknown[])[0], depth + 1); } - if (node.type === 'ConditionalExpression') - return ( - codePrefixSubject(context, node.consequent, depth + 1) || - codePrefixSubject(context, node.alternate, depth + 1) - ); - if (node.type === 'LogicalExpression') - return ( - codePrefixSubject(context, node.left, depth + 1) || - codePrefixSubject(context, node.right, depth + 1) - ); - return false; + const branches = prefixBranches(node); + return branches.some((branch) => codePrefixSubject(context, branch, depth + 1)); +} + +function prefixBranches(node: AnyNode): unknown[] { + if (node.type === 'ConditionalExpression') return [node.consequent, node.alternate]; + if (node.type === 'LogicalExpression') return [node.left, node.right]; + return []; } // `code` is also a first-party domain/transport field. Require independent driver evidence @@ -656,24 +474,7 @@ function hasDriverEvidence(input: AnyNode, options: RuleOptions): boolean { ['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'].includes(node.type) ) return false; - const text = staticString(node); - if ( - text !== null && - (options.sqlStatePattern.test(text) || - options.networkCodes.has(text) || - ['cause', 'constraint', 'sqlState', 'errno', 'syscall'].includes(text)) - ) - return true; - if ( - node.type === 'MemberExpression' && - ['cause', 'constraint', 'sqlState', 'errno', 'syscall'].includes( - memberPropertyName(node) ?? '', - ) - ) - return true; - const regex = node.regex as { pattern?: string } | undefined; - if (regex?.pattern && SQLSTATE_REGEX_PATTERNS.some((probe) => probe.test(regex.pattern!))) - return true; + if (isDriverEvidence(node, options)) return true; for (const [key, value] of Object.entries(node)) { if (['parent', 'loc', 'range', 'tokens', 'comments'].includes(key)) continue; if (isNode(value) && walk(value)) return true; @@ -684,6 +485,103 @@ function hasDriverEvidence(input: AnyNode, options: RuleOptions): boolean { return walk(region); } +function isDriverEvidence(node: AnyNode, options: RuleOptions): boolean { + const text = staticString(node); + if ( + text !== null && + (options.sqlStatePattern.test(text) || + options.networkCodes.has(text) || + ['cause', 'constraint', 'sqlState', 'errno', 'syscall'].includes(text)) + ) + return true; + if ( + node.type === 'MemberExpression' && + ['cause', 'constraint', 'sqlState', 'errno', 'syscall'].includes(memberPropertyName(node) ?? '') + ) + return true; + return hasSqlStateRegex(node); +} + +function hasSqlStateRegex(node: AnyNode): boolean { + const regex = node.regex as { pattern?: string } | undefined; + return Boolean( + regex?.pattern && SQLSTATE_REGEX_PATTERNS.some((probe) => probe.test(regex.pattern!)), + ); +} + +function isPrefixComparison(context: Context, value: unknown, other: unknown): boolean { + const text = staticString(value); + const call = unwrap(other); + if (text === null || !TWO_DIGIT.test(text) || call?.type !== 'CallExpression') return false; + const callee = unwrap(call.callee); + if (callee?.type !== 'MemberExpression' || !codePrefixSubject(context, callee.object)) + return false; + return isPrefixSlice(callee, call.arguments as unknown[]); +} +function isPrefixSlice(callee: AnyNode, args: unknown[]): boolean { + return ( + ['slice', 'substring', 'substr'].includes(memberPropertyName(callee) ?? '') && + unwrap(args[0])?.value === 0 && + unwrap(args[1])?.value === 2 + ); +} +function isOwnKeyProbe(context: Context, callee: AnyNode, method: string | null): boolean { + if (method === 'hasOwn') return unshadowedGlobal(context, callee.object, 'Object'); + if (method === 'has') return unshadowedGlobal(context, callee.object, 'Reflect'); + return method === 'call' && isPrototypeOwnProbe(context, callee.object); +} +function isPrototypeOwnProbe(context: Context, input: unknown): boolean { + const own = unwrap(input); + if (own?.type !== 'MemberExpression' || memberPropertyName(own) !== 'hasOwnProperty') + return false; + const prototype = unwrap(own.object); + return ( + prototype?.type === 'MemberExpression' && + memberPropertyName(prototype) === 'prototype' && + unshadowedGlobal(context, prototype.object, 'Object') + ); +} +function isClassArrayParameter(context: Context, input: unknown): boolean { + const first = unwrap(input); + if (first?.type !== 'Identifier') return false; + const variable = resolveVariable(context, String(first.name), first as unknown as ESTree.Node); + if (!variable || variable.references.some(isReassignment)) return false; + const def = variable.defs[0]; + if (def?.type !== 'Parameter') return false; + return callbackUsesClassArray(context, def.node, String(first.name)); +} +function callbackUsesClassArray(context: Context, fn: ESTree.Node, name: string): boolean { + if (fn.type !== 'ArrowFunctionExpression' && fn.type !== 'FunctionExpression') return false; + if (fn.params[0]?.type !== 'Identifier' || fn.params[0].name !== name) return false; + const parent = fn.parent; + if (parent?.type !== 'CallExpression' || parent.arguments[0] !== fn) return false; + return isClassArrayIteration(context, parent.callee); +} +function isClassArrayIteration(context: Context, input: unknown): boolean { + const owner = unwrap(input); + if ( + owner?.type !== 'MemberExpression' || + !['some', 'every', 'find', 'filter'].includes(memberPropertyName(owner) ?? '') + ) + return false; + const list = constValue(context, owner.object); + return ( + list?.type === 'ArrayExpression' && + Array.isArray(list.elements) && + list.elements.length > 0 && + list.elements.every(isClassPrefix) + ); +} +function isClassPrefix(entry: unknown): boolean { + const value = staticString(entry); + return value !== null && TWO_DIGIT.test(value); +} + +function isOwnCauseReceiver(input: unknown): boolean { + const value = unwrap(input); + return value?.type === 'ThisExpression' || value?.type === 'Super'; +} + export const rule = defineRule({ meta: { type: 'problem', @@ -776,8 +674,6 @@ export const rule = defineRule({ if (!options.includeTests && isTestFile(path)) return {}; if (isScriptFile(path)) return {}; - let bindings: EffectBindings = { importsEffect: false, namespaces: new Map() }; - const reportNode = ( node: ESTree.Node, messageId: string, @@ -823,9 +719,6 @@ export const rule = defineRule({ reportNode(node, 'sqlStateRegex', {}); }; return { - Program(program) { - bindings = collectEffectBindings(program); - }, BinaryExpression(node) { if (node.operator === 'in') { narrowing(node, node.right, staticString(node.left)); @@ -836,18 +729,7 @@ export const rule = defineRule({ [node.left, node.right], [node.right, node.left], ]) { - const text = staticString(value); - const call = unwrap(other); - if (text === null || !TWO_DIGIT.test(text) || call?.type !== 'CallExpression') continue; - const callee = unwrap(call.callee); - const args = call.arguments as unknown[]; - if ( - callee?.type === 'MemberExpression' && - codePrefixSubject(context, callee.object) && - ['slice', 'substring', 'substr'].includes(memberPropertyName(callee) ?? '') && - unwrap(args[0])?.value === 0 && - unwrap(args[1])?.value === 2 - ) { + if (isPrefixComparison(context, value, other)) { reportNode(node, 'codePrefix', {}); return; } @@ -867,7 +749,7 @@ export const rule = defineRule({ return; if ( objectLooksLikeExit(raw.object, options.exitNamePattern) || - insideCauseSink(context, raw, bindings, options.causeSinks) + insideCauseSink(context, raw, options.causeSinks) ) return; reportNode(node, 'causeWalk', {}); @@ -880,14 +762,13 @@ export const rule = defineRule({ objectLooksLikeExit(node.init, options.exitNamePattern) ) return; - const value = unwrap(node.init); - if (value?.type === 'ThisExpression' || value?.type === 'Super') return; + if (isOwnCauseReceiver(node.init)) return; for (const property of node.id.properties) { if (property.type !== 'Property') continue; - const key = - !property.computed && property.key.type === 'Identifier' - ? property.key.name - : staticString(property.key); + const key = keyName(property.key, property.computed, { + templates: true, + unwrap: { argumentFallback: true }, + }); if (key === 'cause') reportNode(property, 'causeWalk', {}); } }, @@ -901,23 +782,7 @@ export const rule = defineRule({ if (callee?.type !== 'MemberExpression') return; const method = memberPropertyName(callee); const args = node.arguments; - if ( - (method === 'hasOwn' && unshadowedGlobal(context, callee.object, 'Object')) || - (method === 'has' && unshadowedGlobal(context, callee.object, 'Reflect')) - ) - narrowing(node, args[0], staticString(args[1])); - if (method === 'call') { - const own = unwrap(callee.object); - const prototype = own?.type === 'MemberExpression' ? unwrap(own.object) : null; - if ( - own?.type === 'MemberExpression' && - memberPropertyName(own) === 'hasOwnProperty' && - prototype?.type === 'MemberExpression' && - memberPropertyName(prototype) === 'prototype' && - unshadowedGlobal(context, prototype.object, 'Object') - ) - narrowing(node, args[0], staticString(args[1])); - } + if (isOwnKeyProbe(context, callee, method)) narrowing(node, args[0], staticString(args[1])); if ( !method || !options.prefixMethods.has(method) || @@ -929,49 +794,7 @@ export const rule = defineRule({ reportNode(node, 'codePrefix', {}); return; } - // Hoisted class arrays only when the callback's actual parameter supplies the prefix. - const first = unwrap(args[0]); - if (first?.type !== 'Identifier') return; - let scope: ReturnType | null = - context.sourceCode.getScope(first as unknown as ESTree.Node); - while (scope) { - const variable = scope.set.get(String(first.name)); - if (!variable) { - scope = scope.upper; - continue; - } - const def = variable.defs[0]; - const fn = def?.node; - if ( - def?.type !== 'Parameter' || - !fn || - (fn.type !== 'ArrowFunctionExpression' && fn.type !== 'FunctionExpression') || - fn.params[0]?.type !== 'Identifier' || - fn.params[0].name !== first.name || - variable.references.some((r) => r.isWrite() && !r.init) - ) - return; - const parent = fn.parent; - if (parent?.type !== 'CallExpression' || parent.arguments[0] !== fn) return; - const owner = unwrap(parent.callee); - if ( - owner?.type !== 'MemberExpression' || - !['some', 'every', 'find', 'filter'].includes(memberPropertyName(owner) ?? '') - ) - return; - const list = constValue(context, owner.object); - if ( - list?.type === 'ArrayExpression' && - Array.isArray(list.elements) && - list.elements.length > 0 && - list.elements.every((entry) => { - const value = staticString(entry); - return value !== null && TWO_DIGIT.test(value); - }) - ) - reportNode(node, 'codePrefix', {}); - return; - } + if (isClassArrayParameter(context, args[0])) reportNode(node, 'codePrefix', {}); }, }; }, diff --git a/app/tools/oxlint/effect-native/rules/no-duplicate-literal-vocabulary.ts b/app/tools/oxlint/effect-native/rules/no-duplicate-literal-vocabulary.ts index 283d7d2fe..95908d911 100644 --- a/app/tools/oxlint/effect-native/rules/no-duplicate-literal-vocabulary.ts +++ b/app/tools/oxlint/effect-native/rules/no-duplicate-literal-vocabulary.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * effect-native/no-duplicate-literal-vocabulary * @@ -81,23 +82,16 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree } from '@oxlint/plugins'; import { collectEffectBindings } from '../shared/effect-imports.ts'; -import type { EffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; +import { collectSchemaLocals } from '../shared/imports.ts'; +import { memberName, staticString, unwrapNode } from '../shared/ast.ts'; +import { lookupVariable, resolvesToImport } from '../shared/bindings.ts'; +import { booleanOption, positiveInteger, stringArray } from '../shared/options.ts'; +import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; const SCHEMA_NAMESPACE = 'Schema'; -const EFFECT_ROOT_MODULE = 'effect'; -const EFFECT_SCHEMA_MODULE = /^effect\/(?:.*\/)?Schema$/u; - -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production `include` defaults instead of - * forcing the fixture config to pass loosened options (which `run-on-repo.mts` reuses verbatim). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; - const DEFAULT_INCLUDE: readonly string[] = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; const DEFAULT_IGNORE: readonly string[] = []; @@ -130,52 +124,19 @@ interface RuleOptions { readonly reexportModules: readonly string[]; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - -function boolean(value: unknown, fallback: boolean): boolean { - return typeof value === 'boolean' ? value : fallback; -} - -function positiveInteger(value: unknown, fallback: number): number { - return typeof value === 'number' && Number.isInteger(value) && value >= 1 ? value : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { include: stringArray(record.include, DEFAULT_INCLUDE), ignore: stringArray(record.ignore, DEFAULT_IGNORE), - ignoreTests: boolean(record.ignoreTests, false), - reportSubsets: boolean(record.reportSubsets, false), + ignoreTests: booleanOption(record.ignoreTests, false), + reportSubsets: booleanOption(record.reportSubsets, false), minMembers: positiveInteger(record.minMembers, DEFAULT_MIN_MEMBERS), factories: stringArray(record.factories, DEFAULT_FACTORIES), reexportModules: stringArray(record.reexportModules, DEFAULT_REEXPORT_MODULES), }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -function importedName(specifier: ESTree.ImportSpecifier): string { - return specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; -} - interface SchemaLocals { /** Locals standing for Effect's `Schema` namespace (`Schema`, `S`, `import * as Schema from "effect/Schema"`). */ readonly schema: ReadonlySet; @@ -185,98 +146,30 @@ interface SchemaLocals { readonly direct: ReadonlyMap; } -function collectSchemaLocals( - program: ESTree.Program, - bindings: EffectBindings, - reexportModules: readonly string[], -): SchemaLocals { - const schema = new Set(); - const barrel = new Set(); - const direct = new Map(); - for (const [local, namespace] of bindings.namespaces) { - if (namespace === SCHEMA_NAMESPACE) schema.add(local); - } - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - const source = statement.source.value; - if (EFFECT_SCHEMA_MODULE.test(source)) { - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportSpecifier') - direct.set(specifier.local.name, importedName(specifier)); - else if (specifier.type === 'ImportNamespaceSpecifier') schema.add(specifier.local.name); - } - continue; - } - const isEffectRoot = source === EFFECT_ROOT_MODULE; - const isReexport = matchesGlobs(source, reexportModules); - if (!isEffectRoot && !isReexport) continue; - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportNamespaceSpecifier') barrel.add(specifier.local.name); - else if ( - specifier.type === 'ImportSpecifier' && - importedName(specifier) === SCHEMA_NAMESPACE - ) { - schema.add(specifier.local.name); - } - } - } - return { schema, barrel, direct }; -} - -/** Non-computed `.Literals`, or computed `["Literals"]`. */ -function memberName(node: ESTree.MemberExpression): string | null { - if (!node.computed) return node.property.type === 'Identifier' ? node.property.name : null; - const property = node.property; - return property.type === 'Literal' && typeof property.value === 'string' ? property.value : null; -} +const VOCABULARY_WRAPPERS: ReadonlySet = new Set([ + 'TSAsExpression', + 'TSSatisfiesExpression', + 'TSNonNullExpression', + 'ChainExpression', + 'TSInstantiationExpression', +]); -function lookupVariable( - context: Context, - identifier: Extract, -): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(identifier); - while (scope !== null) { - const variable = scope.set.get(identifier.name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; +/** Preserve the vocabulary rule's bounded, deliberately narrow wrapper policy. */ +function unwrap(node: ESTree.Node): ESTree.Node { + return unwrapNode(node, { wrappers: VOCABULARY_WRAPPERS, maxDepth: MAX_NAME_DEPTH }); } -/** - * `true` when the identifier still resolves to an `import` binding. Unresolved names fall back to - * `true` because the module-level import declaration already proved the binding exists; only a local - * shadow (parameter, `const`, catch clause, …) rejects the match. - */ -function resolvesToImport( +function constBindingDeclarator( context: Context, identifier: Extract, -): boolean { +): ESTree.VariableDeclarator | null { const variable = lookupVariable(context, identifier); - if (variable === null) return true; - if (variable.defs.length === 0) return true; - return variable.defs.some((definition) => definition.type === 'ImportBinding'); -} - -/** Strip transparent expression wrappers (`as const`, `satisfies …`, `!`, `(…)`). */ -function unwrap(node: ESTree.Node): ESTree.Node { - let current = node; - for (let depth = 0; depth < MAX_NAME_DEPTH; depth += 1) { - if (current.type === 'TSAsExpression' || current.type === 'TSSatisfiesExpression') { - current = current.expression; - continue; - } - if (current.type === 'TSNonNullExpression' || current.type === 'ChainExpression') { - current = current.expression; - continue; - } - if (current.type === 'TSInstantiationExpression') { - current = current.expression; - continue; - } - return current; - } - return current; + if (variable === null || variable.defs.length !== 1) return null; + const definition = variable.defs[0]; + if (definition === undefined || definition.type !== 'Variable') return null; + const declaration = definition.parent; + if (declaration?.type !== 'VariableDeclaration' || declaration.kind !== 'const') return null; + return definition.node.type === 'VariableDeclarator' ? definition.node : null; } /** @@ -288,21 +181,9 @@ function constInitializer( context: Context, identifier: Extract, ): ESTree.Node | null { - const variable = lookupVariable(context, identifier); - if (variable === null || variable.defs.length !== 1) return null; - const definition = variable.defs[0]; - if (definition === undefined || definition.type !== 'Variable') return null; - const declarator = definition.node; - if (declarator.type !== 'VariableDeclarator' || declarator.init === null) return null; - if (declarator.id.type !== 'Identifier') return null; - const declaration = definition.parent; - if ( - declaration === null || - declaration.type !== 'VariableDeclaration' || - declaration.kind !== 'const' - ) { + const declarator = constBindingDeclarator(context, identifier); + if (declarator === null || declarator.init === null || declarator.id.type !== 'Identifier') return null; - } return unwrap(declarator.init); } @@ -335,6 +216,23 @@ function isSchemaNamespace( * The Schema vocabulary factory this callee denotes (`"Literals"`), or `null` when it is not a * tracked Effect `Schema` member. */ +function identifierFactory( + callee: Extract, + context: Context, + locals: SchemaLocals, + factories: readonly string[], + hops: number, +): string | null { + const exported = locals.direct.get(callee.name); + if (exported !== undefined) { + if (!factories.includes(exported)) return null; + return resolvesToImport(context, callee) ? exported : null; + } + if (hops <= 0) return null; + const init = constInitializer(context, callee); + return init === null ? null : factoryOf(init, context, locals, factories, hops - 1); +} + function factoryOf( node: ESTree.Node, context: Context, @@ -343,18 +241,8 @@ function factoryOf( hops: number, ): string | null { const callee = unwrap(node); - if (callee.type === 'Identifier') { - // `Literals([...])` from `import { Literals } from "effect/Schema"`. - const exported = locals.direct.get(callee.name); - if (exported !== undefined) { - if (!factories.includes(exported)) return null; - return resolvesToImport(context, callee) ? exported : null; - } - if (hops <= 0) return null; - // `const Literals = Schema.Literals; Literals([...])`. - const init = constInitializer(context, callee); - return init === null ? null : factoryOf(init, context, locals, factories, hops - 1); - } + if (callee.type === 'Identifier') + return identifierFactory(callee, context, locals, factories, hops); if (callee.type !== 'MemberExpression') return null; const member = memberName(callee); if (member === null || !factories.includes(member)) return null; @@ -375,12 +263,7 @@ function vocabularyFactory( * literal (`` `plan` ``, which is the same member written differently). `null` for anything computed. */ function constantString(node: ESTree.Node): string | null { - const value = unwrap(node); - if (value.type === 'Literal') return typeof value.value === 'string' ? value.value : null; - if (value.type !== 'TemplateLiteral' || value.expressions.length !== 0) return null; - const quasi = value.quasis[0]; - if (quasi === undefined || value.quasis.length !== 1) return null; - return quasi.value.cooked; + return staticString(unwrap(node), { singleQuasi: true, rawTemplates: false }); } /** @@ -417,28 +300,22 @@ function constantVocabulary( return members === null ? null : { name: node.name, members }; } -/** The name this call is bound to (`const PrincipalStatus = Schema.Literals([...])`), else `null`. */ +function bindingOwnerName(current: ESTree.Node, previous: ESTree.Node): string | null { + if (current.type === 'VariableDeclarator') { + if (current.init !== previous) return null; + return current.id.type === 'Identifier' ? current.id.name : null; + } + if (current.type !== 'PropertyDefinition' || current.value !== previous) return null; + return current.key.type === 'Identifier' && !current.computed ? current.key.name : null; +} + +/** The name this call is bound to, through at most the original eight ancestors. */ function boundName(call: ESTree.CallExpression): string | null { let previous: ESTree.Node = call; let current: ESTree.Node | null | undefined = call.parent; for (let depth = 0; depth < MAX_NAME_DEPTH; depth += 1) { if (current === null || current === undefined) return null; - switch (current.type) { - case 'TSAsExpression': - case 'TSSatisfiesExpression': - case 'TSNonNullExpression': - case 'TSInstantiationExpression': - case 'ChainExpression': - break; - case 'VariableDeclarator': - if (current.init !== previous) return null; - return current.id.type === 'Identifier' ? current.id.name : null; - case 'PropertyDefinition': - if (current.value !== previous) return null; - return current.key.type === 'Identifier' && !current.computed ? current.key.name : null; - default: - return null; - } + if (!VOCABULARY_WRAPPERS.has(current.type)) return bindingOwnerName(current, previous); previous = current; current = current.parent; } @@ -473,6 +350,91 @@ function isStrictSubset(inner: readonly string[], outer: readonly string[]): boo return inner.every((member) => set.has(member)); } +function collectVocabulary( + context: Context, + node: ESTree.CallExpression, + argument: ESTree.Node, + minMembers: number, + groups: Map, +): void { + const inline = inlineStringMembers(argument); + const constant = inline === null ? constantVocabulary(context, argument) : null; + const written = inline ?? constant?.members ?? null; + if (written === null) return; + const members = vocabularyKey(written); + if (members.length < minMembers) return; + const key = JSON.stringify(members); + const existing = groups.get(key); + const occurrence: Occurrence = { + node, + name: constant === null ? boundName(node) : constant.name, + line: context.sourceCode.getLoc(node).start.line, + authority: constant !== null, + }; + if (existing === undefined) groups.set(key, { members, occurrences: [occurrence] }); + else existing.occurrences.push(occurrence); +} + +function duplicateMessage(canonical: Occurrence) { + if (canonical.authority) return 'duplicateOfConstant'; + return canonical.name === null ? 'duplicateAnonymous' : 'duplicateOfNamed'; +} + +function reportDuplicateGroup( + context: Context, + group: Group, + reported: Set, +): void { + // Calls built from a shared constant are authorities, never copies to report. + const copies = group.occurrences.filter((occurrence) => !occurrence.authority); + if (copies.length === 0) return; + const authority = group.occurrences.find((occurrence) => occurrence.authority); + const named = copies.find((occurrence) => occurrence.name !== null); + const canonical = authority ?? named ?? copies[0]; + if (canonical === undefined) return; + const members = formatMembers(group.members); + for (const occurrence of copies) { + if (occurrence === canonical) continue; + reported.add(occurrence.node); + context.report({ + node: occurrence.node, + messageId: duplicateMessage(canonical), + data: { + members, + memberList: members.replaceAll(' | ', ', '), + count: String(group.occurrences.length), + owner: canonical.name ?? 'the first declaration', + ownerLine: String(canonical.line), + }, + }); + } +} + +function reportSubsetGroup( + context: Context, + group: Group, + all: readonly Group[], + reported: Set, +): void { + const superset = all.find((other) => isStrictSubset(group.members, other.members)); + if (superset === undefined) return; + const owner = superset.occurrences[0]; + if (owner === undefined) return; + for (const occurrence of group.occurrences) { + if (occurrence.authority || reported.has(occurrence.node)) continue; + reported.add(occurrence.node); + context.report({ + node: occurrence.node, + messageId: 'subsetVocabulary', + data: { + members: formatMembers(group.members), + supersetMembers: formatMembers(superset.members), + ownerLine: String(owner.line), + }, + }); + } +} + export const rule = defineRule({ meta: { type: 'problem', @@ -576,22 +538,7 @@ export const rule = defineRule({ const argument = node.arguments[0]; if (argument === undefined || argument.type === 'SpreadElement') return; if (vocabularyFactory(node, context, schemaLocals, resolved.factories) === null) return; - const inline = inlineStringMembers(argument); - const constant = inline === null ? constantVocabulary(context, argument) : null; - const written = inline ?? constant?.members ?? null; - if (written === null) return; - const members = vocabularyKey(written); - if (members.length < resolved.minMembers) return; - const key = JSON.stringify(members); - const existing = groups.get(key); - const occurrence: Occurrence = { - node, - name: constant === null ? boundName(node) : constant.name, - line: context.sourceCode.getLoc(node).start.line, - authority: constant !== null, - }; - if (existing === undefined) groups.set(key, { members, occurrences: [occurrence] }); - else existing.occurrences.push(occurrence); + collectVocabulary(context, node, argument, resolved.minMembers, groups); }, 'Program:exit'() { @@ -600,58 +547,9 @@ export const rule = defineRule({ const all = [...groups.values()]; const reported = new Set(); - for (const group of all) { - // Calls built from a shared constant are authorities, never copies to report. - const copies = group.occurrences.filter((occurrence) => !occurrence.authority); - if (copies.length === 0) continue; - const authority = group.occurrences.find((occurrence) => occurrence.authority); - const named = copies.find((occurrence) => occurrence.name !== null); - const canonical = authority ?? named ?? copies[0]; - if (canonical === undefined) continue; - const members = formatMembers(group.members); - for (const occurrence of copies) { - if (occurrence === canonical) continue; - reported.add(occurrence.node); - context.report({ - node: occurrence.node, - messageId: - authority !== undefined - ? 'duplicateOfConstant' - : canonical.name === null - ? 'duplicateAnonymous' - : 'duplicateOfNamed', - data: { - members, - memberList: members.replaceAll(' | ', ', '), - count: String(group.occurrences.length), - owner: canonical.name ?? 'the first declaration', - ownerLine: String(canonical.line), - }, - }); - } - } - + for (const group of all) reportDuplicateGroup(context, group, reported); if (!resolved.reportSubsets) return; - for (const group of all) { - const superset = all.find((other) => isStrictSubset(group.members, other.members)); - if (superset === undefined) continue; - const owner = superset.occurrences[0]; - if (owner === undefined) continue; - for (const occurrence of group.occurrences) { - if (occurrence.authority) continue; - if (reported.has(occurrence.node)) continue; - reported.add(occurrence.node); - context.report({ - node: occurrence.node, - messageId: 'subsetVocabulary', - data: { - members: formatMembers(group.members), - supersetMembers: formatMembers(superset.members), - ownerLine: String(owner.line), - }, - }); - } - } + for (const group of all) reportSubsetGroup(context, group, all, reported); }, }; }, diff --git a/app/tools/oxlint/effect-native/rules/no-effect-provide-in-library.ts b/app/tools/oxlint/effect-native/rules/no-effect-provide-in-library.ts index 909013168..06dcf2730 100644 --- a/app/tools/oxlint/effect-native/rules/no-effect-provide-in-library.ts +++ b/app/tools/oxlint/effect-native/rules/no-effect-provide-in-library.ts @@ -53,8 +53,19 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree } from '@oxlint/plugins'; +import { + unwrapNode as unwrap, + memberName as staticMemberName, + FUNCTION_TYPES, +} from '../shared/ast.ts'; +import { lookupVariable, resolvesToImport } from '../shared/bindings.ts'; +import { collectNamedImports, collectRootNamespaces } from '../shared/imports.ts'; +import { + isInTypePosition as inTypePosition, + isNonReferencePosition as nonReferencePosition, +} from '../shared/reference-positions.ts'; import { bindingsFor } from '../shared/effect-imports.ts'; import type { EffectBindings } from '../shared/effect-imports.ts'; import { isTestFile, matchesAny } from '../shared/paths.ts'; @@ -121,56 +132,40 @@ function resolveOptions(context: Context): ResolvedOptions { }; } -function importedName(specifier: ESTree.ImportSpecifier): string { - return specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; -} +const RUN_MEMBER = /^run(?:Promise|Sync|Fork|Callback)(?:Exit)?$/u; -/** - * Collect every local that can reach `Effect.provide*`. The shared collector already handles named and - * submodule-namespace imports; the root barrel (`import * as X from "effect"`) and direct member - * imports (`import { provide } from "effect/Effect"`) are collected here, exactly like the sibling - * rules `no-runtime-construction-outside-root` and `no-layer-or-die-outside-root` do. - */ +/** Preserve root/submodule filtering and run-before-provide-before-pipe priority. */ function collectProvideBindings( program: ESTree.Program, bindings: EffectBindings, members: ReadonlySet, ): ProvideBindings { - const namespaces = new Set(); - const barrels = new Set(); - const directMembers = new Map(); - const pipes = new Set(); - const directRuns = new Set(); - for (const [local, namespace] of bindings.namespaces) { - if (namespace === EFFECT_NAMESPACE) namespaces.add(local); - else if (namespace === PIPE_NAMESPACE) pipes.add(local); + const isRoot = (source: string) => source === EFFECT_ROOT_MODULE; + const isSubmodule = (source: string) => EFFECT_EFFECT_MODULE.test(source); + const policy = { valueOnly: true }; + const namespaces = new Set( + [...bindings.namespaces] + .filter(([, name]) => name === EFFECT_NAMESPACE) + .map(([local]) => local), + ); + const pipes = new Set( + [...bindings.namespaces].filter(([, name]) => name === PIPE_NAMESPACE).map(([local]) => local), + ); + const barrels = collectRootNamespaces(program, isRoot, policy); + for (const local of collectRootNamespaces(program, isSubmodule, policy)) namespaces.add(local); + for (const [local, name] of collectNamedImports(program, isRoot, undefined, policy)) { + if (name === EFFECT_NAMESPACE) namespaces.add(local); + else if (name === PIPE_NAMESPACE) pipes.add(local); } - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - if (statement.importKind === 'type') continue; - const source = statement.source.value; - const isRoot = source === EFFECT_ROOT_MODULE; - const isEffectSubmodule = EFFECT_EFFECT_MODULE.test(source); - if (!isRoot && !isEffectSubmodule) continue; - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportNamespaceSpecifier') { - if (isRoot) barrels.add(specifier.local.name); - else namespaces.add(specifier.local.name); - continue; - } - if (specifier.type !== 'ImportSpecifier') continue; - if (specifier.importKind === 'type') continue; - const imported = importedName(specifier); - if (isRoot && imported === EFFECT_NAMESPACE) namespaces.add(specifier.local.name); - else if (isRoot && imported === PIPE_NAMESPACE) pipes.add(specifier.local.name); - else if (isEffectSubmodule && /^run(?:Promise|Sync|Fork|Callback)(?:Exit)?$/u.test(imported)) - directRuns.add(specifier.local.name); - else if (isEffectSubmodule && members.has(imported)) - directMembers.set(specifier.local.name, imported); - else if (isEffectSubmodule && imported === PIPE_NAMESPACE) pipes.add(specifier.local.name); - } + const direct = collectNamedImports(program, isSubmodule, undefined, policy); + const directRuns = new Set( + [...direct].filter(([, name]) => RUN_MEMBER.test(name)).map(([local]) => local), + ); + const directMembers = new Map( + [...direct].filter(([, name]) => !RUN_MEMBER.test(name) && members.has(name)), + ); + for (const [local, name] of direct) { + if (name === PIPE_NAMESPACE && !members.has(name)) pipes.add(local); } return { namespaces, @@ -182,26 +177,6 @@ function collectProvideBindings( }; } -/** Strip the wrappers that sit between a reference and its semantic parent expression. */ -function unwrap(node: ESTree.Node): ESTree.Node { - let current = node; - while ( - current.type === 'ChainExpression' || - current.type === 'TSNonNullExpression' || - current.type === 'TSAsExpression' || - current.type === 'TSSatisfiesExpression' || - current.type === 'TSInstantiationExpression' || - current.type === 'TSTypeAssertion' || - current.type === 'ParenthesizedExpression' - ) { - const inner: ESTree.Node | undefined = (current as unknown as { expression?: ESTree.Node }) - .expression; - if (inner === undefined) return current; - current = inner; - } - return current; -} - /** TS nodes that still contain runtime expressions; every other `TS*` ancestor means a type position. */ const TS_EXPRESSION_NODES = new Set([ 'TSAsExpression', @@ -214,58 +189,12 @@ const TS_EXPRESSION_NODES = new Set([ 'TSTypeAssertion', ]); -/** True when the node only ever appears in an erased type position (`typeof provide`, ...). */ function isInTypePosition(node: ESTree.Node): boolean { - let current: ESTree.Node | null = node.parent; - while (current !== null && current.type !== 'Program') { - if (current.type.startsWith('TS') && !TS_EXPRESSION_NODES.has(current.type)) return true; - current = current.parent; - } - return false; + return inTypePosition(node, TS_EXPRESSION_NODES); } -/** Non-computed `.provide`, or computed `["provide"]` / `` [`provide`] ``. */ function memberName(node: ESTree.MemberExpression): string | null { - const property = node.property; - if (!node.computed) return property.type === 'Identifier' ? property.name : null; - if (property.type === 'Literal' && typeof property.value === 'string') return property.value; - if ( - property.type === 'TemplateLiteral' && - property.expressions.length === 0 && - property.quasis.length === 1 - ) { - const quasi = property.quasis[0]; - return quasi === undefined ? null : (quasi.value.cooked ?? quasi.value.raw); - } - return null; -} - -function lookupVariable( - context: Context, - identifier: Extract, -): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(identifier); - while (scope !== null) { - const variable = scope.set.get(identifier.name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; -} - -/** - * `true` when the identifier still resolves to an `import` binding. Unresolved names fall back to - * `true` because the module-level import declaration already proved the binding exists; only a local - * shadow (parameter, `const`, catch clause, …) rejects the match. - */ -function resolvesToImport( - context: Context, - identifier: Extract, -): boolean { - const variable = lookupVariable(context, identifier); - if (variable === null) return true; - if (variable.defs.length === 0) return true; - return variable.defs.some((definition) => definition.type === 'ImportBinding'); + return staticMemberName(node, { templates: true, rawTemplates: true, singleQuasi: true }); } /** @@ -342,29 +271,35 @@ function isRunReference(context: Context, node: ESTree.Node, bindings: ProvideBi return member !== null && /^run(?:Promise|Sync|Fork|Callback)(?:Exit)?$/u.test(member); } -/** Recognise only an immediately invoked wrapper or a single top-level invocation of a module function. */ -function isEntryFunction(context: Context, fn: ESTree.Node): boolean { - if ( - fn.type !== 'FunctionDeclaration' && - fn.type !== 'FunctionExpression' && - fn.type !== 'ArrowFunctionExpression' - ) - return false; - let parent: ESTree.Node | null = fn.parent; - if (parent?.type === 'CallExpression' && parent.callee === fn) { - for (let at: ESTree.Node | null = parent.parent; at !== null; at = at.parent) - if (isFunctionLikeBoundary(at)) return false; - return true; - } +function isModuleEvaluation(node: ESTree.Node): boolean { + for (let at: ESTree.Node | null = node.parent; at !== null; at = at.parent) + if (isFunctionLikeBoundary(at)) return false; + return true; +} + +function isProgramParent(parent: ESTree.Node | null): boolean { + if (parent?.type === 'ExportNamedDeclaration' || parent?.type === 'ExportDefaultDeclaration') + parent = parent.parent; + return parent?.type === 'Program'; +} + +function entryFunctionIdentifier( + fn: ESTree.Node, +): Extract | null { + let parent = fn.parent; let id: Extract | null = null; if (fn.type === 'FunctionDeclaration') id = fn.id; else if (parent?.type === 'VariableDeclarator' && parent.id.type === 'Identifier') { id = parent.id; parent = parent.parent?.parent ?? null; } - if (parent?.type === 'ExportNamedDeclaration' || parent?.type === 'ExportDefaultDeclaration') - parent = parent.parent; - if (id === null || parent?.type !== 'Program') return false; + return isProgramParent(parent) ? id : null; +} + +function isSingleModuleCall( + context: Context, + id: Extract, +): boolean { const variable = lookupVariable(context, id); if (variable === null) return false; const reads = variable.references.filter( @@ -376,10 +311,16 @@ function isEntryFunction(context: Context, fn: ESTree.Node): boolean { if (reads.length !== 1) return false; const reference = reads[0]?.identifier; const call = reference?.parent; - if (call?.type !== 'CallExpression' || call.callee !== reference) return false; - for (let at: ESTree.Node | null = call.parent; at !== null; at = at.parent) - if (isFunctionLikeBoundary(at)) return false; - return true; + return call?.type === 'CallExpression' && call.callee === reference && isModuleEvaluation(call); +} + +/** Recognise only an immediately invoked wrapper or a single top-level invocation. */ +function isEntryFunction(context: Context, fn: ESTree.Node): boolean { + if (!FUNCTION_TYPES.has(fn.type)) return false; + const parent = fn.parent; + if (parent?.type === 'CallExpression' && parent.callee === fn) return isModuleEvaluation(parent); + const id = entryFunctionIdentifier(fn); + return id !== null && isSingleModuleCall(context, id); } /** @@ -393,6 +334,73 @@ function isEntryFunction(context: Context, fn: ESTree.Node): boolean { * laundered through another combinator (`Effect.runSync(Effect.succeed(p.pipe(Effect.provide(L))))`) * escapes the run as a pre-provided library value and is still reported. */ +interface PipelineState { + inPipeline: boolean; + sawRunSeam: boolean; +} + +function aliasRead(context: Context, node: ESTree.VariableDeclarator): ESTree.Node | null { + if (node.id.type !== 'Identifier') return null; + const variable = lookupVariable(context, node.id); + const reads = variable?.references.filter((ref) => ref.isRead()) ?? []; + if (reads.length !== 1 || variable?.references.some((ref) => ref.isWrite() && !ref.init)) + return null; + return reads[0]!.identifier; +} + +function pipelineAlias( + current: ESTree.Node, + child: ESTree.Node, + state: PipelineState, +): current is ESTree.VariableDeclarator { + return ( + state.inPipeline && + !state.sawRunSeam && + current.type === 'VariableDeclarator' && + current.init === child && + current.id.type === 'Identifier' + ); +} + +function hasTerminalRun( + context: Context, + call: ESTree.CallExpression, + bindings: ProvideBindings, +): boolean { + const terminal = call.arguments.at(-1); + return terminal !== undefined && isRunReference(context, terminal, bindings); +} + +function visitPipelineCall( + context: Context, + call: ESTree.CallExpression, + child: ESTree.Node, + bindings: ProvideBindings, + state: PipelineState, +): void { + const pipe = isPipeCall(context, call, bindings); + if (state.inPipeline && pipe && hasTerminalRun(context, call, bindings)) state.sawRunSeam = true; + if (Object.is(unwrap(call.callee), child) || Object.is(call.callee, child)) return; + if (state.inPipeline && !state.sawRunSeam && isRunReference(context, call.callee, bindings)) + state.sawRunSeam = true; + else if (!pipe) state.inPipeline = false; +} + +function visitPipelineNode( + context: Context, + current: ESTree.Node, + child: ESTree.Node, + bindings: ProvideBindings, + state: PipelineState, +): void { + if (current.type === 'CallExpression') { + visitPipelineCall(context, current, child, bindings, state); + } else if (current.type === 'MemberExpression') { + const isObject = Object.is(current.object, child) || Object.is(unwrap(current.object), child); + if (!isObject || memberName(current) !== 'pipe') state.inPipeline = false; + } else if (!preservesPipeline(current)) state.inPipeline = false; +} + function isOuterRunSeam( context: Context, node: ESTree.Node, @@ -400,77 +408,37 @@ function isOuterRunSeam( hops = 0, ): boolean { if (hops > 8) return false; - let child: ESTree.Node = node; - let current: ESTree.Node | null = node.parent; - let inPipeline = true; - let sawRunSeam = false; - while (current !== null) { + let child = node; + const state: PipelineState = { inPipeline: true, sawRunSeam: false }; + for (let current = node.parent; current !== null; current = current.parent) { if (isFunctionLikeBoundary(current) && !isEntryFunction(context, current)) return false; - if (current.type === 'Program') return sawRunSeam; - if ( - inPipeline && - !sawRunSeam && - current.type === 'VariableDeclarator' && - current.init === child && - current.id.type === 'Identifier' - ) { + if (current.type === 'Program') return state.sawRunSeam; + if (pipelineAlias(current, child, state)) { if (current.parent?.parent?.type === 'ExportNamedDeclaration') return false; - const variable = lookupVariable(context, current.id); - const reads = variable?.references.filter((ref) => ref.isRead()) ?? []; - // An escaping/mutated pre-provided library value is not a process seam. - if (reads.length === 1 && !variable?.references.some((ref) => ref.isWrite() && !ref.init)) { - return isOuterRunSeam(context, reads[0]!.identifier, bindings, hops + 1); - } - } - if (current.type === 'CallExpression') { - if (inPipeline && isPipeCall(context, current, bindings)) { - const terminal = current.arguments.at(-1); - if (terminal !== undefined && isRunReference(context, terminal, bindings)) - sawRunSeam = true; - } - const isCalleePosition = - Object.is(unwrap(current.callee), child) || Object.is(current.callee, child); - if (!isCalleePosition) { - if (inPipeline && !sawRunSeam && isRunReference(context, current.callee, bindings)) - sawRunSeam = true; - else if (!isPipeCall(context, current, bindings)) inPipeline = false; - } - } else if (current.type === 'MemberExpression') { - // `pipe(program, Effect.provide(L)).pipe(...)`: the object of a `.pipe` member stays in the pipeline. - const isObjectPosition = - Object.is(current.object, child) || Object.is(unwrap(current.object), child); - if (!isObjectPosition || memberName(current) !== 'pipe') inPipeline = false; - } else if (!preservesPipeline(current)) { - inPipeline = false; + const read = aliasRead(context, current); + if (read !== null) return isOuterRunSeam(context, read, bindings, hops + 1); } + visitPipelineNode(context, current, child, bindings, state); child = current; - current = current.parent; } - return sawRunSeam; + return state.sawRunSeam; } -/** Identifier positions that are declarations or property keys, never a value reference. */ -function isNonReferencePosition(node: Extract): boolean { - const parent = node.parent; - if (parent === null || parent === undefined) return true; - if ( - parent.type === 'ImportSpecifier' || - parent.type === 'ImportDefaultSpecifier' || - parent.type === 'ImportNamespaceSpecifier' || - parent.type === 'ExportSpecifier' - ) { - return true; - } - if (parent.type === 'MemberExpression' && Object.is(parent.property, node) && !parent.computed) - return true; - if (parent.type === 'Property' && Object.is(parent.key, node) && !parent.computed) return true; - if (parent.type === 'PropertyDefinition' && Object.is(parent.key, node) && !parent.computed) - return true; - if (parent.type === 'MethodDefinition' && Object.is(parent.key, node) && !parent.computed) - return true; - if (parent.type === 'AccessorProperty' && Object.is(parent.key, node) && !parent.computed) - return true; - return false; +const REFERENCE_KEYS = new Set([ + 'Property', + 'PropertyDefinition', + 'MethodDefinition', + 'AccessorProperty', +]); +function isRuntimeImportReference( + context: Context, + node: Extract, +): boolean { + return ( + !nonReferencePosition(node, { keyParents: REFERENCE_KEYS }) && + !isInTypePosition(node) && + resolvesToImport(context, node) + ); } /** S1/A1: keep `Effect.provide*` at the composition root so every program's `R` stays honest. */ @@ -543,9 +511,7 @@ export const rule = defineRule({ if (resolved === null || imports === null || imports.directMembers.size === 0) return; const member = imports.directMembers.get(node.name); if (member === undefined || !resolved.members.has(member)) return; - if (isNonReferencePosition(node)) return; - if (isInTypePosition(node)) return; - if (!resolvesToImport(context, node)) return; + if (!isRuntimeImportReference(context, node)) return; if (resolved.allowOuterRunSeam && isOuterRunSeam(context, node, imports)) return; report(node, member); }, diff --git a/app/tools/oxlint/effect-native/rules/no-effect-run-in-scripts.ts b/app/tools/oxlint/effect-native/rules/no-effect-run-in-scripts.ts index f5fc6f652..777967e95 100644 --- a/app/tools/oxlint/effect-native/rules/no-effect-run-in-scripts.ts +++ b/app/tools/oxlint/effect-native/rules/no-effect-run-in-scripts.ts @@ -63,7 +63,22 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree } from '@oxlint/plugins'; + +import { + parentOf, + keyName, + unwrapNode, + skipWrappers as sharedSkipWrappers, + walk as walkAst, +} from '../shared/ast.ts'; +import { resolveVariable, isTrackedReference as trackedReference } from '../shared/bindings.ts'; +import { importedName } from '../shared/imports.ts'; +import { + isNonReferencePosition, + isInTypePosition as inTypePosition, +} from '../shared/reference-positions.ts'; +import { nearestFunction, isTopLevel, programLevelFunctionName } from '../shared/script-entry.ts'; import { collectEffectBindings, effectMember } from '../shared/effect-imports.ts'; import type { EffectBindings } from '../shared/effect-imports.ts'; @@ -181,111 +196,33 @@ function readOptions(raw: unknown): RuleOptions { }; } -function parentOf(node: AnyNode): AnyNode | null { - return (node as { parent?: AnyNode | null }).parent ?? null; +/** Preserve this rule's narrower transparent-wrapper policy. */ +function skipWrappers(node: AnyNode) { + return sharedSkipWrappers(node, TRANSPARENT_PARENTS); } - -function isNode(value: unknown): value is AnyNode { - return ( - typeof value === 'object' && - value !== null && - typeof (value as { type?: unknown }).type === 'string' - ); -} - -/** Depth-first walk over the AST, skipping the circular `parent` links. */ -function walk(node: AnyNode, visit: (node: AnyNode) => void): void { - visit(node); - for (const key of Object.keys(node)) { - if (key === 'parent') continue; - const value: unknown = (node as unknown as Record)[key]; - if (Array.isArray(value)) { - for (const entry of value) if (isNode(entry)) walk(entry, visit); - } else if (isNode(value)) walk(value, visit); - } -} - -/** Climb through parentheses/type wrappers; returns the outermost equivalent node and its parent. */ -function skipWrappers(node: AnyNode): { readonly node: AnyNode; readonly parent: AnyNode | null } { - let current = node; - let parent = parentOf(current); - while (parent !== null && TRANSPARENT_PARENTS.has(parent.type)) { - current = parent; - parent = parentOf(current); - } - return { node: current, parent }; -} - -/** Strip parentheses and expression-level TS wrappers, going inwards. */ function unwrapExpression(node: AnyNode): AnyNode { - let current = node; - for (;;) { - if (!TRANSPARENT_PARENTS.has(current.type)) return current; - const inner: unknown = (current as unknown as Record)['expression']; - if (!isNode(inner)) return current; - current = inner; - } + return unwrapNode(node, { wrappers: TRANSPARENT_PARENTS }); } - function isInTypePosition(node: AnyNode): boolean { - let current = parentOf(node); - while (current !== null && current.type !== 'Program') { - if (current.type.startsWith('TS') && !TS_EXPRESSION_NODES.has(current.type)) return true; - current = parentOf(current); - } - return false; -} - -/** Parents where an identifier is a declaration key or module-record name, never a value reference. */ + return inTypePosition(node, TS_EXPRESSION_NODES); +} +const DECLARATION_PARENTS = new Set(['LabeledStatement', 'BreakStatement', 'ContinueStatement']); +const DECLARATION_KEYS = new Set([ + 'Property', + 'PropertyDefinition', + 'MethodDefinition', + 'AccessorProperty', +]); function isDeclarationPosition(node: AnyNode): boolean { - const parent = parentOf(node); - if (parent === null) return false; - switch (parent.type) { - case 'ImportSpecifier': - case 'ImportDefaultSpecifier': - case 'ImportNamespaceSpecifier': - case 'ExportSpecifier': - case 'LabeledStatement': - case 'BreakStatement': - case 'ContinueStatement': - return true; - case 'MemberExpression': - return ( - (parent as ESTree.MemberExpression).property === node && - !(parent as ESTree.MemberExpression).computed - ); - case 'Property': - case 'PropertyDefinition': - case 'MethodDefinition': - case 'AccessorProperty': - return ( - (parent as unknown as { key?: unknown }).key === node && - (parent as unknown as { computed?: boolean }).computed !== true - ); - default: - return false; - } + return isNonReferencePosition(node, { + detached: false, + nonReferenceParents: DECLARATION_PARENTS, + keyParents: DECLARATION_KEYS, + strictComputed: true, + }); } - -/** Static member/property name, including `X["run"]` and the template-literal computed form. */ function staticName(key: AnyNode, computed: boolean): string | null { - if (!computed) { - if (key.type === 'Identifier') return (key as ESTree.IdentifierName).name; - if (key.type === 'Literal' && typeof (key as { value?: unknown }).value === 'string') { - return (key as unknown as { value: string }).value; - } - return null; - } - if (key.type === 'Literal' && typeof (key as { value?: unknown }).value === 'string') { - return (key as unknown as { value: string }).value; - } - if (key.type === 'TemplateLiteral') { - const template = key as ESTree.TemplateLiteral; - if (template.expressions.length !== 0 || template.quasis.length !== 1) return null; - const quasi = template.quasis[0]; - return quasi === undefined ? null : (quasi.value.cooked ?? quasi.value.raw); - } - return null; + return keyName(key, computed, { templates: computed, rawTemplates: true, singleQuasi: true }); } function sameSpan(left: AnyNode, right: AnyNode): boolean { @@ -295,33 +232,8 @@ function sameSpan(left: AnyNode, right: AnyNode): boolean { ); } -function resolveVariable(context: Context, name: string, from: AnyNode): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(from); - while (scope !== null) { - const variable = scope.set.get(name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; -} - -/** - * `true` when `identifier` really resolves to the tracked `import`/alias declaration, so a shadowing - * parameter, local or destructuring key of the same name never reports. An identifier oxlint cannot - * resolve is trusted (its scope analysis does not model every TS construct), keeping the rule strict. - */ function isTrackedReference(context: Context, identifier: AnyNode, declaration: AnyNode): boolean { - if (identifier.type !== 'Identifier') return false; - const variable = resolveVariable( - context, - (identifier as ESTree.IdentifierReference).name, - identifier, - ); - if (variable === null) return true; - if (variable.defs.length === 0) return false; - return variable.defs.some((definition) => - sameSpan(definition.name as unknown as AnyNode, declaration), - ); + return trackedReference(context, identifier, declaration, sameSpan); } interface NamespaceBinding { @@ -346,184 +258,194 @@ interface RunBindings { readonly tracked: boolean; } -function importedName(specifier: ESTree.ImportSpecifier): string { - return specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; -} - /** * Collect every local that can reach an `Effect.run*` entry point. Type-only imports are skipped: * they are erased, so no reference can start a fiber. */ +interface BindingState { + readonly context: Context; + readonly namespaces: Map; + readonly packages: Map; + readonly runLocals: Map; + readonly effectModules: readonly string[]; +} +function collectNamedRunner( + state: BindingState, + specifier: ESTree.ImportSpecifier, + source: string, +): void { + if (specifier.importKind === 'type') return; + const imported = importedName(specifier); + const local = specifier.local; + const submodule = source.split('/').at(-1) ?? ''; + if (state.effectModules.includes(imported)) { + state.namespaces.set(local.name, { declaration: local, namespace: imported }); + } else if ( + RUN_MEMBER.test(imported) && + (source === 'effect' || state.effectModules.includes(submodule)) + ) { + state.runLocals.set(local.name, { + declaration: local, + member: imported, + namespace: source === 'effect' ? (state.effectModules[0] ?? 'Effect') : submodule, + }); + } +} +function collectRunnerImport(state: BindingState, statement: ESTree.ImportDeclaration): void { + if (statement.importKind === 'type' || !EFFECT_MODULE.test(statement.source.value)) return; + const source = statement.source.value; + const submodule = source.split('/').at(-1) ?? ''; + for (const specifier of statement.specifiers) { + if (specifier.type === 'ImportSpecifier') collectNamedRunner(state, specifier, source); + else if (specifier.type === 'ImportNamespaceSpecifier') { + if (source === 'effect') state.packages.set(specifier.local.name, specifier.local); + else if (state.effectModules.includes(submodule)) + state.namespaces.set(specifier.local.name, { + declaration: specifier.local, + namespace: submodule, + }); + } + } +} function collectRunBindings( context: Context, program: ESTree.Program, effectModules: readonly string[], ): RunBindings { - const namespaces = new Map(); - const packages = new Map(); - const runLocals = new Map(); - + const state: BindingState = { + context, + effectModules, + namespaces: new Map(), + packages: new Map(), + runLocals: new Map(), + }; for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - if ((statement as { importKind?: string }).importKind === 'type') continue; - const source = statement.source.value; - if (!EFFECT_MODULE.test(source)) continue; - const isRoot = source === 'effect'; - const submodule = source.split('/').at(-1) ?? ''; - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportSpecifier') { - if ((specifier as { importKind?: string }).importKind === 'type') continue; - const imported = importedName(specifier); - const local = specifier.local as unknown as AnyNode; - if (effectModules.includes(imported)) { - namespaces.set(specifier.local.name, { declaration: local, namespace: imported }); - } else if (RUN_MEMBER.test(imported) && (isRoot || effectModules.includes(submodule))) { - // `import { runPromise } from "effect/Effect"` — a bare runner with no namespace object. - runLocals.set(specifier.local.name, { - declaration: local, - member: imported, - namespace: isRoot ? (effectModules[0] ?? 'Effect') : submodule, - }); - } - } else if (specifier.type === 'ImportNamespaceSpecifier') { - const local = specifier.local as unknown as AnyNode; - if (isRoot) packages.set(specifier.local.name, local); - else if (effectModules.includes(submodule)) { - namespaces.set(specifier.local.name, { declaration: local, namespace: submodule }); - } - } - } - } - - if (namespaces.size > 0 || packages.size > 0) { - propagateAliases(context, program, namespaces, packages, runLocals, effectModules); + if (statement.type === 'ImportDeclaration') collectRunnerImport(state, statement); } - + if (state.namespaces.size > 0 || state.packages.size > 0) propagateAliases(state, program); return { - namespaces, - packages, - runLocals, - tracked: namespaces.size > 0 || packages.size > 0 || runLocals.size > 0, + namespaces: state.namespaces, + packages: state.packages, + runLocals: state.runLocals, + tracked: state.namespaces.size > 0 || state.packages.size > 0 || state.runLocals.size > 0, }; } - -/** - * Follow `const Fx = Effect;`, `const Fx = Pkg.Effect;`, `const run = Effect.runPromise;` and - * `const { runFork } = Effect;` to a fixed point, so a one-line re-binding cannot defeat the rule. - */ -function propagateAliases( - context: Context, - program: ESTree.Program, - namespaces: Map, - packages: Map, - runLocals: Map, - effectModules: readonly string[], -): void { +type AliasKind = NamespaceBinding | 'package'; +function packageNamespace( + state: BindingState, + member: ESTree.MemberExpression, +): NamespaceBinding | null { + const name = staticName(member.property, member.computed); + if (name === null || !state.effectModules.includes(name)) return null; + const object = unwrapExpression(member.object); + if (object.type !== 'Identifier') return null; + const declaration = state.packages.get(object.name); + return declaration !== undefined && isTrackedReference(state.context, object, declaration) + ? { declaration: object, namespace: name } + : null; +} +function aliasKind(state: BindingState, init: AnyNode): AliasKind | null { + if (init.type === 'MemberExpression') return packageNamespace(state, init); + if (init.type !== 'Identifier') return null; + const known = state.namespaces.get(init.name); + if (known !== undefined && isTrackedReference(state.context, init, known.declaration)) + return known; + const declaration = state.packages.get(init.name); + return declaration !== undefined && isTrackedReference(state.context, init, declaration) + ? 'package' + : null; +} +function addBinding(map: Map, name: string, value: T): boolean { + if (map.has(name)) return false; + map.set(name, value); + return true; +} +function bindAlias( + state: BindingState, + target: ESTree.BindingIdentifier, + kind: AliasKind, +): boolean { + return kind === 'package' + ? addBinding(state.packages, target.name, target) + : addBinding(state.namespaces, target.name, { declaration: target, namespace: kind.namespace }); +} +function bindProperty( + state: BindingState, + property: Extract, + kind: AliasKind, +): boolean { + const key = staticName(property.key, property.computed); + if (key === null) return false; + const value = property.value.type === 'AssignmentPattern' ? property.value.left : property.value; + if (value.type !== 'Identifier') return false; + if (kind === 'package') { + return ( + state.effectModules.includes(key) && + addBinding(state.namespaces, value.name, { declaration: value, namespace: key }) + ); + } + return ( + RUN_MEMBER.test(key) && + addBinding(state.runLocals, value.name, { + declaration: value, + member: key, + namespace: kind.namespace, + }) + ); +} +function runnerAlias( + state: BindingState, + init: AnyNode, +): { member: string; namespace: string } | null { + if (init.type !== 'MemberExpression') return null; + const namespace = namespaceOfObject( + state.context, + init, + state.namespaces, + state.packages, + state.effectModules, + ); + const member = staticName(init.property, init.computed); + return namespace !== null && member !== null && RUN_MEMBER.test(member) + ? { member, namespace } + : null; +} +function propagateDeclarator(state: BindingState, declarator: ESTree.VariableDeclarator): boolean { + if (declarator.init == null) return false; + const init = unwrapExpression(declarator.init); + const target = declarator.id; + const runner = runnerAlias(state, init); + if (runner !== null) { + return ( + target.type === 'Identifier' && + addBinding(state.runLocals, target.name, { declaration: target, ...runner }) + ); + } + const kind = aliasKind(state, init); + if (kind === null) return false; + if (target.type === 'Identifier') return bindAlias(state, target, kind); + if (target.type !== 'ObjectPattern') return false; + let changed = false; + for (const property of target.properties) { + if (property.type === 'Property' && bindProperty(state, property, kind)) changed = true; + } + return changed; +} +/** Bounded fixed point retains declaration order and the original five-pass limit. */ +function propagateAliases(state: BindingState, program: ESTree.Program): void { const declarators: ESTree.VariableDeclarator[] = []; - walk(program as unknown as AnyNode, (node) => { - if (node.type === 'VariableDeclarator' && (node as ESTree.VariableDeclarator).init != null) { - declarators.push(node as ESTree.VariableDeclarator); - } - }); - if (declarators.length === 0) return; - + walkAst( + program, + {}, + (node) => { + if (node.type === 'VariableDeclarator' && node.init != null) declarators.push(node); + }, + false, + ); for (let pass = 0; pass < 5; pass += 1) { let changed = false; for (const declarator of declarators) { - const rawInit = declarator.init; - if (rawInit == null) continue; - const init = unwrapExpression(rawInit as unknown as AnyNode); - const target = declarator.id as unknown as AnyNode; - - // `const run = Effect.runPromise;` / `const run = Fx.Effect["runSync"];` - if (init.type === 'MemberExpression') { - const member = init as ESTree.MemberExpression; - const namespace = namespaceOfObject(context, member, namespaces, packages, effectModules); - const name = staticName(member.property as unknown as AnyNode, member.computed); - if (namespace !== null && name !== null && RUN_MEMBER.test(name)) { - if ( - target.type === 'Identifier' && - !runLocals.has((target as ESTree.BindingIdentifier).name) - ) { - runLocals.set((target as ESTree.BindingIdentifier).name, { - declaration: target, - member: name, - namespace, - }); - changed = true; - } - continue; - } - } - - // `const Fx = Effect;` / `const Fx = Pkg.Effect;` / `const { runFork } = Effect;` - let kind: NamespaceBinding | 'package' | null = null; - if (init.type === 'Identifier') { - const name = (init as ESTree.IdentifierReference).name; - const known = namespaces.get(name); - if (known !== undefined && isTrackedReference(context, init, known.declaration)) - kind = known; - else { - const declaration = packages.get(name); - if (declaration !== undefined && isTrackedReference(context, init, declaration)) - kind = 'package'; - } - } else if (init.type === 'MemberExpression') { - const member = init as ESTree.MemberExpression; - const name = staticName(member.property as unknown as AnyNode, member.computed); - if (name !== null && effectModules.includes(name)) { - const object = unwrapExpression(member.object as unknown as AnyNode); - if (object.type === 'Identifier') { - const declaration = packages.get((object as ESTree.IdentifierReference).name); - if (declaration !== undefined && isTrackedReference(context, object, declaration)) { - kind = { declaration: object, namespace: name }; - } - } - } - } - if (kind === null) continue; - - if (target.type === 'Identifier') { - const name = (target as ESTree.BindingIdentifier).name; - if (kind === 'package') { - if (!packages.has(name)) { - packages.set(name, target); - changed = true; - } - } else if (!namespaces.has(name)) { - namespaces.set(name, { declaration: target, namespace: kind.namespace }); - changed = true; - } - continue; - } - if (target.type !== 'ObjectPattern') continue; - for (const property of (target as ESTree.ObjectPattern).properties) { - if (property.type !== 'Property') continue; - const key = staticName(property.key as unknown as AnyNode, property.computed); - if (key === null) continue; - const rawValue = property.value as unknown as AnyNode; - const value = - rawValue.type === 'AssignmentPattern' - ? ((rawValue as ESTree.AssignmentPattern).left as unknown as AnyNode) - : rawValue; - if (value.type !== 'Identifier') continue; - const local = (value as ESTree.BindingIdentifier).name; - if (kind === 'package') { - // `const { Effect } = Pkg;` - if (effectModules.includes(key) && !namespaces.has(local)) { - namespaces.set(local, { declaration: value, namespace: key }); - changed = true; - } - continue; - } - // `const { runFork } = Effect;` - if (RUN_MEMBER.test(key) && !runLocals.has(local)) { - runLocals.set(local, { declaration: value, member: key, namespace: kind.namespace }); - changed = true; - } - } + if (propagateDeclarator(state, declarator)) changed = true; } if (!changed) return; } @@ -578,6 +500,15 @@ function runMember( if (!isTrackedReference(context, object, tracked.declaration)) return null; return { member, namespace }; } + return packageRunMember(context, object, bindings, effectModules, member); +} +function packageRunMember( + context: Context, + object: AnyNode, + bindings: RunBindings, + effectModules: readonly string[], + member: string, +): { namespace: string; member: string } | null { if (object.type !== 'MemberExpression') return null; // `import * as Fx from "effect"` -> `Fx.Effect.runSync(...)`. const inner = object as ESTree.MemberExpression; @@ -591,54 +522,16 @@ function runMember( return { member, namespace }; } -function nearestFunction(node: AnyNode): AnyNode | null { - let current = parentOf(node); - while (current !== null) { - if (FUNCTION_LIKE.has(current.type)) return current; - current = parentOf(current); - } - return null; -} - -/** `true` when the node is evaluated during module evaluation, not inside any function body. */ -function isTopLevel(node: AnyNode): boolean { - return nearestFunction(node) === null; +/** Return a call only when the wrapped expression is its callee. */ +function invocation(node: AnyNode): ESTree.CallExpression | null { + const wrapped = skipWrappers(node); + return wrapped.parent?.type === 'CallExpression' && wrapped.parent.callee === wrapped.node + ? wrapped.parent + : null; } - -/** A declaration/statement directly in `Program`, optionally behind `export` / `export default`. */ -function isProgramLevelStatement(node: AnyNode): boolean { - const parent = parentOf(node); - if (parent === null) return false; - if (parent.type === 'Program') return true; - if (parent.type !== 'ExportNamedDeclaration' && parent.type !== 'ExportDefaultDeclaration') - return false; - return parentOf(parent)?.type === 'Program'; -} - -/** Name of a Program-level `function main() {}` / `const main = () => {}`, else `null`. */ -function programLevelFunctionName(fn: AnyNode): string | null { - if (fn.type === 'FunctionDeclaration') { - if (!isProgramLevelStatement(fn)) return null; - const id = (fn as ESTree.Function).id; - return id === null || id === undefined ? null : id.name; - } - if (fn.type !== 'FunctionExpression' && fn.type !== 'ArrowFunctionExpression') return null; - const declarator = parentOf(fn); - if (declarator === null || declarator.type !== 'VariableDeclarator') return null; - if ((declarator as ESTree.VariableDeclarator).init !== fn) return null; - const id = (declarator as ESTree.VariableDeclarator).id; - if (id.type !== 'Identifier') return null; - const declaration = parentOf(declarator); - if (declaration === null || declaration.type !== 'VariableDeclaration') return null; - return isProgramLevelStatement(declaration) ? id.name : null; -} - -/** `void (async () => { ... })()` / `(function () { ... })()` evaluated during module evaluation. */ function isTopLevelImmediatelyInvoked(fn: AnyNode): boolean { - const { node, parent } = skipWrappers(fn); - if (parent === null || parent.type !== 'CallExpression') return false; - if ((parent as ESTree.CallExpression).callee !== node) return false; - return isTopLevel(parent); + const call = invocation(fn); + return call !== null && isTopLevel(call); } /** `export { main }` / `export default main` mention the entrypoint without invoking it. */ @@ -666,12 +559,7 @@ function isOnlyCalledFromTopLevel(context: Context, fn: AnyNode, name: string): !isExportReference(reference.identifier as unknown as AnyNode), ); if (uses.length === 0) return false; - return uses.every((reference) => { - const { node, parent } = skipWrappers(reference.identifier as unknown as AnyNode); - if (parent === null || parent.type !== 'CallExpression') return false; - if ((parent as ESTree.CallExpression).callee !== node) return false; - return isTopLevel(parent); - }); + return uses.every((reference) => isTopLevelImmediatelyInvoked(reference.identifier as AnyNode)); } /** @@ -789,9 +677,28 @@ function promiseChainMethod(site: AnyNode): string | null { ); if (method === null || (!PROMISE_CHAIN_METHODS.has(method) && method !== CLEANUP_CHAIN_METHOD)) return null; - const invoked = skipWrappers(member); - if (invoked.parent === null || invoked.parent.type !== 'CallExpression') return null; - return (invoked.parent as ESTree.CallExpression).callee === invoked.node ? method : null; + return invocation(member) !== null ? method : null; +} + +function isAliasInitializer(node: AnyNode): boolean { + const wrapped = skipWrappers(node); + const parent = wrapped.parent; + return ( + parent?.type === 'VariableDeclarator' && + parent.init === wrapped.node && + parent.id.type === 'Identifier' + ); +} +function chargeSlot(charged: Array>, path: readonly Decision[]): number { + const slot = charged.findIndex((alternatives) => + alternatives.every((other) => mutuallyExclusive(other, path)), + ); + if (slot !== -1) { + charged[slot]?.push(path); + return slot; + } + charged.push([path]); + return charged.length - 1; } interface RunSite { @@ -911,24 +818,12 @@ export const rule = defineRule({ if (matched === null) return; const self = node as unknown as AnyNode; if (isInTypePosition(self)) return; - const { node: reference, parent } = skipWrappers(self); - if ( - parent !== null && - parent.type === 'CallExpression' && - (parent as ESTree.CallExpression).callee === reference - ) { - addSite(parent, matched.namespace, matched.member); - return; - } - // `const run = Effect.runPromise;` is a *binding*, not a run: its call sites are the runs. - if ( - parent !== null && - parent.type === 'VariableDeclarator' && - (parent as ESTree.VariableDeclarator).init === reference && - (parent as ESTree.VariableDeclarator).id.type === 'Identifier' - ) { + const call = invocation(self); + if (call !== null) { + addSite(call, matched.namespace, matched.member); return; } + if (isAliasInitializer(self)) return; // A point-free reference (`pipe(program, Effect.runPromise)`) still starts a root fiber. addSite(self, matched.namespace, matched.member); }, @@ -940,16 +835,7 @@ export const rule = defineRule({ if (sameSpan(identifier, tracked.declaration)) return; if (isDeclarationPosition(identifier) || isInTypePosition(identifier)) return; if (!isTrackedReference(context, identifier, tracked.declaration)) return; - const { node: reference, parent } = skipWrappers(identifier); - if ( - parent !== null && - parent.type === 'CallExpression' && - (parent as ESTree.CallExpression).callee === reference - ) { - addSite(parent, tracked.namespace, tracked.member); - return; - } - addSite(identifier, tracked.namespace, tracked.member); + addSite(invocation(identifier) ?? identifier, tracked.namespace, tracked.member); }, 'Program:exit'() { if (sites.length === 0) return; @@ -977,13 +863,7 @@ export const rule = defineRule({ const path = decisionPath(site.node); // A slot may be reused only if this site is exclusive with EVERY alternative // already occupying it, not merely one site in some other branch. - let slot = charged.findIndex((alternatives) => - alternatives.every((other) => mutuallyExclusive(other, path)), - ); - if (slot === -1) { - slot = charged.length; - charged.push([path]); - } else charged[slot]?.push(path); + const slot = chargeSlot(charged, path); if (isInLoop(site.node)) { context.report({ node: site.node, messageId: 'runInLoop', data }); continue; diff --git a/app/tools/oxlint/effect-native/rules/no-effect-run-in-tests.ts b/app/tools/oxlint/effect-native/rules/no-effect-run-in-tests.ts index 230adeace..426024f7a 100644 --- a/app/tools/oxlint/effect-native/rules/no-effect-run-in-tests.ts +++ b/app/tools/oxlint/effect-native/rules/no-effect-run-in-tests.ts @@ -1,3 +1,4 @@ +import { collectNamedImports } from '../shared/imports.ts'; /** * effect-native/no-effect-run-in-tests * @@ -170,21 +171,13 @@ function collectBarrelBindings( program: ESTree.Program, sources: readonly string[], ): Map { - const namespaces = new Map(); const patterns = sources.map(globToRegExp); - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - if (statement.importKind === 'type') continue; - if (!patterns.some((pattern) => pattern.test(statement.source.value))) continue; - for (const specifier of statement.specifiers) { - if (specifier.type !== 'ImportSpecifier') continue; - if (specifier.importKind === 'type') continue; - const imported = moduleExportName(specifier.imported); - if (imported === null) continue; - namespaces.set(specifier.local.name, imported); - } - } - return namespaces; + return collectNamedImports( + program, + (source) => patterns.some((pattern) => pattern.test(source)), + undefined, + { valueOnly: true }, + ); } /** @@ -357,6 +350,14 @@ export const rule = defineRule({ return isRootBarrel(target.object, hops + 1); } if (target.type !== 'Identifier') return false; + if (isImportedEffectNamespace(target)) return true; + const alias = aliasInitialiser(target, target.name); + return alias === null ? false : isEffectNamespace(alias, hops + 1); + } + + function isImportedEffectNamespace( + target: Extract, + ): boolean { const dynamic = dynamicNamespaces.get(target.name); if ( dynamic !== undefined && @@ -372,8 +373,7 @@ export const rule = defineRule({ ) { return true; } - const alias = aliasInitialiser(target, target.name); - return alias === null ? false : isEffectNamespace(alias, hops + 1); + return false; } /** `Effect.runPromise` / `Effect["runPromise"]` / `E?.runSync` → the run member name. */ @@ -398,6 +398,37 @@ export const rule = defineRule({ return staticStringValue(target.source); } + function collectRunProperties(pattern: ESTree.ObjectPattern, sites: RunSite[]): void { + for (const property of pattern.properties) { + if (property.type !== 'Property') continue; + const member = staticKey(property.key, property.computed); + if (member !== null && RUN_MEMBER.test(member)) sites.push({ node: property, member }); + } + } + + function collectRootProperties(pattern: ESTree.ObjectPattern): void { + for (const property of pattern.properties) { + if (property.type !== 'Property') continue; + const key = staticKey(property.key, property.computed); + if (key === null || !options.effectModules.includes(key)) continue; + if (property.value.type !== 'Identifier') continue; + dynamicNamespaces.set(property.value.name, { namespace: key, declaration: property.value }); + } + } + + function collectDynamicBinding(id: ESTree.VariableDeclarator['id'], source: string): void { + const submodule = SUBMODULE_SOURCE.exec(source)?.[1]; + if (submodule !== undefined && options.effectModules.includes(submodule)) { + if (id.type === 'Identifier') + dynamicNamespaces.set(id.name, { namespace: submodule, declaration: id }); + else if (id.type === 'ObjectPattern') collectRunProperties(id, dynamicSites); + return; + } + if (source !== 'effect') return; + if (id.type === 'Identifier') dynamicRootNamespaces.set(id.name, id); + else if (id.type === 'ObjectPattern') collectRootProperties(id); + } + return { Program(node) { const canonical = collectEffectBindings(node); @@ -464,49 +495,13 @@ export const rule = defineRule({ }, VariableDeclarator(node) { - const dynamicSource = dynamicImportSource(node.init); - if (dynamicSource !== null) { - const submodule = SUBMODULE_SOURCE.exec(dynamicSource)?.[1]; - const isRoot = dynamicSource === 'effect'; - if (submodule !== undefined && options.effectModules.includes(submodule)) { - if (node.id.type === 'Identifier') - dynamicNamespaces.set(node.id.name, { namespace: submodule, declaration: node.id }); - else if (node.id.type === 'ObjectPattern') { - for (const property of node.id.properties) { - if (property.type !== 'Property') continue; - const member = staticKey(property.key, property.computed); - if (member === null || !RUN_MEMBER.test(member)) continue; - dynamicSites.push({ node: property, member }); - } - } - return; - } - if (isRoot) { - if (node.id.type === 'Identifier') dynamicRootNamespaces.set(node.id.name, node.id); - else if (node.id.type === 'ObjectPattern') { - for (const property of node.id.properties) { - if (property.type !== 'Property') continue; - const key = staticKey(property.key, property.computed); - if (key === null || !options.effectModules.includes(key)) continue; - if (property.value.type === 'Identifier') - dynamicNamespaces.set(property.value.name, { - namespace: key, - declaration: property.value, - }); - } - } - } - return; - } - if (node.id.type !== 'ObjectPattern' || node.init === null || node.init === undefined) + const source = dynamicImportSource(node.init); + if (source !== null) { + collectDynamicBinding(node.id, source); return; - if (!isEffectNamespace(node.init)) return; - for (const property of node.id.properties) { - if (property.type !== 'Property') continue; - const member = staticKey(property.key, property.computed); - if (member === null || !RUN_MEMBER.test(member)) continue; - referenceSites.push({ node: property, member }); } + if (node.id.type !== 'ObjectPattern' || node.init == null) return; + if (isEffectNamespace(node.init)) collectRunProperties(node.id, referenceSites); }, 'Program:exit'() { diff --git a/app/tools/oxlint/effect-native/rules/no-environment-record-type.ts b/app/tools/oxlint/effect-native/rules/no-environment-record-type.ts index 04466b6cd..8d7e4f7e4 100644 --- a/app/tools/oxlint/effect-native/rules/no-environment-record-type.ts +++ b/app/tools/oxlint/effect-native/rules/no-environment-record-type.ts @@ -1,3 +1,4 @@ +import { snippet } from '../shared/reporting.ts'; /** * effect-native/no-environment-record-type * @@ -74,21 +75,16 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { ESTree, Scope } from '@oxlint/plugins'; +import type { ESTree, Variable } from '@oxlint/plugins'; import { collectEffectBindings } from '../shared/effect-imports.ts'; -import { isTestFile, matchesAny, normalisePath } from '../shared/paths.ts'; +import { includesRuleFile } from '../shared/paths.ts'; +import { parentOf } from '../shared/ast.ts'; +import { resolveVariable } from '../shared/bindings.ts'; +import { stringList } from '../shared/options.ts'; type AnyNode = ESTree.Node; -/** Normalize real paths and remove only the fixture prefix, preserving nested workspace directories. */ -function workspacePath(filename: string): string { - return normalisePath(filename).replace( - /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u, - '', - ); -} - const DEFAULT_INCLUDE_PATHS: readonly string[] = [ 'apps/**', 'verticals/**', @@ -125,12 +121,6 @@ const DEFAULTS: RuleOptions = { includePaths: [...DEFAULT_INCLUDE_PATHS], }; -function stringList(value: unknown, fallback: readonly string[]): readonly string[] { - return Array.isArray(value) && value.every((entry) => typeof entry === 'string') - ? (value as readonly string[]) - : fallback; -} - function readOptions(raw: unknown): RuleOptions { const given = (raw ?? {}) as Partial>; const includePaths = stringList(given.includePaths, DEFAULTS.includePaths); @@ -142,10 +132,6 @@ function readOptions(raw: unknown): RuleOptions { }; } -function parentOf(node: AnyNode): AnyNode | null { - return (node as { parent?: AnyNode | null }).parent ?? null; -} - /** `(A | B)` → `A | B`; every other node is returned unchanged. */ function unwrapParens(node: AnyNode): AnyNode { let current = node; @@ -229,18 +215,86 @@ function isInsidePartial(node: AnyNode): boolean { return false; } -/** - * `typeof process.env` / `typeof globalThis.process.env` / `typeof Deno.env` → `true`. - * The chain is flattened, leading container globals are stripped, and the remainder must be - * `.env`. - */ -function isAmbientEnvQueryName(node: AnyNode): boolean { - const dotted = dottedTypeName(node); - if (dotted === null) return false; - const segments = dotted.split('.'); - while (segments.length > 2 && CONTAINER_GLOBALS.has(segments[0] ?? '')) segments.shift(); - if (segments.length !== 2) return false; - return ENV_HOSTS.has(segments[0] ?? '') && segments[1] === 'env'; +/** Return only an unambiguous import binding; local declarations remain shadowing. */ +function singleImport(variable: Variable) { + const definition = variable.defs.length === 1 ? variable.defs[0] : undefined; + return definition?.type === 'ImportBinding' ? definition.node : null; +} + +function importedRecordName( + variable: Variable, + imported: string | undefined, + rest: readonly string[], +): string | null { + const specifier = singleImport(variable); + if (!specifier) return null; + const declaration = parentOf(specifier) as ESTree.ImportDeclaration | null; + if (!declaration || !['effect', 'effect/Record'].includes(declaration.source.value)) return null; + if ( + declaration.source.value === 'effect/Record' && + specifier.type === 'ImportSpecifier' && + imported === 'ReadonlyRecord' && + rest.length === 0 + ) + return 'ReadonlyRecord'; + return imported === 'Record' && rest.join('.') === 'ReadonlyRecord' + ? 'Record.ReadonlyRecord' + : null; +} + +function isImportedEnvQuery(variable: Variable, segments: readonly string[]): boolean { + const specifier = singleImport(variable); + if (!specifier) return false; + const declaration = parentOf(specifier) as ESTree.ImportDeclaration | null; + return ( + declaration?.type === 'ImportDeclaration' && + PROCESS_MODULES.has(declaration.source.value) && + segments.length === 2 && + segments[1] === 'env' && + ['ImportDefaultSpecifier', 'ImportNamespaceSpecifier'].includes(specifier.type) + ); +} + +function isGlobalEnvQuery(segments: readonly string[]): boolean { + if (segments.length === 2) return ENV_HOSTS.has(segments[0]) && segments[1] === 'env'; + return ( + segments.length === 3 && + CONTAINER_GLOBALS.has(segments[0]) && + ENV_HOSTS.has(segments[1]) && + segments[2] === 'env' + ); +} + +function isWithinConstraint(node: AnyNode): boolean { + let ancestor = parentOf(node); + while (ancestor && ancestor.type !== 'Program') { + if ( + ancestor.type === 'TSTypeParameter' && + ancestor.constraint && + node.start >= ancestor.constraint.start && + node.end <= ancestor.constraint.end + ) + return true; + ancestor = parentOf(ancestor); + } + return false; +} + +function isStringValue(node: AnyNode | undefined): boolean { + return ( + node !== undefined && + (unwrapParens(node).type === 'TSStringKeyword' || isOptionalStringUnion(node)) + ); +} + +function isEnvironmentRecord(node: AnyNode): boolean { + const args = typeArgumentsOf(node); + if (args.length !== 2 || unwrapParens(args[0]).type !== 'TSStringKeyword') return false; + const value = args[1]; + return ( + isOptionalStringUnion(value) || + (unwrapParens(value).type === 'TSStringKeyword' && isInsidePartial(node)) + ); } /** Effect-native rule: configuration is a Schema decoded through Config and injected as a service. */ @@ -293,15 +347,10 @@ export const rule = defineRule({ }, create(context) { const options = readOptions(context.options[0]); - const path = workspacePath(context.filename); - if (!matchesAny(`/${path}`, options.includePaths)) return {}; - if (matchesAny(`/${path}`, options.allowPaths)) return {}; - if (options.ignoreTestFiles && isTestFile(`/${path}`)) return {}; - - const printed = (node: AnyNode): string => { - const text = context.sourceCode.getText(node).replace(/\s+/gu, ' ').trim(); - return text.length > 80 ? `${text.slice(0, 77)}...` : text; - }; + if (!includesRuleFile(context.filename, options)) return {}; + + const printed = (node: AnyNode): string => + snippet(context.sourceCode.getText(node), 80, 77, '...'); const report = (node: AnyNode, messageId: string): void => { context.report({ @@ -312,118 +361,35 @@ export const rule = defineRule({ }; const bindings = collectEffectBindings(context.sourceCode.ast); - const lookup = (name: string, node: AnyNode) => { - for ( - let scope: Scope | null = context.sourceCode.getScope(node); - scope; - scope = scope.upper - ) { - const variable = scope.set.get(name); - if (variable) return variable; - } - return null; - }; // Import identity, not the local spelling, distinguishes Effect.Record from domain lookalikes. const canonicalName = (node: AnyNode): string | null => { const name = typeReferenceName(node); if (!name) return null; const [root, ...rest] = name.split('.'); - const variable = lookup(root, node); + const variable = resolveVariable(context, root, node); if (!variable || variable.defs.length === 0) return name; - const definition = variable.defs.length === 1 ? variable.defs[0] : undefined; - if (definition?.type !== 'ImportBinding') return null; - const specifier = definition.node as ESTree.ImportDeclaration['specifiers'][number]; - const declaration = parentOf(specifier as AnyNode) as ESTree.ImportDeclaration; - if (!declaration || !['effect', 'effect/Record'].includes(declaration.source.value)) - return null; - const imported = bindings.namespaces.get(root); - if ( - declaration.source.value === 'effect/Record' && - specifier.type === 'ImportSpecifier' && - imported === 'ReadonlyRecord' && - rest.length === 0 - ) - return 'ReadonlyRecord'; - return imported === 'Record' && rest.join('.') === 'ReadonlyRecord' - ? 'Record.ReadonlyRecord' - : null; + return importedRecordName(variable, bindings.namespaces.get(root), rest); }; const isAmbientQuery = (expression: AnyNode, indexed = false): boolean => { const name = dottedTypeName(expression); if (!name) return false; - const full = indexed ? `${name}.env` : name; - const segments = full.split('.'); - const root = segments[0]; - const variable = lookup(root, expression); - if (variable && variable.defs.length > 0) { - const definition = variable.defs.length === 1 ? variable.defs[0] : undefined; - if (definition?.type !== 'ImportBinding') return false; - const specifier = definition.node as ESTree.ImportDeclaration['specifiers'][number]; - const declaration = parentOf(specifier as AnyNode) as ESTree.ImportDeclaration; - return ( - declaration?.type === 'ImportDeclaration' && - PROCESS_MODULES.has(declaration.source.value) && - segments.length === 2 && - segments[1] === 'env' && - (specifier.type === 'ImportDefaultSpecifier' || - specifier.type === 'ImportNamespaceSpecifier') - ); - } - return ( - (segments.length === 2 && ENV_HOSTS.has(root) && segments[1] === 'env') || - (segments.length === 3 && - CONTAINER_GLOBALS.has(root) && - ENV_HOSTS.has(segments[1]) && - segments[2] === 'env') - ); + const segments = (indexed ? `${name}.env` : name).split('.'); + const variable = resolveVariable(context, segments[0], expression); + return variable && variable.defs.length > 0 + ? isImportedEnvQuery(variable, segments) + : isGlobalEnvQuery(segments); }; const inspectReference = (node: AnyNode): void => { // A generic utility constraint is not a declaration of configuration authority. - let ancestor = parentOf(node); - while (ancestor && ancestor.type !== 'Program') { - if ( - ancestor.type === 'TSTypeParameter' && - ancestor.constraint && - node.start >= ancestor.constraint.start && - node.end <= ancestor.constraint.end - ) - return; - ancestor = parentOf(ancestor); - } + if (isWithinConstraint(node)) return; const name = canonicalName(node); if (name === null) return; - if (NODEJS_ENV_TYPES.has(name)) { - // `NodeJS.Dict` is only the environment shape when `T` is `string`. - if (name === 'NodeJS.Dict') { - const argument = typeArgumentsOf(node as unknown as AnyNode)[0]; - if ( - argument === undefined || - (unwrapParens(argument).type !== 'TSStringKeyword' && !isOptionalStringUnion(argument)) - ) - return; - } - report(node as unknown as AnyNode, 'processEnvType'); + if (name === 'NodeJS.Dict' && !isStringValue(typeArgumentsOf(node)[0])) return; + report(node, 'processEnvType'); return; } - - if (!RECORD_NAMES.has(name)) return; - const args = typeArgumentsOf(node as unknown as AnyNode); - if (args.length !== 2) return; - const key = unwrapParens(args[0] as AnyNode); - if (key.type !== 'TSStringKeyword') return; - const value = args[1] as AnyNode; - if (isOptionalStringUnion(value)) { - report(node as unknown as AnyNode, 'environmentRecord'); - return; - } - // `Partial>` *is* `Record`. - if ( - unwrapParens(value).type === 'TSStringKeyword' && - isInsidePartial(node as unknown as AnyNode) - ) { - report(node as unknown as AnyNode, 'environmentRecord'); - } + if (RECORD_NAMES.has(name) && isEnvironmentRecord(node)) report(node, 'environmentRecord'); }; return { TSTypeReference: inspectReference, diff --git a/app/tools/oxlint/effect-native/rules/no-failure-discarding-error-callback.ts b/app/tools/oxlint/effect-native/rules/no-failure-discarding-error-callback.ts index fc459c716..b3cf7a2e9 100644 --- a/app/tools/oxlint/effect-native/rules/no-failure-discarding-error-callback.ts +++ b/app/tools/oxlint/effect-native/rules/no-failure-discarding-error-callback.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * Audit findings: **A4** — "Rebuild the error system around typed channels and contract-owned Problem * Details" ("`Effect.mapError(() => oneGenericError)` discarding original failures", "Preserve original @@ -52,26 +53,20 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree, Variable } from '@oxlint/plugins'; -import { - collectEffectBindings, - effectMember, - type EffectBindings, -} from '../shared/effect-imports.ts'; -import { globToRegExp, isScriptFile, isTestFile, normalisePath } from '../shared/paths.ts'; +import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; +import { effectOrigin } from '../shared/effect-identity.ts'; +import { isNode, keyName, memberName, EXPRESSION_WRAPPERS, skipWrappers } from '../shared/ast.ts'; +import { lookupVariable } from '../shared/bindings.ts'; +import { collectNamedImports, collectRootNamespaces } from '../shared/imports.ts'; +import { stringArray } from '../shared/options.ts'; +import { isScriptFile, isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; const EFFECT_NAMESPACE = 'Effect'; const EFFECT_ROOT_MODULE = 'effect'; const EFFECT_SUBMODULE = /^effect\/(?:.*\/)?Effect$/u; -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production `include`/`ignore` defaults instead - * of forcing the fixture config to pass loosened options (which `run-on-repo.mts` reuses). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; - const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**']; const DEFAULT_IGNORE = [ @@ -129,26 +124,8 @@ interface RuleOptions { type AnyNode = Record & { readonly type: string }; -function isNode(value: unknown): value is AnyNode { - return ( - typeof value === 'object' && - value !== null && - typeof (value as { type?: unknown }).type === 'string' - ); -} - -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { include: stringArray(record.include, DEFAULT_INCLUDE), ignore: stringArray(record.ignore, DEFAULT_IGNORE), @@ -159,21 +136,6 @@ function readOptions(context: Context): RuleOptions { }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -function importedName(specifier: ESTree.ImportSpecifier): string { - return specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; -} - interface EffectLocals { /** Locals standing for the `Effect` namespace itself. */ readonly namespace: ReadonlySet; @@ -188,124 +150,27 @@ function collectEffectLocals( bindings: EffectBindings, options: RuleOptions, ): EffectLocals { - const namespace = new Set(); - const barrel = new Set(); - const direct = new Map(); + const submodule = (source: string) => EFFECT_SUBMODULE.test(source); + const root = (source: string) => + !submodule(source) && + (source === EFFECT_ROOT_MODULE || matchesGlobs(source, options.effectModules)); + const namespace = collectRootNamespaces(program, submodule); for (const [local, exported] of bindings.namespaces) { if (exported === EFFECT_NAMESPACE) namespace.add(local); } - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - const source = statement.source.value; - if (EFFECT_SUBMODULE.test(source)) { - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportNamespaceSpecifier') namespace.add(specifier.local.name); - else if (specifier.type === 'ImportSpecifier') { - const imported = importedName(specifier); - if (options.members.includes(imported)) direct.set(specifier.local.name, imported); - } - } - continue; - } - if (source !== EFFECT_ROOT_MODULE && !matchesGlobs(source, options.effectModules)) continue; - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportNamespaceSpecifier') barrel.add(specifier.local.name); - else if ( - specifier.type === 'ImportSpecifier' && - importedName(specifier) === EFFECT_NAMESPACE - ) { - namespace.add(specifier.local.name); - } - } - } - return { namespace, barrel, direct }; -} - -/** Non-computed `.mapError`, or computed `["mapError"]`. */ -function memberName(node: AnyNode): string | null { - const property = node.property; - if (!isNode(property)) return null; - if (node.computed === true) { - return property.type === 'Literal' && typeof property.value === 'string' - ? property.value - : null; - } - return property.type === 'Identifier' && typeof property.name === 'string' ? property.name : null; -} - -function lookupVariable(context: Context, identifier: ESTree.IdentifierReference): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(identifier); - while (scope !== null) { - const variable = scope.set.get(identifier.name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; -} - -/** - * `true` when the identifier still resolves to an `import` binding. Unresolved names fall back to - * `true` because the module-level import declaration already proved the binding exists; only a local - * shadow (parameter, `const`, catch clause, …) rejects the match. - */ -function resolvesToImport(context: Context, identifier: unknown): boolean { - if (!isNode(identifier) || identifier.type !== 'Identifier') return false; - const variable = lookupVariable(context, identifier as unknown as ESTree.IdentifierReference); - if (variable === null) return true; - if (variable.defs.length === 0) return true; - return variable.defs.some((definition) => definition.type === 'ImportBinding'); -} - -/** `Effect.mapError`, `Effect["mapError"]`, `Barrel.Effect.mapError` → the member name. */ -function calleeMember( - context: Context, - callee: unknown, - bindings: EffectBindings, - locals: EffectLocals, -): string | null { - if (!isNode(callee)) return null; - if (callee.type === 'Identifier') { - const member = locals.direct.get(String(callee.name)); - if (member === undefined) return null; - return resolvesToImport(context, callee) ? member : null; - } - if (callee.type !== 'MemberExpression') return null; - const member = memberName(callee); - if (member === null) return null; - const object = callee.object; - if (!isNode(object)) return null; - // `Effect.mapError` — fast path through the shared helper, then the computed/alias fallback. - if (object.type === 'Identifier') { - const fast = effectMember(callee as unknown as ESTree.Node, bindings); - const isEffect = - fast?.namespace === EFFECT_NAMESPACE || locals.namespace.has(String(object.name)); - if (!isEffect) return null; - return resolvesToImport(context, object) ? member : null; - } - // `Barrel.Effect.mapError` where `Barrel` is `import * as Barrel from "effect"`. - if (object.type !== 'MemberExpression') return null; - if (memberName(object) !== EFFECT_NAMESPACE) return null; - const root = object.object; - if (!isNode(root) || root.type !== 'Identifier') return null; - if (!locals.barrel.has(String(root.name))) return null; - return resolvesToImport(context, root) ? member : null; + for (const local of collectNamedImports(program, root, new Set([EFFECT_NAMESPACE])).keys()) + namespace.add(local); + return { + namespace, + barrel: collectRootNamespaces(program, root), + direct: collectNamedImports(program, submodule, new Set(options.members)), + }; } function unwrap(node: unknown): AnyNode | null { let current: unknown = node; while (isNode(current)) { - if ( - current.type === 'TSAsExpression' || - current.type === 'TSSatisfiesExpression' || - current.type === 'TSNonNullExpression' || - current.type === 'TSTypeAssertion' || - current.type === 'TSInstantiationExpression' || - current.type === 'ParenthesizedExpression' - ) { - current = current.expression; - continue; - } - if (current.type === 'ChainExpression') { + if (EXPRESSION_WRAPPERS.has(current.type)) { current = current.expression; continue; } @@ -314,27 +179,22 @@ function unwrap(node: unknown): AnyNode | null { return null; } -/** The value of a non-computed `key` property on an object expression (method shorthand included). */ +/** A later spread or unknown computed key prevents proving the selected value. */ +function selectedProperty(property: unknown, key: string): { value: unknown } | null { + if (!isNode(property)) return null; + if (property.type === 'SpreadElement') return { value: null }; + if (property.type !== 'Property' || !isNode(property.key)) return null; + const name = keyName(property.key, property.computed === true, { templates: true }); + if (name === null && property.computed === true) return { value: null }; + return name === key ? { value: property.kind === 'init' ? property.value : null } : null; +} + +/** Resolve the last statically selected object property, including method shorthand. */ function objectProperty(object: AnyNode, key: string): unknown { const properties = Array.isArray(object.properties) ? object.properties : []; for (const property of [...properties].reverse()) { - if (isNode(property) && property.type === 'SpreadElement') return null; - if (!isNode(property) || property.type !== 'Property') continue; - - const propertyKey = property.key; - if (!isNode(propertyKey)) continue; - const name = - property.computed !== true && propertyKey.type === 'Identifier' - ? propertyKey.name - : propertyKey.type === 'Literal' && typeof propertyKey.value === 'string' - ? propertyKey.value - : propertyKey.type === 'TemplateLiteral' && - (propertyKey.expressions as unknown[]).length === 0 - ? ((propertyKey.quasis as { value: { cooked: string } }[])[0]?.value.cooked ?? null) - : null; - // An unknown later computed key could overwrite the selected callback. - if (name === null && property.computed === true) return null; - if (name === key) return property.kind === 'init' ? property.value : null; + const selected = selectedProperty(property, key); + if (selected !== null) return selected.value; } return null; } @@ -407,174 +267,132 @@ function isFunctionNode(node: AnyNode): boolean { /** Resolve an identifier callback to a same-file function definition, or `null`. */ function resolveLocalFunction(context: Context, identifier: AnyNode, depth = 0): AnyNode | null { if (depth > 24) return null; - const variable = lookupVariable(context, identifier as unknown as ESTree.IdentifierReference); - if (variable === null || variable.defs.length !== 1) return null; + const variable = stableVariable(context, identifier); + if (variable === null) return null; const definition = variable.defs[0]; if (definition === undefined) return null; if (definition.type === 'ImportBinding' || definition.type === 'Parameter') return null; - // Reassigned bindings are not statically knowable. - if (variable.references.some((reference) => reference.isWrite() && !reference.init)) return null; - const node = definition.node as unknown; + return functionDefinition(context, definition.node, depth); +} + +function functionDefinition(context: Context, node: unknown, depth: number): AnyNode | null { if (!isNode(node)) return null; if (node.type === 'FunctionDeclaration') return node; - if (node.type === 'VariableDeclarator') { - const init = unwrap(node.init); - if (init !== null && isFunctionNode(init)) return init; - if (init?.type === 'Identifier') return resolveLocalFunction(context, init, depth + 1); - } - return null; + if (node.type !== 'VariableDeclarator') return null; + const init = unwrap(node.init); + if (init !== null && isFunctionNode(init)) return init; + return init?.type === 'Identifier' ? resolveLocalFunction(context, init, depth + 1) : null; } -// Resolve runtime identity, not spelling. Only immutable same-file aliases are followed; -// dynamic imports, mutable rebinding and arbitrary cross-module re-exports remain unknown. -function effectOrigin( - context: Context, - input: ESTree.Node, - barrels: readonly string[], - depth = 0, -): readonly string[] | null { - if (depth > 24) return null; - let node = input; - while ( - [ - 'ParenthesizedExpression', - 'ChainExpression', - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSInstantiationExpression', - 'TSTypeAssertion', - ].includes(node.type) - ) { - node = (node as { expression: ESTree.Node }).expression; - } - const keyOf = (key: ESTree.Node, computed: boolean): string | null => { - if (!computed && key.type === 'Identifier') return key.name; - if (key.type === 'Literal' && typeof key.value === 'string') return key.value; - if (key.type === 'TemplateLiteral' && key.expressions.length === 0) - return key.quasis[0]?.value.cooked ?? null; - return null; - }; - if (node.type === 'MemberExpression') { - const key = keyOf(node.property, node.computed); - const base = effectOrigin(context, node.object, barrels, depth + 1); - return base && key !== null ? [...base, key] : null; - } - if (node.type !== 'Identifier') return null; - let scope: ReturnType | null = - context.sourceCode.getScope(node); - while (scope) { - const variable = scope.set.get(node.name); - const defs = variable?.defs.filter( - (def) => - !['TSInterfaceDeclaration', 'TSTypeAliasDeclaration', 'TSTypeParameter'].includes( - def.node.type, - ), - ); - if (!variable || !defs?.length) { - scope = scope.upper; - continue; - } - if (defs.length !== 1) return null; - const def = defs[0]!; - if (def.type === 'ImportBinding') { - const spec = def.node; - const declaration = def.parent?.type === 'ImportDeclaration' ? def.parent : spec.parent; - if ( - declaration?.type !== 'ImportDeclaration' || - declaration.importKind === 'type' || - (spec as { importKind?: string }).importKind === 'type' - ) - return null; - const source = declaration.source.value; - const root = source === 'effect' || barrels.some((glob) => globToRegExp(glob).test(source)); - if (!root && !source.startsWith('effect/')) return null; - const base = root ? [] : [source.split('/').at(-1)!]; - if (spec.type === 'ImportNamespaceSpecifier' || spec.type === 'ImportDefaultSpecifier') - return base; - if (spec.type !== 'ImportSpecifier') return null; - return [ - ...base, - spec.imported.type === 'Identifier' ? spec.imported.name : spec.imported.value, - ]; - } - const declaration = def.node; - if ( - declaration.type !== 'VariableDeclarator' || - !declaration.init || - declaration.parent?.type !== 'VariableDeclaration' || - declaration.parent.kind !== 'const' - ) - return null; - if (variable.references.some((reference) => reference.isWrite() && !reference.init)) - return null; - const base = effectOrigin(context, declaration.init, barrels, depth + 1); - if (!base) return null; - if (declaration.id.type === 'Identifier') return base; - if (declaration.id.type !== 'ObjectPattern') return null; - for (const property of declaration.id.properties) { - if ( - property.type !== 'Property' || - property.value.type !== 'Identifier' || - property.value.name !== node.name - ) - continue; - const key = keyOf(property.key, property.computed); - return key === null ? null : [...base, key]; - } - return null; - } - return null; +const OPTION_REFERENCE_WRAPPERS = new Set([ + 'TSAsExpression', + 'TSSatisfiesExpression', + 'TSNonNullExpression', + 'TSTypeAssertion', + 'ParenthesizedExpression', +]); + +function mutatesMember(member: ESTree.MemberExpression): boolean { + const use = member.parent; + if (use?.type === 'AssignmentExpression') return use.left === member; + if (use?.type === 'UpdateExpression') return use.argument === member; + if (use?.type === 'UnaryExpression') return use.operator === 'delete'; + return use?.type === 'CallExpression' && use.callee === member; +} + +function mutatesOptionObject(identifier: ESTree.Node): boolean { + const { node, parent } = skipWrappers(identifier, OPTION_REFERENCE_WRAPPERS); + return parent?.type === 'MemberExpression' && parent.object === node && mutatesMember(parent); +} + +function stableVariable(context: Context, identifier: AnyNode): Variable | null { + const variable = lookupVariable(context, identifier as unknown as ESTree.Node); + if (!variable || variable.defs.length !== 1) return null; + return variable.references.some((reference) => reference.isWrite() && !reference.init) + ? null + : variable; +} + +function constDeclaration(variable: Variable): ESTree.VariableDeclarator | null { + const declaration = variable.defs[0]?.node; + if (declaration?.type !== 'VariableDeclarator') return null; + return declaration.parent?.type === 'VariableDeclaration' && declaration.parent.kind === 'const' + ? declaration + : null; } function resolveValue(context: Context, input: unknown, depth = 0): AnyNode | null { const node = unwrap(input); if (!node || node.type !== 'Identifier' || depth > 24) return node; - const variable = lookupVariable(context, node as unknown as ESTree.IdentifierReference); - if ( - !variable || - variable.defs.length !== 1 || - variable.references.some((r) => r.isWrite() && !r.init) - ) - return node; - const declaration = variable.defs[0]?.node; - if ( - declaration?.type !== 'VariableDeclarator' || - declaration.parent?.type !== 'VariableDeclaration' || - declaration.parent.kind !== 'const' - ) - return node; + const variable = stableVariable(context, node); + if (!variable) return node; + const declaration = constDeclaration(variable); + if (declaration === null) return node; // A const binding does not freeze its option properties. Visible writes/method calls // invalidate this local snapshot; arbitrary escaped-object mutation is not modeled. - if ( - variable.references.some((reference) => { - let current: ESTree.Node = reference.identifier; - while ( - current.parent && - [ - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSTypeAssertion', - 'ParenthesizedExpression', - ].includes(current.parent.type) - ) - current = current.parent; - const member = current.parent; - if (member?.type !== 'MemberExpression' || member.object !== current) return false; - const use = member.parent; - return ( - (use?.type === 'AssignmentExpression' && use.left === member) || - (use?.type === 'UpdateExpression' && use.argument === member) || - (use?.type === 'UnaryExpression' && use.operator === 'delete') || - (use?.type === 'CallExpression' && use.callee === member) - ); - }) - ) + if (variable.references.some((reference) => mutatesOptionObject(reference.identifier))) return node; return resolveValue(context, declaration.init, depth + 1); } +function reportFunction( + context: Context, + callback: AnyNode, + definition: AnyNode, + member: string, + indirect: boolean, +): void { + const classification = classifyFunction(context, definition); + const node = callback as unknown as ESTree.Node; + const name = String(callback.name); + if (classification === 'zeroArity') { + context.report({ + node, + messageId: + member === 'orElseFail' + ? 'discardingLazyFailure' + : indirect + ? 'indirectZeroArity' + : 'zeroArity', + data: { member, name }, + }); + return; + } + if (classification !== 'unusedParameter') return; + const parameter = firstParameterName( + Array.isArray(definition.params) ? definition.params : [], + ).name; + context.report({ + node, + messageId: indirect ? 'indirectUnusedParameter' : 'unusedParameter', + data: { member, name, parameter: parameter ?? 'error' }, + }); +} + +function reportCallback( + context: Context, + callback: AnyNode, + member: string, + flagMemberReferences: boolean, +): void { + if (isFunctionNode(callback)) { + reportFunction(context, callback, callback, member, false); + return; + } + if (callback.type === 'Identifier') { + const definition = resolveLocalFunction(context, callback); + if (definition !== null) reportFunction(context, callback, definition, member, true); + return; + } + if (flagMemberReferences && callback.type === 'MemberExpression') { + context.report({ + node: callback as unknown as ESTree.Node, + messageId: 'memberReference', + data: { member, name: memberName(callback) ?? 'callback' }, + }); + } +} + export const rule = defineRule({ meta: { type: 'problem', @@ -662,65 +480,8 @@ export const rule = defineRule({ const argumentsList = Array.isArray(raw.arguments) ? raw.arguments : []; const callback = errorCallback(context, member, argumentsList); if (callback === null) return; - const target = callback as unknown as ESTree.Node; - - if (isFunctionNode(callback)) { - const classification = classifyFunction(context, callback); - if (classification === 'zeroArity') { - context.report({ - node: target, - messageId: member === 'orElseFail' ? 'discardingLazyFailure' : 'zeroArity', - data: { member }, - }); - return; - } - if (classification === 'unusedParameter') { - const { name } = firstParameterName( - Array.isArray(callback.params) ? callback.params : [], - ); - context.report({ - node: target, - messageId: 'unusedParameter', - data: { member, parameter: name ?? 'error' }, - }); - } - return; - } - - if (callback.type === 'Identifier') { - const name = String(callback.name); - const definition = resolveLocalFunction(context, callback); - if (definition === null) return; - const classification = classifyFunction(context, definition); - if (classification === 'zeroArity') { - context.report({ - node: target, - messageId: member === 'orElseFail' ? 'discardingLazyFailure' : 'indirectZeroArity', - data: { member, name }, - }); - return; - } - if (classification === 'unusedParameter') { - const parameter = firstParameterName( - Array.isArray(definition.params) ? definition.params : [], - ).name; - context.report({ - node: target, - messageId: 'indirectUnusedParameter', - data: { member, name, parameter: parameter ?? 'error' }, - }); - } - return; - } - - if (options.flagMemberReferences && callback.type === 'MemberExpression') { - const property = memberName(callback); - context.report({ - node: target, - messageId: 'memberReference', - data: { member, name: property ?? 'callback' }, - }); - } + + reportCallback(context, callback, member, options.flagMemberReferences); }, }; }, diff --git a/app/tools/oxlint/effect-native/rules/no-hand-built-http-server-in-tests.ts b/app/tools/oxlint/effect-native/rules/no-hand-built-http-server-in-tests.ts index 9f58e17f1..52094d395 100644 --- a/app/tools/oxlint/effect-native/rules/no-hand-built-http-server-in-tests.ts +++ b/app/tools/oxlint/effect-native/rules/no-hand-built-http-server-in-tests.ts @@ -252,83 +252,167 @@ export const rule = defineRule({ ]); // Provenance is local and scope-resolved, not type inference. Unknown writes invalidate // aliases; object fields, function returns and cross-file re-exports are not followed. + function isTypeSpecifier(spec: ESTree.Node): boolean { + return spec.type === 'ImportSpecifier' && spec.importKind === 'type'; + } + function importOrigin(def: Variable['defs'][number]): string | null { + const spec = def.node; + const declaration = def.parent; + if (declaration?.type !== 'ImportDeclaration' || declaration.importKind === 'type') + return null; + if (isTypeSpecifier(spec)) return null; + const source = declaration.source.value; + const name = spec.type === 'ImportSpecifier' ? staticKey(spec.imported, false) : '*'; + if (source === 'node:module' || source === 'module') return moduleMemberOrigin(name); + if (!isServerModule(source)) return null; + return name === '*' ? 'namespace' : factoryOrigin(name); + } + function moduleMemberOrigin(name: string | null): string | null { + if (name === 'createRequire') return 'createRequire'; + return name === '*' ? 'module' : null; + } + function factoryOrigin(key: string | null): string | null { + return key !== null && factoryNames.has(key) ? 'factory' : null; + } + function destructuredOrigin( + pattern: ESTree.ObjectPattern, + name: string, + value: string | null, + ): string | null { + const property = pattern.properties.find( + (p) => p.type === 'Property' && p.value.type === 'Identifier' && p.value.name === name, + ); + if (property?.type !== 'Property' || value !== 'namespace') return null; + return factoryOrigin(staticKey(property.key, property.computed)); + } + function variableOrigin( + node: Extract, + binding: Variable, + next: Set, + ): string | null { + const values: string[] = []; + for (const def of binding.defs) { + if (def.type === 'ImportBinding') return importOrigin(def); + if (def.type !== 'Variable' || def.node.type !== 'VariableDeclarator') return null; + if (def.node.init === null) continue; + let value = origin(def.node.init, next); + if (def.node.id.type === 'ObjectPattern') + value = destructuredOrigin(def.node.id, node.name, value); + if (value === null) return null; + values.push(value); + } + return writtenOrigin(binding, values, next); + } + function writtenOrigin( + binding: Variable, + values: string[], + next: Set, + ): string | null { + for (const write of writes.get(binding) ?? []) { + const value = origin(write, next); + if (value === null) return null; + values.push(value); + } + return values.length > 0 && values.every((value) => value === values[0]) ? values[0]! : null; + } + function identifierOrigin( + node: Extract, + next: Set, + ): string | null { + const binding = variable(node); + if (binding === undefined || binding.defs.length === 0) + return node.name === 'require' ? 'require' : null; + return variableOrigin(node, binding, next); + } + function memberOrigin(node: ESTree.MemberExpression, next: Set): string | null { + const owner = origin(node.object, next); + const key = staticKey(node.property, node.computed); + if (owner === 'namespace') return factoryOrigin(key); + return owner === 'module' && key === 'createRequire' ? 'createRequire' : null; + } + function sourceOrigin(node: ESTree.Node): string | null { + const source = literalSource(node); + return source !== null && isServerModule(source) ? 'namespace' : null; + } + function callOrigin( + node: ESTree.CallExpression | ESTree.NewExpression, + next: Set, + ): string | null { + const callee = origin(node.callee, next); + if (callee === 'factory') return 'server'; + if (callee === 'createRequire') return 'require'; + if (callee === 'require' && node.arguments[0] !== undefined) + return sourceOrigin(unwrap(node.arguments[0])); + return null; + } function origin(input: ESTree.Node, seen = new Set()): string | null { const node = unwrap(input); if (seen.has(node)) return null; const next = new Set(seen).add(node); - if (node.type === 'AwaitExpression') return origin(node.argument as ESTree.Node, next); - if (node.type === 'Identifier') { - const binding = variable(node); - if (binding === undefined || binding.defs.length === 0) { - return node.name === 'require' ? 'require' : null; - } - const values: string[] = []; - for (const def of binding.defs) { - if (def.type === 'ImportBinding') { - const spec = def.node; - const declaration = def.parent; - if (declaration?.type !== 'ImportDeclaration' || declaration.importKind === 'type') - return null; - if (spec.type === 'ImportSpecifier' && spec.importKind === 'type') return null; - const source = declaration.source.value; - const name = spec.type === 'ImportSpecifier' ? staticKey(spec.imported, false) : '*'; - if (source === 'node:module' || source === 'module') - return name === 'createRequire' ? 'createRequire' : name === '*' ? 'module' : null; - if (!isServerModule(source)) return null; - return name === '*' - ? 'namespace' - : name !== null && factoryNames.has(name) - ? 'factory' - : null; - } - if (def.type !== 'Variable' || def.node.type !== 'VariableDeclarator') return null; - if (def.node.init === null) continue; - let value = origin(def.node.init as ESTree.Node, next); - if (def.node.id.type === 'ObjectPattern') { - const property = def.node.id.properties.find( - (p) => - p.type === 'Property' && - p.value.type === 'Identifier' && - p.value.name === node.name, - ); - if (property?.type !== 'Property') return null; - const key = staticKey(property.key as ESTree.Node, property.computed); - value = - value === 'namespace' && key !== null && factoryNames.has(key) ? 'factory' : null; - } - if (value === null) return null; - values.push(value); - } - for (const write of writes.get(binding) ?? []) { - const value = origin(write, next); - if (value === null) return null; - values.push(value); - } - return values.length > 0 && values.every((value) => value === values[0]) - ? values[0]! - : null; + switch (node.type) { + case 'AwaitExpression': + return origin(node.argument, next); + case 'Identifier': + return identifierOrigin(node, next); + case 'MemberExpression': + return memberOrigin(node, next); + case 'ImportExpression': + return sourceOrigin(node.source); + case 'CallExpression': + case 'NewExpression': + return callOrigin(node, next); + default: + return null; } - if (node.type === 'MemberExpression') { - const owner = origin(node.object as ESTree.Node, next); - const key = staticKey(node.property as ESTree.Node, node.computed); - if (owner === 'namespace' && key !== null && factoryNames.has(key)) return 'factory'; - if (owner === 'module' && key === 'createRequire') return 'createRequire'; - return null; + } + function inspectMemberCall( + node: ESTree.CallExpression | ESTree.NewExpression, + callee: ESTree.MemberExpression, + ): void { + const member = staticKey(callee.property as ESTree.Node, callee.computed); + const object = unwrap(callee.object as ESTree.Node); + if (member === 'listen' && object.type === 'Identifier' && origin(object) === 'server') { + reports.push({ node, messageId: 'serverListen', data: { name: object.name } }); } - if (node.type === 'ImportExpression') { - const source = literalSource(node.source as ESTree.Node); - return source !== null && isServerModule(source) ? 'namespace' : null; + if ( + options.includeFetch && + member === 'fetch' && + object.type === 'Identifier' && + ['globalThis', 'global'].includes(object.name) && + isAmbientGlobal(context, object, object.name) + ) { + reports.push({ node, messageId: 'ambientFetch', data: {} }); } - if (node.type === 'CallExpression' || node.type === 'NewExpression') { - const callee = origin(node.callee as ESTree.Node, next); - if (callee === 'factory') return 'server'; - if (callee === 'createRequire') return 'require'; - if (callee === 'require' && node.arguments[0] !== undefined) { - const source = literalSource(unwrap(node.arguments[0] as ESTree.Node)); - return source !== null && isServerModule(source) ? 'namespace' : null; - } + } + function isAmbientFetch(callee: ESTree.Node): boolean { + return ( + options.includeFetch && + callee.type === 'Identifier' && + callee.name === 'fetch' && + isAmbientGlobal(context, callee, 'fetch') + ); + } + function inspectCall(node: ESTree.CallExpression | ESTree.NewExpression): void { + const callee = unwrap(node.callee as ESTree.Node); + const identity = origin(callee); + if (identity === 'factory') { + reports.push({ + node, + messageId: 'serverFactoryCall', + data: { name: context.sourceCode.getText(callee) }, + }); + return; + } + if (identity === 'require' && node.arguments[0] !== undefined) { + const source = literalSource(unwrap(node.arguments[0] as ESTree.Node)); + if (source !== null && isServerModule(source)) + reports.push({ node, messageId: 'dynamicServerModuleImport', data: { source } }); + } + if (callee.type === 'MemberExpression') { + inspectMemberCall(node, callee); + } else if (isAmbientFetch(callee)) { + reports.push({ node, messageId: 'ambientFetch', data: {} }); } - return null; } function exported(node: ESTree.ExportNamedDeclaration | ESTree.ExportAllDeclaration): void { if (node.source === null || node.exportKind === 'type' || !isServerModule(node.source.value)) @@ -384,50 +468,7 @@ export const rule = defineRule({ calls.push(node); }, 'Program:exit'() { - for (const node of calls) { - const callee = unwrap(node.callee as ESTree.Node); - const identity = origin(callee); - if (identity === 'factory') { - reports.push({ - node, - messageId: 'serverFactoryCall', - data: { name: context.sourceCode.getText(callee) }, - }); - continue; - } - if (identity === 'require' && node.arguments[0] !== undefined) { - const source = literalSource(unwrap(node.arguments[0] as ESTree.Node)); - if (source !== null && isServerModule(source)) - reports.push({ node, messageId: 'dynamicServerModuleImport', data: { source } }); - } - if (callee.type === 'MemberExpression') { - const member = staticKey(callee.property as ESTree.Node, callee.computed); - const object = unwrap(callee.object as ESTree.Node); - if ( - member === 'listen' && - object.type === 'Identifier' && - origin(object) === 'server' - ) { - reports.push({ node, messageId: 'serverListen', data: { name: object.name } }); - } - if ( - options.includeFetch && - member === 'fetch' && - object.type === 'Identifier' && - ['globalThis', 'global'].includes(object.name) && - isAmbientGlobal(context, object, object.name) - ) { - reports.push({ node, messageId: 'ambientFetch', data: {} }); - } - } else if ( - options.includeFetch && - callee.type === 'Identifier' && - callee.name === 'fetch' && - isAmbientGlobal(context, callee, 'fetch') - ) { - reports.push({ node, messageId: 'ambientFetch', data: {} }); - } - } + for (const node of calls) inspectCall(node); for (const report of reports.sort((a, b) => a.node.start - b.node.start)) context.report(report); }, diff --git a/app/tools/oxlint/effect-native/rules/no-hand-built-problem-details.ts b/app/tools/oxlint/effect-native/rules/no-hand-built-problem-details.ts index ba65a0803..278ad2d2d 100644 --- a/app/tools/oxlint/effect-native/rules/no-hand-built-problem-details.ts +++ b/app/tools/oxlint/effect-native/rules/no-hand-built-problem-details.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * effect-native/no-hand-built-problem-details * @@ -74,16 +75,10 @@ import { defineRule } from '@oxlint/plugins'; import type { Context, ESTree } from '@oxlint/plugins'; -import { collectEffectBindings } from '../shared/effect-imports.ts'; -import type { EffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isScriptFile, isTestFile, normalisePath } from '../shared/paths.ts'; - -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production `include` defaults instead of - * forcing the fixture config to pass loosened options (which `run-on-repo.mts` reuses). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; +import { unwrapNode } from '../shared/ast.ts'; +import { effectOrigin } from '../shared/effect-identity.ts'; +import { compile, stringArray } from '../shared/options.ts'; +import { isScriptFile, isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; const DEFAULT_INCLUDE: readonly string[] = ['apps/**', 'verticals/**', 'packages/**']; @@ -142,12 +137,6 @@ interface RuleOptions { readonly reportRawDriverMessages: boolean; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - function statusRange( value: unknown, fallback: readonly [number, number], @@ -159,22 +148,8 @@ function statusRange( return [low, high]; } -function errorPattern(value: unknown): RegExp { - if (typeof value !== 'string' || value.length === 0) - return new RegExp(DEFAULT_ERROR_IDENTIFIER_PATTERN, 'iu'); - try { - return new RegExp(value, 'iu'); - } catch { - return new RegExp(DEFAULT_ERROR_IDENTIFIER_PATTERN, 'iu'); - } -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { include: stringArray(record.include, DEFAULT_INCLUDE), ignore: stringArray(record.ignore, DEFAULT_IGNORE), @@ -184,42 +159,15 @@ function readOptions(context: Context): RuleOptions { schemaNamespaces: stringArray(record.schemaNamespaces, DEFAULT_SCHEMA_NAMESPACES), tagSuffixes: stringArray(record.tagSuffixes, DEFAULT_TAG_SUFFIXES), messageKeys: stringArray(record.messageKeys, DEFAULT_MESSAGE_KEYS), - errorIdentifier: errorPattern(record.errorIdentifierPattern), + errorIdentifier: compile(record.errorIdentifierPattern, DEFAULT_ERROR_IDENTIFIER_PATTERN, 'iu'), reportTagOnlyLiterals: record.reportTagOnlyLiterals !== false, reportRawDriverMessages: record.reportRawDriverMessages !== false, }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -/** Strip `as const`, `satisfies`, `!`, `x` and parentheses so the underlying literal is visible. */ +/** Preserve the rule's bounded expression-wrapper traversal. */ function unwrap(node: ESTree.Node): ESTree.Node { - let current: ESTree.Node = node; - for (let depth = 0; depth < MAX_EXPRESSION_DEPTH; depth += 1) { - if ( - current.type === 'ParenthesizedExpression' || - current.type === 'TSAsExpression' || - current.type === 'TSSatisfiesExpression' || - current.type === 'TSTypeAssertion' || - current.type === 'TSNonNullExpression' || - current.type === 'TSInstantiationExpression' || - current.type === 'ChainExpression' - ) { - const inner: ESTree.Node | undefined = (current as { expression?: ESTree.Node }).expression; - if (inner === undefined) return current; - current = inner; - continue; - } - return current; - } - return current; + return unwrapNode(node, { maxDepth: MAX_EXPRESSION_DEPTH }); } /** Static name of a non-computed object-literal property key (`status`, `'status'`). */ @@ -291,36 +239,40 @@ function isSchemaCallee(context: Context, callee: ESTree.Node, options: RuleOpti * hand-built wire payload. The walk stops at function/class/program boundaries so that a literal * *returned from a callback* passed to a Schema combinator is still reported. */ +const EXEMPT_WALK_BOUNDARIES: ReadonlySet = new Set([ + 'ArrowFunctionExpression', + 'FunctionExpression', + 'FunctionDeclaration', + 'ClassBody', + 'Program', + 'BlockStatement', +]); + +function isSchemaArgument( + context: Context, + node: ESTree.Node, + argument: ESTree.Node, + options: RuleOptions, +): boolean { + if (node.type !== 'CallExpression' && node.type !== 'NewExpression') return false; + return ( + node.arguments.some((candidate) => Object.is(candidate, argument)) && + isSchemaCallee(context, node.callee, options) + ); +} + function isExemptContext( context: Context, node: ESTree.ObjectExpression, options: RuleOptions, - bindings: EffectBindings, ): boolean { let previous: ESTree.Node = node; let current: ESTree.Node | null | undefined = node.parent; for (let depth = 0; depth < MAX_ANCESTOR_DEPTH; depth += 1) { if (current === null || current === undefined) return false; - switch (current.type) { - case 'ArrowFunctionExpression': - case 'FunctionExpression': - case 'FunctionDeclaration': - case 'ClassBody': - case 'Program': - case 'BlockStatement': - return false; - case 'JSXAttribute': - case 'JSXSpreadAttribute': - return true; - case 'CallExpression': - case 'NewExpression': { - const isArgument = current.arguments.some((argument) => Object.is(argument, previous)); - if (isArgument && isSchemaCallee(context, current.callee, options)) return true; - break; - } - default: - break; - } + if (EXEMPT_WALK_BOUNDARIES.has(current.type)) return false; + if (current.type === 'JSXAttribute' || current.type === 'JSXSpreadAttribute') return true; + if (isSchemaArgument(context, current, previous, options)) return true; previous = current; current = current.parent; } @@ -367,161 +319,156 @@ function leaksDriverMessage(node: ESTree.Node, options: RuleOptions, depth: numb switch (target.type) { case 'Identifier': return options.errorIdentifier.test(target.name); - case 'MemberExpression': { - const property = memberPropertyName(target); - if (property !== 'message' && property !== 'stack' && property !== 'cause') return false; - const root = rootIdentifier(target.object); - return root !== null && options.errorIdentifier.test(root); - } - case 'CallExpression': { - const callee = unwrap(target.callee); - const isJsonStringify = - callee.type === 'MemberExpression' && - memberPropertyName(callee) === 'stringify' && - unwrap(callee.object).type === 'Identifier' && - (unwrap(callee.object) as ESTree.IdentifierReference).name === 'JSON'; - const isStringify = - isJsonStringify || - (callee.type === 'Identifier' && (callee.name === 'String' || callee.name === 'inspect')) || - (callee.type === 'MemberExpression' && - !callee.computed && - callee.property.type === 'Identifier' && - (callee.property.name === 'toString' || callee.property.name === 'inspect')); - if (!isStringify) return false; - if (callee.type === 'MemberExpression' && !isJsonStringify) - return leaksDriverMessage(callee.object, options, depth + 1); - return target.arguments.some( - (argument) => - argument.type !== 'SpreadElement' && - (isErrorIdentifier(argument, options) || - leaksDriverMessage(argument, options, depth + 1)), - ); - } - case 'TemplateLiteral': - return target.expressions.some((expression) => + case 'MemberExpression': + return leaksMemberMessage(target, options); + case 'CallExpression': + return leaksCallMessage(target, options, depth); + default: + return messageExpressions(target).some((expression) => leaksDriverMessage(expression, options, depth + 1), ); + } +} + +function messageExpressions(node: ESTree.Node): readonly ESTree.Node[] { + switch (node.type) { + case 'TemplateLiteral': + return node.expressions; case 'BinaryExpression': - if (target.operator !== '+') return false; - return ( - leaksDriverMessage(target.left, options, depth + 1) || - leaksDriverMessage(target.right, options, depth + 1) - ); + return node.operator === '+' ? [node.left, node.right] : []; case 'LogicalExpression': - return ( - leaksDriverMessage(target.left, options, depth + 1) || - leaksDriverMessage(target.right, options, depth + 1) - ); + return [node.left, node.right]; case 'ConditionalExpression': - return ( - leaksDriverMessage(target.consequent, options, depth + 1) || - leaksDriverMessage(target.alternate, options, depth + 1) - ); + return [node.consequent, node.alternate]; default: - return false; + return []; } } -// Resolve runtime identity, not spelling. Only immutable same-file aliases are followed; -// dynamic imports, mutable rebinding and arbitrary cross-module re-exports remain unknown. -function effectOrigin( - context: Context, - input: ESTree.Node, - barrels: readonly string[], - depth = 0, -): readonly string[] | null { - if (depth > 24) return null; - let node = input; - while ( - [ - 'ParenthesizedExpression', - 'ChainExpression', - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSInstantiationExpression', - 'TSTypeAssertion', - ].includes(node.type) - ) { - node = (node as { expression: ESTree.Node }).expression; - } - const keyOf = (key: ESTree.Node, computed: boolean): string | null => { - if (!computed && key.type === 'Identifier') return key.name; - if (key.type === 'Literal' && typeof key.value === 'string') return key.value; - if (key.type === 'TemplateLiteral' && key.expressions.length === 0) - return key.quasis[0]?.value.cooked ?? null; - return null; +function leaksMemberMessage(node: ESTree.MemberExpression, options: RuleOptions): boolean { + const property = memberPropertyName(node); + if (property === null || !['message', 'stack', 'cause'].includes(property)) return false; + const root = rootIdentifier(node.object); + return root !== null && options.errorIdentifier.test(root); +} + +function isJsonStringify(node: ESTree.Node): boolean { + if (node.type !== 'MemberExpression' || memberPropertyName(node) !== 'stringify') return false; + const object = unwrap(node.object); + return object.type === 'Identifier' && object.name === 'JSON'; +} + +function isStringifyCallee(node: ESTree.Node): boolean { + if (node.type === 'Identifier') return node.name === 'String' || node.name === 'inspect'; + return ( + node.type === 'MemberExpression' && + !node.computed && + node.property.type === 'Identifier' && + ['toString', 'inspect'].includes(node.property.name) + ); +} + +function leaksCallMessage( + node: ESTree.CallExpression, + options: RuleOptions, + depth: number, +): boolean { + const callee = unwrap(node.callee); + const jsonStringify = isJsonStringify(callee); + if (!jsonStringify && !isStringifyCallee(callee)) return false; + if (callee.type === 'MemberExpression' && !jsonStringify) + return leaksDriverMessage(callee.object, options, depth + 1); + return node.arguments.some( + (argument) => + argument.type !== 'SpreadElement' && + (isErrorIdentifier(argument, options) || leaksDriverMessage(argument, options, depth + 1)), + ); +} + +type Properties = ReadonlyMap; + +function indexedValue(properties: Properties, key: string): ESTree.Node | null { + const property = properties.get(key); + return property === undefined ? null : propertyValue(property); +} + +function problemFields(properties: Properties, options: RuleOptions) { + const tag = stringLiteralValue(indexedValue(properties, '_tag')); + const hasProblemType = isUriLike(indexedValue(properties, 'type')); + const hasTitle = properties.has('title'); + const hasDetail = properties.has('detail'); + return { + tag, + taggedProblem: tag !== null && endsWithAny(tag, options.tagSuffixes), + hasProblemType, + hasTitle, + hasProse: hasTitle || hasDetail, + }; +} + +function problemShape(properties: Properties, options: RuleOptions) { + const fields = problemFields(properties, options); + const status = integerLiteral(indexedValue(properties, 'status')); + const inRange = + status !== null && status >= options.statusRange[0] && status <= options.statusRange[1]; + const corroborated = fields.taggedProblem || (fields.hasProblemType && fields.hasProse); + const reportStatus = inRange && corroborated; + const reportTag = shouldReportTag(fields, reportStatus, options); + return { + tag: fields.tag, + status, + reportStatus, + reportTag, + statusProperty: properties.get('status'), + tagProperty: properties.get('_tag'), + problemShaped: reportStatus || reportTag || (fields.hasTitle && fields.hasProblemType), }; - if (node.type === 'MemberExpression') { - const key = keyOf(node.property, node.computed); - const base = effectOrigin(context, node.object, barrels, depth + 1); - return base && key !== null ? [...base, key] : null; +} + +function shouldReportTag( + fields: ReturnType, + reportStatus: boolean, + options: RuleOptions, +): boolean { + return ( + !reportStatus && + options.reportTagOnlyLiterals && + fields.taggedProblem && + (fields.hasProse || fields.hasProblemType) + ); +} + +function reportProblemShape(context: Context, shape: ReturnType): void { + if (shape.reportStatus && shape.statusProperty !== undefined) { + context.report({ + node: shape.statusProperty, + messageId: 'handBuiltProblem', + data: { + status: String(shape.status), + tagged: shape.tag === null ? '' : ` for \`${shape.tag}\``, + }, + }); + } else if (shape.reportTag && shape.tagProperty !== undefined) { + context.report({ + node: shape.tagProperty, + messageId: 'handBuiltProblemTag', + data: { tag: shape.tag ?? '' }, + }); } - if (node.type !== 'Identifier') return null; - let scope: ReturnType | null = - context.sourceCode.getScope(node); - while (scope) { - const variable = scope.set.get(node.name); - const defs = variable?.defs.filter( - (def) => - !['TSInterfaceDeclaration', 'TSTypeAliasDeclaration', 'TSTypeParameter'].includes( - def.node.type, - ), - ); - if (!variable || !defs?.length) { - scope = scope.upper; - continue; - } - if (defs.length !== 1) return null; - const def = defs[0]!; - if (def.type === 'ImportBinding') { - const spec = def.node; - const declaration = def.parent?.type === 'ImportDeclaration' ? def.parent : spec.parent; - if ( - declaration?.type !== 'ImportDeclaration' || - declaration.importKind === 'type' || - (spec as { importKind?: string }).importKind === 'type' - ) - return null; - const source = declaration.source.value; - const root = source === 'effect' || barrels.some((glob) => globToRegExp(glob).test(source)); - if (!root && !source.startsWith('effect/')) return null; - const base = root ? [] : [source.split('/').at(-1)!]; - if (spec.type === 'ImportNamespaceSpecifier' || spec.type === 'ImportDefaultSpecifier') - return base; - if (spec.type !== 'ImportSpecifier') return null; - return [ - ...base, - spec.imported.type === 'Identifier' ? spec.imported.name : spec.imported.value, - ]; - } - const declaration = def.node; - if ( - declaration.type !== 'VariableDeclarator' || - !declaration.init || - declaration.parent?.type !== 'VariableDeclaration' || - declaration.parent.kind !== 'const' - ) - return null; - if (variable.references.some((reference) => reference.isWrite() && !reference.init)) - return null; - const base = effectOrigin(context, declaration.init, barrels, depth + 1); - if (!base) return null; - if (declaration.id.type === 'Identifier') return base; - if (declaration.id.type !== 'ObjectPattern') return null; - for (const property of declaration.id.properties) { - if ( - property.type !== 'Property' || - property.value.type !== 'Identifier' || - property.value.name !== node.name - ) - continue; - const key = keyOf(property.key, property.computed); - return key === null ? null : [...base, key]; - } - return null; +} + +function reportDriverMessages( + context: Context, + properties: Properties, + options: RuleOptions, +): void { + for (const key of options.messageKeys) { + const property = properties.get(key); + if (property === undefined) continue; + const value = propertyValue(property); + if (value === null || !leaksDriverMessage(value, options, 0)) continue; + context.report({ node: property, messageId: 'rawDriverMessage', data: { key } }); } - return null; } export const rule = defineRule({ @@ -593,67 +540,14 @@ export const rule = defineRule({ if (!options.includeTests && isTestFile(path)) return {}; if (isScriptFile(path)) return {}; - let bindings: EffectBindings = { namespaces: new Map(), importsEffect: false }; - return { - Program(node) { - bindings = collectEffectBindings(node); - }, - ObjectExpression(node) { - if (node.properties.length === 0) return; + if (node.properties.length === 0 || isExemptContext(context, node, options)) return; const properties = indexProperties(node); - const statusProperty = properties.get('status') ?? null; - const status = - statusProperty === null ? null : integerLiteral(propertyValue(statusProperty)); - const inRange = - status !== null && status >= options.statusRange[0] && status <= options.statusRange[1]; - - const tagProperty = properties.get('_tag') ?? null; - const tag = tagProperty === null ? null : stringLiteralValue(propertyValue(tagProperty)); - const taggedProblem = tag !== null && endsWithAny(tag, options.tagSuffixes); - - const typeProperty = properties.get('type') ?? null; - const hasProblemType = typeProperty !== null && isUriLike(propertyValue(typeProperty)); - const hasTitle = properties.has('title'); - const hasRetryable = properties.has('retryable'); - const hasDetail = properties.has('detail'); - - const corroborated = taggedProblem || (hasProblemType && (hasTitle || hasDetail)); - const reportStatus = inRange && corroborated; - const reportTag = - !reportStatus && - options.reportTagOnlyLiterals && - taggedProblem && - (hasTitle || hasProblemType || hasDetail); - // A problem payload for the raw-message check: either of the two report shapes, or the - // RFC 9457 `title` + `type` pair without a tag. - const problemShaped = reportStatus || reportTag || (hasTitle && hasProblemType); - - if (isExemptContext(context, node, options, bindings)) return; - - if (reportStatus && statusProperty !== null) { - context.report({ - node: statusProperty, - messageId: 'handBuiltProblem', - data: { status: String(status), tagged: tag === null ? '' : ` for \`${tag}\`` }, - }); - } else if (reportTag && tagProperty !== null) { - context.report({ - node: tagProperty, - messageId: 'handBuiltProblemTag', - data: { tag: tag ?? '' }, - }); - } - - if (!problemShaped || !options.reportRawDriverMessages) return; - for (const key of options.messageKeys) { - const property = properties.get(key); - if (property === undefined) continue; - const value = propertyValue(property); - if (value === null || !leaksDriverMessage(value, options, 0)) continue; - context.report({ node: property, messageId: 'rawDriverMessage', data: { key } }); - } + const shape = problemShape(properties, options); + reportProblemShape(context, shape); + if (shape.problemShaped && options.reportRawDriverMessages) + reportDriverMessages(context, properties, options); }, }; }, diff --git a/app/tools/oxlint/effect-native/rules/no-hand-parsed-environment-value.ts b/app/tools/oxlint/effect-native/rules/no-hand-parsed-environment-value.ts index 53c0545bb..1cc4b0d8a 100644 --- a/app/tools/oxlint/effect-native/rules/no-hand-parsed-environment-value.ts +++ b/app/tools/oxlint/effect-native/rules/no-hand-parsed-environment-value.ts @@ -79,6 +79,7 @@ * Report-only: no fixers, no suggestions. */ import { defineRule } from '@oxlint/plugins'; +import { importedName } from '../shared/imports.ts'; import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; @@ -453,12 +454,7 @@ export const rule = defineRule({ return null; return { source: declaration.source.value, - member: - specifier.type === 'ImportSpecifier' - ? specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value - : 'default', + member: specifier.type === 'ImportSpecifier' ? importedName(specifier) : 'default', }; }; const processModule = (source: string) => source === 'process' || source === 'node:process'; @@ -469,6 +465,15 @@ export const rule = defineRule({ return value.quasis[0]?.value.cooked ?? null; return null; }; + const isNamedEnvHost = (host: AnyNode, name: string, depth: number): boolean => { + const imported = importOf(host); + if (imported) return processModule(imported.source) && imported.member === 'default'; + if (ENV_HOSTS.has(name) && isUnshadowedGlobal(context, host, name)) return true; + const declaration = declaratorOf(context, host); + return ( + declaration?.id.type === 'Identifier' && isEnvHost(declaration.init as AnyNode, depth + 1) + ); + }; const isEnvHost = (node: AnyNode | null, depth = 0): boolean => { const host = unwrap(node); if (!host || depth > MAX_DEPTH) return false; @@ -477,15 +482,10 @@ export const rule = defineRule({ if (host.type === 'MetaProperty') return host.meta.name === 'import' && host.property.name === 'meta'; const name = identifierName(host); - if (name) { - const imported = importOf(host); - if (imported) return processModule(imported.source) && imported.member === 'default'; - if (ENV_HOSTS.has(name) && isUnshadowedGlobal(context, host, name)) return true; - const declaration = declaratorOf(context, host); - return ( - declaration?.id.type === 'Identifier' && isEnvHost(declaration.init as AnyNode, depth + 1) - ); - } + if (name) return isNamedEnvHost(host, name, depth); + return isGlobalEnvHostMember(host); + }; + const isGlobalEnvHostMember = (host: AnyNode): boolean => { if (host.type !== 'MemberExpression' || !ENV_HOSTS.has(staticKey(host) ?? '')) return false; const owner = unwrap(host.object as AnyNode); const ownerName = identifierName(owner); @@ -495,6 +495,21 @@ export const rule = defineRule({ isUnshadowedGlobal(context, owner as AnyNode, ownerName) ); }; + const isDestructuredEnvBag = ( + declaration: ESTree.VariableDeclarator, + name: string, + ): boolean => { + if (declaration.id.type !== 'ObjectPattern' || !isEnvHost(declaration.init as AnyNode)) + return false; + return declaration.id.properties.some( + (property) => + property.type === 'Property' && + (property.computed + ? staticString(property.key) + : (identifierName(property.key) ?? staticString(property.key))) === 'env' && + identifierName(property.value) === name, + ); + }; /** Only statically known local aliases are followed; arbitrary returned records are unknown. */ const isAmbientEnvBag = (node: AnyNode | null, depth = 0): boolean => { const bag = unwrap(node); @@ -508,16 +523,7 @@ export const rule = defineRule({ if (!declaration) return false; if (declaration.id.type === 'Identifier') return isAmbientEnvBag(declaration.init as AnyNode, depth + 1); - if (declaration.id.type !== 'ObjectPattern' || !isEnvHost(declaration.init as AnyNode)) - return false; - return declaration.id.properties.some( - (property) => - property.type === 'Property' && - (property.computed - ? staticString(property.key) - : (identifierName(property.key) ?? staticString(property.key))) === 'env' && - identifierName(property.value) === bag.name, - ); + return isDestructuredEnvBag(declaration, bag.name); }; const isLiteralObject = (node: AnyNode | null, depth = 0): boolean => { const value = unwrap(node); @@ -537,6 +543,9 @@ export const rule = defineRule({ const record = unwrap(node); if (!record || depth > MAX_DEPTH) return false; if (isAmbientEnvBag(record)) return true; + return isNamedEnvironmentRecord(record, depth); + }; + const isNamedEnvironmentRecord = (record: AnyNode, depth: number): boolean => { const name = identifierName(record); if (!name) return false; const declaration = declaratorOf(context, record); @@ -570,73 +579,78 @@ export const rule = defineRule({ ); }; + const isComputedEnvironmentKey = (input: AnyNode): boolean => { + const property = unwrap(input); + if (property === null) return false; + const literalKey = staticString(property); + if (literalKey !== null) return SCREAMING_KEY.test(literalKey); + if (property.type !== 'Literal') return true; + const value = (property as { value?: unknown }).value; + return typeof value === 'string' && SCREAMING_KEY.test(value); + }; + const isEnvironmentMemberRead = (member: ESTree.MemberExpression, depth: number): boolean => { + const object = member.object as AnyNode; + if (isAmbientEnvBag(object)) return true; + if (!isEnvironmentRecord(object, depth)) return false; + if (member.computed) return isComputedEnvironmentKey(member.property as AnyNode); + const key = staticKey(member); + return key !== null && SCREAMING_KEY.test(key); + }; + const isEnvironmentReaderCall = (read: ESTree.CallExpression): boolean => { + const callee = unwrap(read.callee as AnyNode); + if (callee === null) return false; + if (identifierName(callee) !== null) return isReader(callee); + if (callee.type !== 'MemberExpression') return false; + const key = staticKey(callee); + if (key === null) return false; + if (readers.has(key)) return true; + return key === 'get' && isAmbientEnvBag(callee.object as AnyNode); + }; + const isEnvironmentBindingRead = (read: AnyNode, depth: number): boolean => { + const declarator = declaratorOf(context, read); + if (declarator === null) return false; + const target = declarator.id as AnyNode; + if (target.type === 'ObjectPattern' || target.type === 'ArrayPattern') + return isEnvironmentRecord(declarator.init as AnyNode | null, depth + 1); + return isEnvironmentDerived(declarator.init as AnyNode | null, depth + 1); + }; /** A read of a single environment variable. */ const isEnvironmentRead = (node: AnyNode | null, depth: number): boolean => { const read = unwrap(node); if (read === null || depth > MAX_DEPTH) return false; - - if (read.type === 'MemberExpression') { - const member = read as ESTree.MemberExpression; - const object = member.object as AnyNode; - // `process.env.DATABASE_URL` — the ambient bag has no non-configuration members. - if (isAmbientEnvBag(object)) return true; - if (!isEnvironmentRecord(object, depth)) return false; - if (member.computed) { - const property = unwrap(member.property as AnyNode); - if (property === null) return false; - const literalKey = staticString(property); - if (literalKey !== null) return SCREAMING_KEY.test(literalKey); - if (property.type !== 'Literal') return true; // `environment[name]` - const value = (property as { value?: unknown }).value; - return typeof value === 'string' && SCREAMING_KEY.test(value); - } - const key = staticKey(member); - return key !== null && SCREAMING_KEY.test(key); - } - - if (read.type === 'CallExpression') { - const callee = unwrap((read as ESTree.CallExpression).callee as AnyNode); - if (callee === null) return false; - const calleeName = identifierName(callee); - if (calleeName !== null) return isReader(callee); - if (callee.type !== 'MemberExpression') return false; - const key = staticKey(callee as ESTree.MemberExpression); - if (key === null) return false; - if (readers.has(key)) return true; - // `Deno.env.get('X')`, `process.env.get?.('X')`. - return ( - key === 'get' && isAmbientEnvBag((callee as ESTree.MemberExpression).object as AnyNode) - ); - } - - if (read.type === 'Identifier') { - const declarator = declaratorOf(context, read); - if (declarator === null) return false; - const target = declarator.id as AnyNode; - // `const { DATABASE_URL } = process.env`. - if (target.type === 'ObjectPattern' || target.type === 'ArrayPattern') { - return isEnvironmentRecord(declarator.init as AnyNode | null, depth + 1); - } - return isEnvironmentDerived(declarator.init as AnyNode | null, depth + 1); + switch (read.type) { + case 'MemberExpression': + return isEnvironmentMemberRead(read, depth); + case 'CallExpression': + return isEnvironmentReaderCall(read); + case 'Identifier': + return isEnvironmentBindingRead(read, depth); + default: + return false; } - - return false; }; + const isDerivedLogical = (logical: ESTree.LogicalExpression, depth: number): boolean => { + if (logical.operator !== '??' && logical.operator !== '||') return false; + return ( + isEnvironmentDerived(logical.left as AnyNode, depth + 1) || + isEnvironmentDerived(logical.right as AnyNode, depth + 1) + ); + }; + const isDerivedStringCall = (value: ESTree.CallExpression, depth: number): boolean => { + const callee = unwrap((value as ESTree.CallExpression).callee as AnyNode); + if (callee === null || callee.type !== 'MemberExpression') return false; + const key = staticKey(callee as ESTree.MemberExpression); + if (key === null || !DERIVING_STRING_OPS.has(key)) return false; + return isEnvironmentDerived((callee as ESTree.MemberExpression).object as AnyNode, depth + 1); + }; /** An environment read, possibly defaulted, interpolated or passed through a string op. */ function isEnvironmentDerived(node: AnyNode | null, depth: number): boolean { const value = unwrap(node); if (value === null || depth > MAX_DEPTH) return false; if (isEnvironmentRead(value, depth)) return true; - if (value.type === 'LogicalExpression') { - const logical = value as ESTree.LogicalExpression; - if (logical.operator !== '??' && logical.operator !== '||') return false; - return ( - isEnvironmentDerived(logical.left as AnyNode, depth + 1) || - isEnvironmentDerived(logical.right as AnyNode, depth + 1) - ); - } + if (value.type === 'LogicalExpression') return isDerivedLogical(value, depth); if (value.type === 'ConditionalExpression') return ( @@ -651,16 +665,7 @@ export const rule = defineRule({ ); } - if (value.type === 'CallExpression') { - const callee = unwrap((value as ESTree.CallExpression).callee as AnyNode); - if (callee === null || callee.type !== 'MemberExpression') return false; - const key = staticKey(callee as ESTree.MemberExpression); - if (key === null || !DERIVING_STRING_OPS.has(key)) return false; - return isEnvironmentDerived( - (callee as ESTree.MemberExpression).object as AnyNode, - depth + 1, - ); - } + if (value.type === 'CallExpression') return isDerivedStringCall(value, depth); return false; } @@ -689,110 +694,137 @@ export const rule = defineRule({ return verdict; }; - function computeClassification( - node: AnyNode, - ): { readonly messageId: string; readonly data: Record } | null { - if (node.type === 'CallExpression') { - const call = node as ESTree.CallExpression; - const callee = unwrap(call.callee as AnyNode); - if (callee === null) return null; - const firstArgument = (call.arguments[0] as AnyNode | undefined) ?? null; - - if (callee.type === 'MemberExpression') { - const member = callee as ESTree.MemberExpression; - const key = staticKey(member); - if (key === null) return null; - const owner = unwrap(member.object as AnyNode); - const ownerName = identifierName(owner); - // `JSON.parse(...)` / `Number.parseInt(...)`. - const namespaced = ownerName === null ? undefined : NAMESPACED_PARSERS.get(ownerName); - if ( - namespaced !== undefined && - namespaced.has(key) && - isUnshadowedGlobal(context, owner as AnyNode, ownerName as string) && - firstArgument !== null && - firstArgument.type !== 'SpreadElement' && - isEnvironmentDerived(firstArgument, 0) - ) { - return ownerName === 'JSON' - ? { messageId: 'envJsonParse', data: { operation: `${ownerName}.${key}` } } - : { - messageId: - ownerName === 'URL' || ownerName === 'Date' - ? 'envStructuredParse' - : 'envCoercion', - data: { operation: `${ownerName}.${key}` }, - }; - } - // `environment['X'].trim()`, `env.MODE.split(',')`. - if (!MEMBER_PARSE_OPS.has(key)) return null; - return isEnvironmentDerived(member.object as AnyNode, 0) - ? { messageId: 'envStringSurgery', data: { operation: key } } - : null; - } - - const calleeName = identifierName(callee); - if (calleeName === null || !GLOBAL_COERCIONS.has(calleeName)) return null; - if (!isUnshadowedGlobal(context, callee, calleeName)) return null; - if (firstArgument === null || firstArgument.type === 'SpreadElement') return null; - return isEnvironmentDerived(firstArgument, 0) - ? { messageId: 'envCoercion', data: { operation: calleeName } } - : null; - } - - if (node.type === 'NewExpression') { - const construction = node as ESTree.NewExpression; - const callee = unwrap(construction.callee as AnyNode); - const calleeName = identifierName(callee); - if (calleeName === null || !STRUCTURED_CONSTRUCTORS.has(calleeName)) return null; - if (!isUnshadowedGlobal(context, callee as AnyNode, calleeName)) return null; - const firstArgument = (construction.arguments[0] as AnyNode | undefined) ?? null; - if (firstArgument === null || firstArgument.type === 'SpreadElement') return null; - return isEnvironmentDerived(firstArgument, 0) - ? { messageId: 'envStructuredParse', data: { operation: calleeName } } - : null; - } - - if (node.type === 'UnaryExpression') { - if (node.operator === '!' && isEnvironmentLength(node.argument)) - return { messageId: 'envLengthCheck', data: { operation: 'length' } }; - if ( - (node.operator === '+' || node.operator === '-') && - isEnvironmentDerived(node.argument, 0) - ) - return { messageId: 'envCoercion', data: { operation: node.operator } }; - } - if (node.type === 'SwitchStatement' && isEnvironmentDerived(node.discriminant, 0)) { - const branch = node.cases.find((entry) => isLiteralValue(entry.test)); - if (branch?.test) - return { - messageId: 'envLiteralComparison', - data: { literal: context.sourceCode.getText(branch.test) }, - }; - } - if (node.type === 'BinaryExpression') { - const comparison = node as ESTree.BinaryExpression; - if (!COMPARISON_OPERATORS.has(comparison.operator)) return null; - const left = comparison.left as AnyNode; - const right = comparison.right as AnyNode; - if (isEnvironmentLength(left) || isEnvironmentLength(right)) { - return { messageId: 'envLengthCheck', data: { operation: 'length' } }; - } - const leftValue = unwrap(left); - const rightValue = unwrap(right); - let literal: AnyNode | null = null; - if (isLiteralValue(rightValue) && isEnvironmentDerived(left, 0)) literal = rightValue; - else if (isLiteralValue(leftValue) && isEnvironmentDerived(right, 0)) literal = leftValue; - if (literal === null) return null; - const raw = (literal as { raw?: string | null }).raw; + type Classification = { + readonly messageId: string; + readonly data: Record; + } | null; + function namespaceMessage(name: string | null): string { + if (name === 'JSON') return 'envJsonParse'; + return name === 'URL' || name === 'Date' ? 'envStructuredParse' : 'envCoercion'; + } + function classifyNamespacedCall( + member: ESTree.MemberExpression, + key: string, + firstArgument: AnyNode | null, + ): Classification { + const owner = unwrap(member.object as AnyNode); + const ownerName = identifierName(owner); + // `JSON.parse(...)` / `Number.parseInt(...)`. + const namespaced = ownerName === null ? undefined : NAMESPACED_PARSERS.get(ownerName); + if ( + namespaced !== undefined && + namespaced.has(key) && + isUnshadowedGlobal(context, owner as AnyNode, ownerName as string) && + firstArgument !== null && + firstArgument.type !== 'SpreadElement' && + isEnvironmentDerived(firstArgument, 0) + ) { return { - messageId: 'envLiteralComparison', - data: { literal: raw ?? String((literal as { value?: unknown }).value) }, + messageId: namespaceMessage(ownerName), + data: { operation: `${ownerName}.${key}` }, }; } - return null; } + function classifyMemberCall( + member: ESTree.MemberExpression, + firstArgument: AnyNode | null, + ): Classification { + const key = staticKey(member); + if (key === null) return null; + const namespaced = classifyNamespacedCall(member, key, firstArgument); + if (namespaced !== null) return namespaced; + // `environment['X'].trim()`, `env.MODE.split(',')`. + if (!MEMBER_PARSE_OPS.has(key)) return null; + return isEnvironmentDerived(member.object as AnyNode, 0) + ? { messageId: 'envStringSurgery', data: { operation: key } } + : null; + } + function classifyCall(node: ESTree.CallExpression): Classification { + const call = node as ESTree.CallExpression; + const callee = unwrap(call.callee as AnyNode); + if (callee === null) return null; + const firstArgument = (call.arguments[0] as AnyNode | undefined) ?? null; + + if (callee.type === 'MemberExpression') return classifyMemberCall(callee, firstArgument); + const calleeName = identifierName(callee); + if (calleeName === null || !GLOBAL_COERCIONS.has(calleeName)) return null; + if (!isUnshadowedGlobal(context, callee, calleeName)) return null; + if (firstArgument === null || firstArgument.type === 'SpreadElement') return null; + return isEnvironmentDerived(firstArgument, 0) + ? { messageId: 'envCoercion', data: { operation: calleeName } } + : null; + } + function classifyConstruction(node: ESTree.NewExpression): Classification { + const construction = node as ESTree.NewExpression; + const callee = unwrap(construction.callee as AnyNode); + const calleeName = identifierName(callee); + if (calleeName === null || !STRUCTURED_CONSTRUCTORS.has(calleeName)) return null; + if (!isUnshadowedGlobal(context, callee as AnyNode, calleeName)) return null; + const firstArgument = (construction.arguments[0] as AnyNode | undefined) ?? null; + if (firstArgument === null || firstArgument.type === 'SpreadElement') return null; + return isEnvironmentDerived(firstArgument, 0) + ? { messageId: 'envStructuredParse', data: { operation: calleeName } } + : null; + } + function classifyUnary(node: ESTree.UnaryExpression): Classification { + if (node.operator === '!' && isEnvironmentLength(node.argument)) + return { messageId: 'envLengthCheck', data: { operation: 'length' } }; + if ( + (node.operator === '+' || node.operator === '-') && + isEnvironmentDerived(node.argument, 0) + ) + return { messageId: 'envCoercion', data: { operation: node.operator } }; + return null; + } + function classifySwitch(node: ESTree.SwitchStatement): Classification { + if (!isEnvironmentDerived(node.discriminant, 0)) return null; + const branch = node.cases.find((entry) => isLiteralValue(entry.test)); + if (!branch?.test) return null; + return { + messageId: 'envLiteralComparison', + data: { literal: context.sourceCode.getText(branch.test) }, + }; + } + function classifyComparison( + node: ESTree.BinaryExpression | ESTree.PrivateInExpression, + ): Classification { + const comparison = node as ESTree.BinaryExpression; + if (!COMPARISON_OPERATORS.has(comparison.operator)) return null; + const left = comparison.left as AnyNode; + const right = comparison.right as AnyNode; + if (isEnvironmentLength(left) || isEnvironmentLength(right)) { + return { messageId: 'envLengthCheck', data: { operation: 'length' } }; + } + const leftValue = unwrap(left); + const rightValue = unwrap(right); + let literal: AnyNode | null = null; + if (isLiteralValue(rightValue) && isEnvironmentDerived(left, 0)) literal = rightValue; + else if (isLiteralValue(leftValue) && isEnvironmentDerived(right, 0)) literal = leftValue; + if (literal === null) return null; + const raw = (literal as { raw?: string | null }).raw; + return { + messageId: 'envLiteralComparison', + data: { literal: raw ?? String((literal as { value?: unknown }).value) }, + }; + } + /** The diagnostic a node would raise on its own. */ + function computeClassification(node: AnyNode): Classification { + switch (node.type) { + case 'CallExpression': + return classifyCall(node); + case 'NewExpression': + return classifyConstruction(node); + case 'UnaryExpression': + return classifyUnary(node); + case 'SwitchStatement': + return classifySwitch(node); + case 'BinaryExpression': + return classifyComparison(node); + default: + return null; + } + } /** Only the outermost hand parse reports; an enclosing parse always wins. */ const hasReportableAncestor = (node: AnyNode): boolean => { diff --git a/app/tools/oxlint/effect-native/rules/no-hand-rolled-tagged-union.ts b/app/tools/oxlint/effect-native/rules/no-hand-rolled-tagged-union.ts index e3dc94d08..202bcc488 100644 --- a/app/tools/oxlint/effect-native/rules/no-hand-rolled-tagged-union.ts +++ b/app/tools/oxlint/effect-native/rules/no-hand-rolled-tagged-union.ts @@ -84,14 +84,9 @@ import type { Context, ESTree } from '@oxlint/plugins'; import { collectEffectBindings } from '../shared/effect-imports.ts'; import type { EffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; - -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production `include` defaults instead of - * forcing the fixture config to pass loosened options (which `run-on-repo.mts` reuses). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; +import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; +import { optionRecord, stringArray } from '../shared/options.ts'; +import { keyName } from '../shared/ast.ts'; const DEFAULT_DISCRIMINANT_KEYS: readonly string[] = ['_tag']; @@ -128,18 +123,8 @@ interface RuleOptions { readonly ignoreAmbient: boolean; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { discriminantKeys: stringArray(record.discriminantKeys, DEFAULT_DISCRIMINANT_KEYS), include: stringArray(record.include, DEFAULT_INCLUDE), @@ -152,21 +137,9 @@ function readOptions(context: Context): RuleOptions { }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -/** Static string name of a property-signature key (`_tag`, `"_tag"`, `["_tag"]`). */ +/** Static property-signature keys allow computed string literals, not templates or computed identifiers. */ function propertyKeyName(node: ESTree.TSPropertySignature): string | null { - const key = node.key; - if (key.type === 'Identifier') return node.computed ? null : key.name; - if (key.type === 'Literal' && typeof key.value === 'string') return key.value; - return null; + return keyName(node.key, node.computed, { templates: false }); } /** @@ -190,33 +163,33 @@ function noSubstitutionTemplate( * not a closed set of string literals (`string`, a type reference, a *substituting* template-literal * type, a generic parameter, …). */ +function singletonTag(value: string | null): readonly string[] | null { + return value === null ? null : [value]; +} + +function literalTags(literal: ESTree.Node): readonly string[] | null { + if (literal.type === 'Literal') return typeof literal.value === 'string' ? [literal.value] : null; + if (literal.type === 'TemplateLiteral') + return singletonTag(noSubstitutionTemplate(literal.quasis, literal.expressions.length)); + return null; +} + +function unionTags(types: readonly ESTree.Node[]): readonly string[] | null { + const values: string[] = []; + for (const member of types) { + const nested = tagLiterals(member); + if (nested === null) return null; + values.push(...nested); + } + return values.length > 0 ? values : null; +} + function tagLiterals(type: ESTree.Node): readonly string[] | null { if (type.type === 'TSParenthesizedType') return tagLiterals(type.typeAnnotation); - if (type.type === 'TSLiteralType') { - const literal = type.literal; - if (literal.type === 'Literal') - return typeof literal.value === 'string' ? [literal.value] : null; - // oxc parses a no-substitution template in type position as a `TemplateLiteral` literal. - if (literal.type === 'TemplateLiteral') { - const cooked = noSubstitutionTemplate(literal.quasis, literal.expressions.length); - return cooked === null ? null : [cooked]; - } - return null; - } - // Belt and braces: some parses spell the same thing as a zero-substitution template-literal type. - if (type.type === 'TSTemplateLiteralType') { - const cooked = noSubstitutionTemplate(type.quasis, type.types.length); - return cooked === null ? null : [cooked]; - } - if (type.type === 'TSUnionType') { - const values: string[] = []; - for (const member of type.types) { - const nested = tagLiterals(member); - if (nested === null) return null; - values.push(...nested); - } - return values.length > 0 ? values : null; - } + if (type.type === 'TSLiteralType') return literalTags(type.literal); + if (type.type === 'TSTemplateLiteralType') + return singletonTag(noSubstitutionTemplate(type.quasis, type.types.length)); + if (type.type === 'TSUnionType') return unionTags(type.types); return null; } @@ -284,6 +257,64 @@ function expressionReferenceName( * `null` (the signature belongs to a query, a conditional, a generic constraint, a function * signature, a value annotation, … and must not report). */ +const TRANSPARENT_TYPE_ANCESTORS: ReadonlySet = new Set([ + 'TSInterfaceBody', + 'TSTypeLiteral', + 'TSUnionType', + 'TSIntersectionType', + 'TSParenthesizedType', + 'TSArrayType', + 'TSTupleType', + 'TSNamedTupleMember', + 'TSOptionalType', + 'TSRestType', + 'TSInterfaceHeritage', + 'TSTypeReference', +]); + +function hasTransparentParameterOwner( + node: ESTree.Node, + options: RuleOptions, + bindings: EffectBindings, +): boolean { + const owner = node.parent; + if (owner == null) return false; + if (owner.type === 'TSTypeReference') return isTransparentWrapper(owner, options, bindings); + if (owner.type === 'TSInterfaceHeritage') return isTransparentHeritage(owner, options, bindings); + return false; +} + +function isTransparentAncestor( + current: ESTree.Node, + previous: ESTree.Node, + options: RuleOptions, + bindings: EffectBindings, +): boolean { + if (TRANSPARENT_TYPE_ANCESTORS.has(current.type)) return true; + switch (current.type) { + case 'TSTypeOperator': + return current.operator === 'readonly'; + case 'TSTypeAnnotation': + return current.typeAnnotation === previous; + case 'TSPropertySignature': + return options.includeNestedTypes; + case 'TSTypeParameterInstantiation': + return hasTransparentParameterOwner(current, options, bindings); + default: + return false; + } +} + +function declarationName(current: ESTree.Node, previous: ESTree.Node): string | null { + if (current.type === 'TSInterfaceDeclaration') { + const owns = current.body === previous || previous.type === 'TSInterfaceHeritage'; + return owns ? current.id.name : null; + } + if (current.type === 'TSTypeAliasDeclaration') + return current.typeAnnotation === previous ? current.id.name : null; + return null; +} + function owningDeclaration( signature: ESTree.TSPropertySignature, options: RuleOptions, @@ -292,53 +323,10 @@ function owningDeclaration( let previous: ESTree.Node = signature; let current: ESTree.Node | null | undefined = signature.parent; for (let depth = 0; depth < MAX_ANCESTOR_DEPTH; depth += 1) { - if (current === null || current === undefined) return null; - switch (current.type) { - case 'TSInterfaceDeclaration': - // Either the interface body itself, or an `extends Readonly<{ … }>` heritage clause that the - // `TSTypeParameterInstantiation` arm below already proved transparent. - if (current.body === previous) return current.id.name; - return previous.type === 'TSInterfaceHeritage' ? current.id.name : null; - case 'TSTypeAliasDeclaration': - return current.typeAnnotation === previous ? current.id.name : null; - case 'TSInterfaceBody': - case 'TSTypeLiteral': - case 'TSUnionType': - case 'TSIntersectionType': - case 'TSParenthesizedType': - case 'TSArrayType': - case 'TSTupleType': - case 'TSNamedTupleMember': - case 'TSOptionalType': - case 'TSRestType': - case 'TSInterfaceHeritage': - case 'TSTypeReference': - break; - case 'TSTypeOperator': - if (current.operator !== 'readonly') return null; - break; - case 'TSTypeAnnotation': - if (current.typeAnnotation !== previous) return null; - break; - case 'TSPropertySignature': - if (!options.includeNestedTypes) return null; - break; - case 'TSTypeParameterInstantiation': { - const owner = current.parent; - if (owner === null || owner === undefined) return null; - if (owner.type === 'TSTypeReference') { - if (!isTransparentWrapper(owner, options, bindings)) return null; - break; - } - if (owner.type === 'TSInterfaceHeritage') { - if (!isTransparentHeritage(owner, options, bindings)) return null; - break; - } - return null; - } - default: - return null; - } + if (current == null) return null; + const name = declarationName(current, previous); + if (name !== null) return name; + if (!isTransparentAncestor(current, previous, options, bindings)) return null; previous = current; current = current.parent; } @@ -348,10 +336,7 @@ function owningDeclaration( /** Static string name of a class member key (`_tag`, `"_tag"`), or `null` for computed/private keys. */ function classKeyName(node: ESTree.PropertyDefinition): string | null { if (node.computed) return null; - const key = node.key; - if (key.type === 'Identifier') return key.name; - if (key.type === 'Literal' && typeof key.value === 'string') return key.value; - return null; + return keyName(node.key); } /** @@ -386,21 +371,28 @@ function enclosingClass(node: ESTree.PropertyDefinition): ESTree.Class | null { * *blessed* form; only a base resolved through a tracked `effect` / `effect/*` binding counts, so a * same-named local helper class stays in scope. */ +function baseExpression(node: ESTree.Node): ESTree.Node | null { + switch (node.type) { + case 'CallExpression': + case 'NewExpression': + return node.callee; + case 'TSInstantiationExpression': + case 'ParenthesizedExpression': + case 'TSNonNullExpression': + case 'TSAsExpression': + return node.expression; + default: + return null; + } +} + function derivesFromEffectBase(node: ESTree.Class, bindings: EffectBindings): boolean { let base: ESTree.Node | null | undefined = node.superClass; for (let depth = 0; depth < MAX_ANCESTOR_DEPTH; depth += 1) { if (base === null || base === undefined) return false; - if (base.type === 'CallExpression' || base.type === 'NewExpression') { - base = base.callee; - continue; - } - if ( - base.type === 'TSInstantiationExpression' || - base.type === 'ParenthesizedExpression' || - base.type === 'TSNonNullExpression' || - base.type === 'TSAsExpression' - ) { - base = base.expression; + const inner = baseExpression(base); + if (inner !== null) { + base = inner; continue; } const resolved = expressionReferenceName(base); @@ -430,6 +422,16 @@ function isAmbient(node: ESTree.Node): boolean { return false; } +function shouldCheckClassField(node: ESTree.PropertyDefinition, options: RuleOptions): boolean { + return options.includeClassFields && !(options.ignoreAmbient && isAmbient(node)); +} + +function classFieldTags(node: ESTree.PropertyDefinition): readonly string[] | null { + if (node.typeAnnotation != null) return tagLiterals(node.typeAnnotation.typeAnnotation); + if (node.value == null) return null; + return singletonTag(initialiserTag(node.value)); +} + export const rule = defineRule({ meta: { type: 'problem', @@ -501,18 +503,10 @@ export const rule = defineRule({ let bindings: EffectBindings = { namespaces: new Map(), importsEffect: false }; function reportClassField(node: ESTree.PropertyDefinition): void { - if (!options.includeClassFields || (options.ignoreAmbient && isAmbient(node))) return; + if (!shouldCheckClassField(node, options)) return; const key = classKeyName(node); if (key === null || !options.discriminantKeys.includes(key)) return; - const annotation = node.typeAnnotation; - const literals = - annotation === null || annotation === undefined - ? ((): readonly string[] | null => { - if (node.value === null || node.value === undefined) return null; - const tag = initialiserTag(node.value); - return tag === null ? null : [tag]; - })() - : tagLiterals(annotation.typeAnnotation); + const literals = classFieldTags(node); if (literals === null) return; const owner = enclosingClass(node); if (owner === null) return; diff --git a/app/tools/oxlint/effect-native/rules/no-imperative-loop-in-effect-gen.ts b/app/tools/oxlint/effect-native/rules/no-imperative-loop-in-effect-gen.ts index 65f6d4bce..b68bcd1a8 100644 --- a/app/tools/oxlint/effect-native/rules/no-imperative-loop-in-effect-gen.ts +++ b/app/tools/oxlint/effect-native/rules/no-imperative-loop-in-effect-gen.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * effect-native/no-imperative-loop-in-effect-gen * @@ -73,19 +74,27 @@ import { defineRule } from '@oxlint/plugins'; import type { Context, ESTree } from '@oxlint/plugins'; -import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isScriptFile, isTestFile, normalisePath } from '../shared/paths.ts'; - -/** Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; - -const EFFECT_NAMESPACE = 'Effect'; -const EFFECT_ROOT_MODULE = 'effect'; -const EFFECT_EFFECT_MODULE = /^effect\/(?:.*\/)?Effect$/u; +import { + asNode as sharedAsNode, + FUNCTION_TYPES, + identityUnwrap, + nearestFunction as enclosingFunction, + parentOf, + walk, + type Syntax as AnyNode, +} from '../shared/ast.ts'; +import { lookupVariable as lexicalVariable } from '../shared/bindings.ts'; +import { isGenCallee } from '../shared/effect-identity.ts'; +import { + bindingsWithExtraModules, + collectDirectMemberImports, + collectRootNamespaces, +} from '../shared/imports.ts'; +import { booleanOption as boolean, stringArray } from '../shared/options.ts'; +import { isScriptFile, isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**']; const DEFAULT_IGNORE = ['**/dist/**', '**/build/**', '**/node_modules/**', 'tools/**', '**/*.d.ts']; -const DEFAULT_SCRIPT_GLOBS = ['scripts/**', '**/scripts/**']; /** Wrappers whose generator argument is an Effect program body. */ const DEFAULT_GEN_MEMBERS = ['gen', 'fn', 'fnUntraced']; /** Barrels that re-export `Effect` verbatim, so `Effect.gen` there is the same generator. */ @@ -94,11 +103,6 @@ const DEFAULT_EFFECT_MODULES = [ '@modern-js/plugin-bff/effect-edge', ]; -const FUNCTION_TYPES = new Set([ - 'ArrowFunctionExpression', - 'FunctionDeclaration', - 'FunctionExpression', -]); const MEMBER_TYPES = new Set([ 'ComputedMemberExpression', 'MemberExpression', @@ -123,22 +127,8 @@ interface RuleOptions { readonly effectModules: readonly string[]; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - -function boolean(value: unknown, fallback: boolean): boolean { - return typeof value === 'boolean' ? value : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { include: stringArray(record.include, DEFAULT_INCLUDE), ignore: stringArray(record.ignore, DEFAULT_IGNORE), @@ -151,47 +141,24 @@ function readOptions(context: Context): RuleOptions { }; } -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -interface AnyNode { - readonly type: string; - readonly start: number; - readonly end: number; - readonly parent?: AnyNode | null; - readonly [key: string]: unknown; -} +const LOOP_EXPRESSION_WRAPPERS = new Set([ + 'ParenthesizedExpression', + 'ChainExpression', + 'TSNonNullExpression', + 'TSAsExpression', + 'TSSatisfiesExpression', + 'TSInstantiationExpression', +]); function asNode(value: unknown): AnyNode | null { - if (typeof value !== 'object' || value === null) return null; - const candidate = value as { type?: unknown; start?: unknown }; - if (typeof candidate.type !== 'string' || typeof candidate.start !== 'number') return null; - return value as AnyNode; -} - -function parentOf(node: AnyNode | null): AnyNode | null { - return (node?.parent as AnyNode | null | undefined) ?? null; + return sharedAsNode(value, true); } -/** Strip parens, `!`, `as`, `satisfies` and optional-chaining wrappers to reach the real expression. */ +/** Keep the loop walker's 16-hop bound and validate every wrapper child's span. */ function unwrap(value: unknown): AnyNode | null { let current = asNode(value); - for (let guard = 0; current !== null && guard < 16; guard += 1) { - if ( - current.type !== 'ParenthesizedExpression' && - current.type !== 'ChainExpression' && - current.type !== 'TSNonNullExpression' && - current.type !== 'TSAsExpression' && - current.type !== 'TSSatisfiesExpression' && - current.type !== 'TSInstantiationExpression' - ) { - return current; - } + for (let depth = 0; current !== null && depth < 16; depth += 1) { + if (!LOOP_EXPRESSION_WRAPPERS.has(current.type)) return current; const inner = asNode(current.expression); if (inner === null) return current; current = inner; @@ -199,147 +166,16 @@ function unwrap(value: unknown): AnyNode | null { return current; } -/** Non-computed `.gen`, or computed `["gen"]`. */ -function memberName(node: AnyNode): string | null { - const property = asNode(node.property); - if (property === null) return null; - if (node.computed !== true) - return property.type === 'Identifier' ? (property.name as string) : null; - if (property.type === 'Literal' && typeof property.value === 'string') return property.value; - if (property.type === 'StringLiteral' && typeof property.value === 'string') - return property.value; - return null; -} - -/** Locals bound by `import * as X from "effect"` — `X.Effect.gen` must still be recognised. */ -function collectRootNamespaces(program: ESTree.Program): ReadonlySet { - const locals = new Set(); - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - if (statement.source.value !== EFFECT_ROOT_MODULE) continue; - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportNamespaceSpecifier') locals.add(specifier.local.name); - } - } - return locals; -} - -/** Locals bound by `import { gen, fn } from "effect/Effect"` — bare `gen(function* ())` must be caught. */ -function collectDirectMemberImports( - program: ESTree.Program, - members: readonly string[], -): ReadonlySet { - const locals = new Set(); - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - if (!EFFECT_EFFECT_MODULE.test(statement.source.value)) continue; - for (const specifier of statement.specifiers) { - if (specifier.type !== 'ImportSpecifier') continue; - const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; - if (members.includes(imported)) locals.add(specifier.local.name); - } - } - return locals; -} - -/** Extend `effect` bindings with named `Effect` imports from configured re-export barrels. */ -function bindingsWithExtraModules( - program: ESTree.Program, - modules: readonly string[], -): EffectBindings { - const base = collectEffectBindings(program); - if (modules.length === 0) return base; - const namespaces = new Map(base.namespaces); - let importsEffect = base.importsEffect; - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - if (!modules.includes(statement.source.value)) continue; - for (const specifier of statement.specifiers) { - if (specifier.type !== 'ImportSpecifier') continue; - const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; - if (imported !== EFFECT_NAMESPACE) continue; - namespaces.set(specifier.local.name, EFFECT_NAMESPACE); - importsEffect = true; - } - } - return { importsEffect, namespaces }; -} - -interface GeneratorMatcher { - readonly context: Context; - readonly effectModules: readonly string[]; - readonly bindings: EffectBindings; - readonly rootNamespaces: ReadonlySet; - readonly directMembers: ReadonlySet; - readonly genMembers: readonly string[]; -} - -/** `Effect.gen` / `E.gen` / `X.Effect.gen` / bare `gen` (direct member import), incl. computed + optional. */ -function isGenCallee(callee: AnyNode | null, matcher: GeneratorMatcher): boolean { - if (callee === null) return false; - const target = identityUnwrap(callee as unknown as ESTree.Node); - if (target.type === 'CallExpression') return isGenCallee(asNode(target.callee), matcher); - const path = bindingPath(matcher.context, target, matcher.effectModules); - return path?.length === 2 && path[0] === 'Effect' && matcher.genMembers.includes(path[1] ?? ''); -} - -/** Nearest enclosing function of `node`, or `null` at `Program` level. */ -function enclosingFunction(node: AnyNode): AnyNode | null { - let current = parentOf(node); - while (current !== null) { - if (FUNCTION_TYPES.has(current.type)) return current; - current = parentOf(current); - } - return null; -} - -type Walker = (node: AnyNode) => boolean; - -function childrenOf( - node: AnyNode, - visitorKeys: Readonly>, -): AnyNode[] { - const keys = visitorKeys[node.type]; - const names = keys ?? Object.keys(node).filter((key) => key !== 'parent' && key !== 'type'); - const children: AnyNode[] = []; - for (const name of names) { - const value = node[name]; - if (Array.isArray(value)) { - for (const entry of value) { - const child = asNode(entry); - if (child !== null) children.push(child); - } - continue; - } - const child = asNode(value); - if (child !== null) children.push(child); - } - return children; -} - -/** Depth-first walk; `visit` returns `false` to skip the node's children. */ -function walk( - node: AnyNode, - visitorKeys: Readonly>, - visit: Walker, -): void { - const stack: AnyNode[] = [node]; - while (stack.length > 0) { - const current = stack.pop(); - if (current === undefined) break; - if (!visit(current)) continue; - const children = childrenOf(current, visitorKeys); - for (let index = children.length - 1; index >= 0; index -= 1) { - const child = children[index]; - if (child !== undefined) stack.push(child); - } +/** A return exits the loop unless a surrounding try can override it. */ +function isTerminalYield(node: AnyNode, loop: AnyNode): boolean { + let parent = parentOf(node); + while (parent !== null && unwrap(parent) === node) parent = parentOf(parent); + if (parent?.type !== 'ReturnStatement') return false; + while (parent !== null && parent !== loop) { + if (parent.type === 'TryStatement') return false; + parent = parentOf(parent); } + return true; } /** @@ -355,16 +191,8 @@ function containsDelegatingYield( if (found) return false; if (node !== loop && FUNCTION_TYPES.has(node.type)) return false; if (node.type === 'YieldExpression' && node.delegate === true) { - // A pure search that returns its only yield cannot sequence effects across iterations. - // A return inside try/finally may be overridden, and a throw may be caught: keep those. - let parent = parentOf(node); - while (parent !== null && unwrap(parent) === node) parent = parentOf(parent); - let terminal = parent?.type === 'ReturnStatement'; - while (terminal && parent !== null && parent !== loop) { - if (parent.type === 'TryStatement') terminal = false; - parent = parentOf(parent); - } - if (!terminal) found = true; + // Returns in try/finally may be overridden, so those yields still sequence effects. + found = !isTerminalYield(node, loop); return false; } return true; @@ -391,10 +219,7 @@ function collectLoopMutations( target = unwrap(target); while (target !== null && MEMBER_TYPES.has(target.type)) target = unwrap(target.object); if (target?.type === 'Identifier') { - const variable = lexicalVariable( - context, - target as unknown as Extract, - ); + const variable = lexicalVariable(context, target); if (variable !== null) assigned.add(variable); } return true; @@ -475,108 +300,59 @@ function collectGeneratorLets( return declarations; } -// Resolve lexical value bindings, not identifier spellings. Only immutable local aliases are -// followed; arbitrary object mutation, re-export contents and dynamic keys need type/data-flow analysis. -function lexicalVariable(context: Context, node: Extract) { - let scope: import('@oxlint/plugins').Scope | null = context.sourceCode.getScope(node); - while (scope !== null) { - const variable = scope.set.get(node.name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; +function isIncludedFile(context: Context, options: RuleOptions): boolean { + const path = scopePath(context.filename); + if (matchesGlobs(path, options.ignore)) return false; + const inScripts = isScriptFile(path); + if (!options.includeScripts && inScripts) return false; + // Opting scripts in also widens the default include globs. + if (!matchesGlobs(path, options.include) && !(options.includeScripts && inScripts)) return false; + return options.includeTests || !isTestFile(path); +} + +function allowsUnmutatingForOf( + loop: AnyNode, + options: RuleOptions, + mutatesOuter: boolean, +): boolean { + return options.allowForOfWithoutMutation && loop.type === 'ForOfStatement' && !mutatesOuter; } -function staticString(node: ESTree.Node): string | null { - if (node.type === 'Literal' && typeof node.value === 'string') return node.value; - if (node.type === 'TemplateLiteral' && node.expressions.length === 0) - return node.quasis[0]?.value.cooked ?? null; - return null; + +function hasGeneratorImports(program: ESTree.Program, options: RuleOptions): boolean { + const rootNamespaces = collectRootNamespaces(program); + const directMembers = collectDirectMemberImports( + program, + new Map([['Effect', new Set(options.genMembers)]]), + ); + const bindings = bindingsWithExtraModules(program, options.effectModules); + return bindings.importsEffect || rootNamespaces.size > 0 || directMembers.size > 0; } -function identityUnwrap(node: ESTree.Node): ESTree.Node { - let current = node; - for (;;) { - if (current.type === 'SequenceExpression') { - const last = current.expressions.at(-1); - if (last === undefined) return current; - current = last; - } else if ( - [ - 'ChainExpression', - 'ParenthesizedExpression', - 'TSAsExpression', - 'TSTypeAssertion', - 'TSNonNullExpression', - 'TSSatisfiesExpression', - 'TSInstantiationExpression', - ].includes(current.type) - ) { - current = (current as unknown as { expression: ESTree.Node }).expression; - } else return current; - } + +function generatorDefinitionValue( + context: Context, + definition: import('@oxlint/plugins').Variable['defs'][number] | undefined, + seen: Set, +): ESTree.Node | null { + if (definition?.type === 'FunctionName') return definition.node; + if (definition?.type !== 'Variable') return null; + const declaration = definition.node as ESTree.VariableDeclarator; + if ((definition.parent as ESTree.VariableDeclaration)?.kind !== 'const') return null; + return declaration.init === null ? null : generatorValue(context, declaration.init, seen); } -function bindingPath( + +function generatorValue( context: Context, - expression: ESTree.Node, - extraModules: readonly string[] = [], + value: ESTree.Node, seen = new Set(), -): readonly string[] | null { - const node = identityUnwrap(expression); - if (node.type === 'MemberExpression') { - const key = - !node.computed && node.property.type === 'Identifier' - ? node.property.name - : staticString(node.property); - const root = bindingPath(context, node.object, extraModules, seen); - return root !== null && key !== null ? [...root, key] : null; - } +): ESTree.Node | null { + const node = identityUnwrap(value); + if (node.type === 'FunctionExpression' || node.type === 'FunctionDeclaration') + return node.generator ? node : null; if (node.type !== 'Identifier') return null; const variable = lexicalVariable(context, node); - if (variable === null || seen.has(variable)) return null; + if (variable === null || seen.has(variable) || variable.defs.length !== 1) return null; seen.add(variable); - if (variable.defs.length !== 1) return null; - const definition = variable.defs[0]; - if (definition === undefined) return null; - if (definition.type === 'ImportBinding') { - const specifier = definition.node as - | ESTree.ImportSpecifier - | ESTree.ImportNamespaceSpecifier - | ESTree.ImportDefaultSpecifier; - const declaration = definition.parent as ESTree.ImportDeclaration; - if (declaration?.type !== 'ImportDeclaration' || declaration.importKind === 'type') return null; - if (specifier.type === 'ImportSpecifier' && specifier.importKind === 'type') return null; - const source = declaration.source.value; - if (source !== 'effect' && !source.startsWith('effect/') && !extraModules.includes(source)) - return null; - const last = source.split('/').at(-1) ?? ''; - const base = source.startsWith('effect/') && /^[A-Z]/u.test(last) ? [last] : []; - if (specifier.type === 'ImportNamespaceSpecifier') return base; - if (specifier.type !== 'ImportSpecifier') return null; - const imported = - specifier.imported.type === 'Identifier' ? specifier.imported.name : specifier.imported.value; - return [...base, imported]; - } - if (definition.type !== 'Variable') return null; - const declaration = definition.node as ESTree.VariableDeclarator; - const parent = definition.parent as ESTree.VariableDeclaration; - if (parent?.kind !== 'const' || declaration.init === null) return null; - const base = bindingPath(context, declaration.init, extraModules, seen); - if (base === null) return null; - if (declaration.id.type === 'Identifier') return base; - if (declaration.id.type !== 'ObjectPattern') return null; - for (const property of declaration.id.properties) { - if ( - property.type === 'RestElement' || - property.value.type !== 'Identifier' || - property.value.name !== node.name - ) - continue; - const key = - !property.computed && property.key.type === 'Identifier' - ? property.key.name - : staticString(property.key); - return key === null ? null : [...base, key]; - } - return null; + return generatorDefinitionValue(context, variable.defs[0], seen); } export const rule = defineRule({ @@ -635,49 +411,37 @@ export const rule = defineRule({ }, create(context) { const options = readOptions(context); - const path = scopePath(context.filename); - if (matchesGlobs(path, options.ignore)) return {}; - const inScripts = isScriptFile(path) || matchesGlobs(path, DEFAULT_SCRIPT_GLOBS); - if (!options.includeScripts && inScripts) return {}; - // `includeScripts` also widens `include`, so the root `scripts/` tree is reachable without - // restating the default `include` globs in the config. - if (!matchesGlobs(path, options.include) && !(options.includeScripts && inScripts)) return {}; - if (!options.includeTests && isTestFile(path)) return {}; + if (!isIncludedFile(context, options)) return {}; + if (!hasGeneratorImports(context.sourceCode.ast, options)) return {}; - const program = context.sourceCode.ast; - const rootNamespaces = collectRootNamespaces(program); - const directMembers = collectDirectMemberImports(program, options.genMembers); - const bindings = bindingsWithExtraModules(program, options.effectModules); - if (!bindings.importsEffect && rootNamespaces.size === 0 && directMembers.size === 0) return {}; - - const matcher: GeneratorMatcher = { - context, - effectModules: options.effectModules, - bindings, - directMembers, - genMembers: options.genMembers, - rootNamespaces, - }; const visitorKeys = context.sourceCode.visitorKeys; const generatorLets = new Map(); const reportedCounters = new Set(); const effectGenerators = new Set(); const loops: { node: ESTree.Node; fn: AnyNode | null }[] = []; - const generatorValue = (value: ESTree.Node, seen = new Set()): ESTree.Node | null => { - const node = identityUnwrap(value); - if (node.type === 'FunctionExpression' || node.type === 'FunctionDeclaration') - return node.generator ? node : null; - if (node.type !== 'Identifier') return null; - const variable = lexicalVariable(context, node); - if (variable === null || seen.has(variable) || variable.defs.length !== 1) return null; - seen.add(variable); - const def = variable.defs[0]; - if (def?.type === 'FunctionName') return def.node; - if (def?.type !== 'Variable') return null; - const decl = def.node as ESTree.VariableDeclarator; - return (def.parent as ESTree.VariableDeclaration)?.kind === 'const' && decl.init !== null - ? generatorValue(decl.init, seen) - : null; + const reportCounters = ( + loop: AnyNode, + fn: AnyNode, + assigned: ReadonlySet, + label: string, + ): void => { + let declarations = generatorLets.get(fn.start); + if (declarations === undefined) { + declarations = collectGeneratorLets(fn, visitorKeys); + generatorLets.set(fn.start, declarations); + } + for (const declaration of declarations) { + if (declaration.start >= loop.start && declaration.end <= loop.end) continue; + const variable = lexicalVariable(context, declaration.idNode); + if (variable === null || !assigned.has(variable)) continue; + if (reportedCounters.has(declaration.start)) continue; + reportedCounters.add(declaration.start); + context.report({ + data: { loop: label, name: declaration.name }, + messageId: 'mutableCounter', + node: declaration.idNode, + }); + } }; const checkLoop = (node: ESTree.Node, fn: AnyNode | null): void => { @@ -689,9 +453,7 @@ export const rule = defineRule({ if (!containsDelegatingYield(loop, visitorKeys)) return; const mutations = collectLoopMutations(loop, context, visitorKeys); - const mutatesOuter = mutations.mutatesOuter; - if (options.allowForOfWithoutMutation && loop.type === 'ForOfStatement' && !mutatesOuter) - return; + if (allowsUnmutatingForOf(loop, options, mutations.mutatesOuter)) return; const keyword = context.sourceCode.getFirstToken(node); context.report({ @@ -700,37 +462,18 @@ export const rule = defineRule({ node: (keyword ?? node) as ESTree.Node, }); - if (!options.flagCounters) return; - let declarations = generatorLets.get(fn.start); - if (declarations === undefined) { - declarations = collectGeneratorLets(fn, visitorKeys); - generatorLets.set(fn.start, declarations); - } - for (const declaration of declarations) { - if (declaration.start >= loop.start && declaration.end <= loop.end) continue; - const variable = lexicalVariable( - context, - declaration.idNode as unknown as Extract, - ); - if (variable === null || !mutations.assigned.has(variable)) continue; - if (reportedCounters.has(declaration.start)) continue; - reportedCounters.add(declaration.start); - context.report({ - data: { loop: label, name: declaration.name }, - messageId: 'mutableCounter', - node: declaration.idNode as unknown as ESTree.Node, - }); - } + if (options.flagCounters) reportCounters(loop, fn, mutations.assigned, label); }; const collectLoop = (node: ESTree.Node): void => { - loops.push({ node, fn: enclosingFunction(node as unknown as AnyNode) }); + loops.push({ node, fn: enclosingFunction(node) }); }; return { CallExpression(node) { - if (!isGenCallee(asNode(node.callee), matcher)) return; + if (!isGenCallee(context, asNode(node.callee), options.genMembers, options.effectModules)) + return; for (const argument of node.arguments) { - const generator = generatorValue(argument); + const generator = generatorValue(context, argument); if (generator !== null) effectGenerators.add(generator.start); } }, diff --git a/app/tools/oxlint/effect-native/rules/no-interface-first-codec.ts b/app/tools/oxlint/effect-native/rules/no-interface-first-codec.ts index 896709c06..03a6da8d0 100644 --- a/app/tools/oxlint/effect-native/rules/no-interface-first-codec.ts +++ b/app/tools/oxlint/effect-native/rules/no-interface-first-codec.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * Audit finding: **A2** — "Make Schema the sole authority for contracts and domain models" * (`docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`). A2 counts "approximately 119 @@ -58,25 +59,17 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree } from '@oxlint/plugins'; -import { - collectEffectBindings, - effectMember, - type EffectBindings, -} from '../shared/effect-imports.ts'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; +import { collectEffectBindings, effectMember } from '../shared/effect-imports.ts'; +import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; +import { booleanOption as boolean, stringArray } from '../shared/options.ts'; +import { memberName, typeNameSegments, unwrapNode as unwrapExpression } from '../shared/ast.ts'; +import { lookupVariable, resolvesToImport } from '../shared/bindings.ts'; +import { collectSchemaLocals } from '../shared/imports.ts'; +import { isNonReferencePosition } from '../shared/reference-positions.ts'; const SCHEMA_NAMESPACE = 'Schema'; -const EFFECT_ROOT_MODULE = 'effect'; -const EFFECT_SCHEMA_MODULE = /^effect\/(?:.*\/)?Schema$/u; - -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production `include` defaults instead of - * forcing the fixture config to pass loosened options (which `run-on-repo.mts` reuses verbatim). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; @@ -118,22 +111,8 @@ interface RuleOptions { readonly reexportModules: readonly string[]; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - -function boolean(value: unknown, fallback: boolean): boolean { - return typeof value === 'boolean' ? value : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { include: stringArray(record.include, DEFAULT_INCLUDE), ignore: stringArray(record.ignore, DEFAULT_IGNORE), @@ -151,114 +130,6 @@ function readOptions(context: Context): RuleOptions { }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -function importedName(specifier: ESTree.ImportSpecifier): string { - return specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; -} - -interface SchemaLocals { - /** Locals standing for Effect's `Schema` namespace (`Schema`, `S`, `import * as Schema from "effect/Schema"`). */ - readonly schema: ReadonlySet; - /** Locals standing for the whole Effect barrel (`import * as Effect from "effect"` → `Effect.Schema.Codec`). */ - readonly barrel: ReadonlySet; - /** Locals bound directly from `effect/Schema` (`import { Struct, suspend } from "effect/Schema"`). */ - readonly direct: ReadonlyMap; -} - -function collectSchemaLocals( - program: ESTree.Program, - bindings: EffectBindings, - reexportModules: readonly string[], -): SchemaLocals { - const schema = new Set(); - const barrel = new Set(); - const direct = new Map(); - for (const [local, namespace] of bindings.namespaces) { - if (namespace === SCHEMA_NAMESPACE) schema.add(local); - } - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - const source = statement.source.value; - if (EFFECT_SCHEMA_MODULE.test(source)) { - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportSpecifier') - direct.set(specifier.local.name, importedName(specifier)); - else if (specifier.type === 'ImportNamespaceSpecifier') schema.add(specifier.local.name); - } - continue; - } - const isEffectRoot = source === EFFECT_ROOT_MODULE; - const isReexport = matchesGlobs(source, reexportModules); - if (!isEffectRoot && !isReexport) continue; - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportNamespaceSpecifier') barrel.add(specifier.local.name); - else if ( - specifier.type === 'ImportSpecifier' && - importedName(specifier) === SCHEMA_NAMESPACE - ) { - schema.add(specifier.local.name); - } - } - } - return { schema, barrel, direct }; -} - -/** Flatten `Schema.Codec` / `Effect.Schema.Codec` / `Codec` into its dotted segments. */ -function typeNameSegments(name: ESTree.TSTypeName): readonly string[] | null { - if (name.type === 'Identifier') return [name.name]; - if (name.type === 'TSQualifiedName') { - const left = typeNameSegments(name.left); - return left === null ? null : [...left, name.right.name]; - } - return null; -} - -/** Non-computed `.pipe`, or computed `["pipe"]`. */ -function memberName(node: ESTree.MemberExpression): string | null { - if (!node.computed) return node.property.type === 'Identifier' ? node.property.name : null; - const property = node.property; - if (property.type === 'Literal' && typeof property.value === 'string') return property.value; - return null; -} - -function lookupVariable( - context: Context, - identifier: Extract, -): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(identifier); - while (scope !== null) { - const variable = scope.set.get(identifier.name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; -} - -/** - * `true` when the identifier still resolves to an `import` binding. Unresolved names fall back to - * `true` because the module-level import declaration already proved the binding exists; only a local - * shadow (parameter, `const`, catch clause, …) rejects the match. - */ -function resolvesToImport( - context: Context, - identifier: Extract, -): boolean { - const variable = lookupVariable(context, identifier); - if (variable === null) return true; - if (variable.defs.length === 0) return true; - return variable.defs.some((definition) => definition.type === 'ImportBinding'); -} - interface Candidate { readonly node: ESTree.Node; readonly ownerStart: number; @@ -348,12 +219,16 @@ export const rule = defineRule({ const locals = collectSchemaLocals(program, bindings, options.reexportModules); if (locals.schema.size === 0 && locals.barrel.size === 0 && locals.direct.size === 0) return {}; - const codecTypeLocals = new Set(); - const suspendLocals = new Set(); - for (const [local, imported] of locals.direct) { - if (options.codecTypes.includes(imported)) codecTypeLocals.add(local); - if (imported === SUSPEND_MEMBER) suspendLocals.add(local); - } + const codecTypeLocals = new Set( + [...locals.direct] + .filter(([, imported]) => options.codecTypes.includes(imported)) + .map(([local]) => local), + ); + const suspendLocals = new Set( + [...locals.direct] + .filter(([, imported]) => imported === SUSPEND_MEMBER) + .map(([local]) => local), + ); const candidates: Candidate[] = []; const suspendSpans: Array<{ start: number; end: number }> = []; @@ -367,26 +242,6 @@ export const rule = defineRule({ return current; }; - /** Strip value-level wrappers that never change what the expression *is*. */ - const unwrapExpression = (expression: ESTree.Node): ESTree.Node => { - let current = expression; - for (;;) { - if ( - current.type === 'ParenthesizedExpression' || - current.type === 'ChainExpression' || - current.type === 'TSAsExpression' || - current.type === 'TSSatisfiesExpression' || - current.type === 'TSNonNullExpression' || - current.type === 'TSTypeAssertion' || - current.type === 'TSInstantiationExpression' - ) { - current = current.expression; - continue; - } - return current; - } - }; - /** `Schema.X` / `S.X` / `Effect.Schema.X` / `Schema["X"]`, with the shared `effectMember` matcher first. */ const schemaMemberName = (node: ESTree.MemberExpression): string | null => { const viaShared = effectMember(node, bindings); @@ -402,6 +257,10 @@ export const rule = defineRule({ if (!locals.schema.has(object.name)) return null; return resolvesToImport(context, object) ? member : null; } + return barrelMemberName(object, member); + }; + + const barrelMemberName = (object: ESTree.Node, member: string): string | null => { if (object.type !== 'MemberExpression') return null; if (memberName(object) !== SCHEMA_NAMESPACE) return null; if (object.object.type !== 'Identifier') return null; @@ -441,6 +300,13 @@ export const rule = defineRule({ } if (current.type === 'MemberExpression') return schemaMemberName(current) !== null; if (current.type !== 'CallExpression' && current.type !== 'NewExpression') return false; + return isSchemaCall(current, depth); + }; + + const isSchemaCall = ( + current: ESTree.CallExpression | ESTree.NewExpression, + depth: number, + ): boolean => { const callee = unwrapExpression(current.callee); // ANY instance-method chain, not just `.pipe`: Effect v4 Schemas carry `.annotate(...)`, // `.check(...)`, `.pipe(...)` and friends, so the receiver — never the method name — decides. @@ -469,32 +335,12 @@ export const rule = defineRule({ seen.add(current); if (options.allowDerivedTypeArguments && current.type === 'TSTypeQuery') return true; if (current.type !== 'TSTypeReference') return false; - if (current.typeName.type === 'Identifier') { - const name = current.typeName.name; - const variable = lookupVariable(context, current.typeName); - for (const definition of variable?.defs ?? []) { - const declaration = definition.node as ESTree.Node; - if (declaration.type === 'TSTypeParameter') return true; - if (declaration.type === 'TSTypeAliasDeclaration') { - return ( - options.allowDerivedTypeArguments && - derivedOrGeneric(declaration.typeAnnotation, seen) - ); - } - } - // Some scope providers do not expose type-parameter definitions. Check only enclosing - // binders; an unrelated generic elsewhere in the file must not exempt a prior interface. - if (variable === null || variable.defs.length === 0) { - let ancestor: ESTree.Node | null | undefined = current.parent; - while (ancestor != null) { - const parameters = ( - ancestor as { typeParameters?: ESTree.TSTypeParameterDeclaration | null } - ).typeParameters; - if (parameters?.params.some((parameter) => parameter.name.name === name)) return true; - ancestor = ancestor.parent; - } - } - } + const named = namedDerivation(current, seen); + if (named !== null) return named; + return derivedArguments(current, seen); + }; + + const derivedArguments = (current: ESTree.TSTypeReference, seen: Set): boolean => { // Schema.Type, ReturnType, etc. remain derived rather than // introducing a shape of their own. Mixed handwritten arguments are not waived. const arguments_ = current.typeArguments?.params ?? []; @@ -505,6 +351,48 @@ export const rule = defineRule({ ); }; + const enclosingParameter = (current: ESTree.Node, name: string): boolean => { + let ancestor = current.parent; + while (ancestor != null) { + const parameters = ( + ancestor as { typeParameters?: ESTree.TSTypeParameterDeclaration | null } + ).typeParameters; + if (parameters?.params.some((parameter) => parameter.name.name === name)) return true; + ancestor = ancestor.parent; + } + return false; + }; + + const namedDerivation = ( + current: ESTree.TSTypeReference, + seen: Set, + ): boolean | null => { + if (current.typeName.type !== 'Identifier') return null; + const variable = lookupVariable(context, current.typeName); + if (variable === null) + return enclosingParameter(current, current.typeName.name) ? true : null; + for (const definition of variable.defs) { + const declaration = definition.node as ESTree.Node; + if (declaration.type === 'TSTypeParameter') return true; + if (declaration.type === 'TSTypeAliasDeclaration') { + return ( + options.allowDerivedTypeArguments && derivedOrGeneric(declaration.typeAnnotation, seen) + ); + } + } + if (variable.defs.length > 0) return null; + return enclosingParameter(current, current.typeName.name) ? true : null; + }; + + const isCodecName = (segments: readonly string[]): boolean => { + const root = segments[0] ?? ''; + if (segments.length === 1) return codecTypeLocals.has(root); + const member = segments.at(-1) ?? ''; + if (!options.codecTypes.includes(member)) return false; + if (segments.length === 2) return locals.schema.has(root); + return segments.length === 3 && locals.barrel.has(root) && segments[1] === SCHEMA_NAMESPACE; + }; + /** * When the annotation is an Effect Schema codec type applied to a prior type, return its * printed form and the printed first type argument. `null` means "not an authority conflict". @@ -515,20 +403,15 @@ export const rule = defineRule({ const segments = typeNameSegments(reference.typeName); if (segments === null || segments.length === 0) return null; const member = segments[segments.length - 1] ?? ''; - if (segments.length === 1) { - // A single segment is a *local* name (`Codec`, or `SchemaCodec` from - // `import type { Codec as SchemaCodec } from "effect/Schema"`). `codecTypeLocals` is keyed by - // local name and built from the *imported* name, so it must be consulted before — never after — - // any test against the codec-type list, otherwise every alias escapes. - if (!codecTypeLocals.has(segments[0] ?? '')) return null; - } else if (segments.length === 2) { - if (!options.codecTypes.includes(member)) return null; - if (!locals.schema.has(segments[0] ?? '')) return null; - } else if (segments.length === 3) { - if (!options.codecTypes.includes(member)) return null; - if (!locals.barrel.has(segments[0] ?? '') || segments[1] !== SCHEMA_NAMESPACE) return null; - } else return null; + if (!isCodecName(segments)) return null; + + return codecArguments(reference, member); + }; + const codecArguments = ( + reference: ESTree.TSTypeReference, + member: string, + ): { annotation: string; type: string } | null => { const parameters = reference.typeArguments?.params ?? []; const first = parameters[0]; if (first === undefined) @@ -566,6 +449,25 @@ export const rule = defineRule({ return { name: 'this schema', start: node.start, end: node.end }; }; + const collectExpressionCandidate = ( + node: ESTree.TSSatisfiesExpression | ESTree.TSAsExpression | ESTree.TSTypeAssertion, + messageId: 'satisfies' | 'cast', + ): void => { + const match = codecAnnotation(node.typeAnnotation); + if (match === null || !isSchemaExpression(node.expression, 0)) return; + const owner = ownerOf(node); + if (annotatedOwners.has(owner.start)) return; + candidates.push({ + node: node.typeAnnotation, + ownerStart: owner.start, + ownerEnd: owner.end, + messageId, + name: owner.name, + annotation: match.annotation, + type: match.type, + }); + }; + return { VariableDeclarator(node) { if (node.init === null || node.init === undefined) return; @@ -575,10 +477,7 @@ export const rule = defineRule({ if (annotation === null || annotation === undefined) return; const match = codecAnnotation(annotation.typeAnnotation); if (match === null) return; - if (options.requireSchemaInitializer) { - if (node.init === null || node.init === undefined) return; - if (!isSchemaExpression(node.init, 0)) return; - } + if (options.requireSchemaInitializer && !isSchemaExpression(node.init, 0)) return; annotatedOwners.add(node.start); candidates.push({ node: annotation.typeAnnotation, @@ -591,56 +490,13 @@ export const rule = defineRule({ }); }, TSSatisfiesExpression(node) { - if (!options.checkSatisfies) return; - const match = codecAnnotation(node.typeAnnotation); - if (match === null) return; - if (!isSchemaExpression(node.expression, 0)) return; - const owner = ownerOf(node); - if (annotatedOwners.has(owner.start)) return; - candidates.push({ - node: node.typeAnnotation, - ownerStart: owner.start, - ownerEnd: owner.end, - messageId: 'satisfies', - name: owner.name, - annotation: match.annotation, - type: match.type, - }); + if (options.checkSatisfies) collectExpressionCandidate(node, 'satisfies'); }, TSAsExpression(node) { - if (!options.checkAsExpressions) return; - const match = codecAnnotation(node.typeAnnotation); - if (match === null) return; - if (!isSchemaExpression(node.expression, 0)) return; - const owner = ownerOf(node); - if (annotatedOwners.has(owner.start)) return; - candidates.push({ - node: node.typeAnnotation, - ownerStart: owner.start, - ownerEnd: owner.end, - messageId: 'cast', - name: owner.name, - annotation: match.annotation, - type: match.type, - }); + if (options.checkAsExpressions) collectExpressionCandidate(node, 'cast'); }, - /** `>Schema.Struct({...})` — the angle-bracket spelling of the same cast. */ TSTypeAssertion(node) { - if (!options.checkAsExpressions) return; - const match = codecAnnotation(node.typeAnnotation); - if (match === null) return; - if (!isSchemaExpression(node.expression, 0)) return; - const owner = ownerOf(node); - if (annotatedOwners.has(owner.start)) return; - candidates.push({ - node: node.typeAnnotation, - ownerStart: owner.start, - ownerEnd: owner.end, - messageId: 'cast', - name: owner.name, - annotation: match.annotation, - type: match.type, - }); + if (options.checkAsExpressions) collectExpressionCandidate(node, 'cast'); }, /** * `class Repo { private readonly rows: Schema.Codec = Schema.Struct({...}) }`. The Schema @@ -682,12 +538,7 @@ export const rule = defineRule({ Identifier(node) { if (!options.allowSuspend || suspendLocals.size === 0) return; if (!suspendLocals.has(node.name)) return; - const parent = node.parent; - if (parent === null || parent === undefined) return; - if (parent.type === 'ImportSpecifier' || parent.type === 'ImportDefaultSpecifier') return; - if (parent.type === 'ImportNamespaceSpecifier' || parent.type === 'ExportSpecifier') return; - if (parent.type === 'MemberExpression' && parent.property === node && !parent.computed) - return; + if (isNonReferencePosition(node, { keyParents: new Set() })) return; if (!resolvesToImport(context, node)) return; suspendSpans.push({ start: node.start, end: node.end }); }, diff --git a/app/tools/oxlint/effect-native/rules/no-json-schema-as-document-contract.ts b/app/tools/oxlint/effect-native/rules/no-json-schema-as-document-contract.ts index 2056ec497..6599a6df2 100644 --- a/app/tools/oxlint/effect-native/rules/no-json-schema-as-document-contract.ts +++ b/app/tools/oxlint/effect-native/rules/no-json-schema-as-document-contract.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * Audit finding: **A7** — "Give topology, composition, and authorization evidence shared Schemas" * (`docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`). @@ -69,22 +70,18 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree, Variable } from '@oxlint/plugins'; -import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; +import { collectEffectBindings } from '../shared/effect-imports.ts'; +import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; +import { booleanOption as boolean, stringArray } from '../shared/options.ts'; +import { keyName, memberName as staticMemberName, unwrapNode } from '../shared/ast.ts'; +import { lookupVariable, resolvesToImport } from '../shared/bindings.ts'; +import { collectSchemaLocals, importedName } from '../shared/imports.ts'; const SCHEMA_NAMESPACE = 'Schema'; -const EFFECT_ROOT_MODULE = 'effect'; const EFFECT_SCHEMA_MODULE = /^effect\/(?:.*\/)?Schema$/u; -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production defaults instead of forcing the - * fixture config to pass loosened options (which `run-on-repo.mts` reuses verbatim against the repo). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; - /** Shape-free JSON codecs on Effect's `Schema` namespace. */ const DEFAULT_JSON_MEMBERS = ['Json', 'JsonValue']; @@ -174,22 +171,8 @@ interface RuleOptions { readonly jsonMembers: readonly string[]; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - -function boolean(value: unknown, fallback: boolean): boolean { - return typeof value === 'boolean' ? value : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { allowPaths: stringArray(record.allowPaths, DEFAULT_ALLOW_PATHS), codecMembers: stringArray(record.codecMembers, DEFAULT_CODEC_MEMBERS), @@ -198,178 +181,137 @@ function readOptions(context: Context): RuleOptions { }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); +function unwrapExpression(node: ESTree.Node): ESTree.Node { + return unwrapNode(node, { wrappers: EXPRESSION_WRAPPERS, maxDepth: MAX_RESOLUTION_DEPTH }); } -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); +function memberName(node: ESTree.MemberExpression): string | null { + return staticMemberName(node, { + templates: true, + unwrap: { wrappers: EXPRESSION_WRAPPERS, maxDepth: MAX_RESOLUTION_DEPTH }, + }); } -function importedName(specifier: ESTree.ImportSpecifier): string { - return specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; +function recordValue(args: ESTree.CallExpression['arguments']): ESTree.Node | null { + if (args.length >= 2) { + const second = args[1]; + return second && second.type !== 'SpreadElement' ? second : null; + } + return recordObjectValue(args[0]); } -/** Non-computed `.Json`, or computed `["Json"]`. */ -function memberName(node: ESTree.MemberExpression): string | null { - if (!node.computed) return node.property.type === 'Identifier' ? node.property.name : null; - const property = unwrapExpression(node.property); - if (property.type === 'TemplateLiteral' && property.expressions.length === 0) - return property.quasis[0]?.value.cooked ?? null; - if (property.type === 'Literal' && typeof property.value === 'string') return property.value; - return null; +function recordObjectValue(first: ESTree.Node | undefined): ESTree.Node | null { + if (first?.type !== 'ObjectExpression') return null; + let value: ESTree.Node | null = null; + for (const property of first.properties) { + if (property.type !== 'Property' || property.computed) continue; + if (property.key.type === 'Identifier' && property.key.name === 'value') value = property.value; + } + return value; } -function unwrapExpression(node: ESTree.Node): ESTree.Node { - let current = node; - for (let step = 0; step < MAX_RESOLUTION_DEPTH; step += 1) { - if (!EXPRESSION_WRAPPERS.has(current.type)) return current; - const next = - 'expression' in current && current.expression !== null && current.expression !== undefined - ? (current.expression as ESTree.Node) - : null; - if (next === null) return current; - current = next; - } - return current; +type Definition = Variable['defs'][number]; + +function importedSchemaIdentity(def: Definition, reexports: readonly string[]): string | null { + const specifier = def.node; + const declaration = def.parent; + if (declaration?.type !== 'ImportDeclaration' || declaration.importKind === 'type') return null; + if (specifier.type === 'ImportSpecifier' && specifier.importKind === 'type') return null; + return schemaImportSpecifierIdentity(specifier, declaration.source.value, reexports); } -function lookupVariable( - context: Context, - identifier: Extract, -): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(identifier); - while (scope !== null) { - const variable = scope.set.get(identifier.name); - if (variable !== undefined) return variable; - scope = scope.upper; +function schemaImportSpecifierIdentity( + specifier: ESTree.Node, + source: string, + reexports: readonly string[], +): string | null { + if (EFFECT_SCHEMA_MODULE.test(source)) { + if (specifier.type === 'ImportNamespaceSpecifier') return '@schema'; + return specifier.type === 'ImportSpecifier' ? importedName(specifier) : null; } - return null; + if (source !== 'effect' && !matchesGlobs(source, reexports)) return null; + if (specifier.type === 'ImportNamespaceSpecifier') return '@effect'; + return specifier.type === 'ImportSpecifier' && importedName(specifier) === 'Schema' + ? '@schema' + : null; } -/** - * `true` when the identifier still resolves to an `import` binding. Unresolved names fall back to - * `true` because the module-level import declaration already proved the binding exists; only a local - * shadow (parameter, `const`, catch clause, …) rejects the match. - */ -function resolvesToImport( - context: Context, - identifier: Extract, -): boolean { - const variable = lookupVariable(context, identifier); - if (variable === null) return true; - if (variable.defs.length === 0) return true; - return variable.defs.some((definition) => definition.type === 'ImportBinding'); +function constantInitializer(def: Definition): ESTree.VariableDeclarator | null { + if (def.type !== 'Variable' || def.node.type !== 'VariableDeclarator' || def.node.init === null) + return null; + return def.node.parent?.type === 'VariableDeclaration' && def.node.parent.kind === 'const' + ? def.node + : null; } -interface SchemaLocals { - /** Locals standing for Effect's `Schema` namespace (`Schema`, `S`, `import * as S from "effect/Schema"`). */ - readonly namespaces: ReadonlySet; - /** Locals bound directly from `effect/Schema` (`import { Json as AnyJson } from "effect/Schema"`). */ - readonly direct: ReadonlyMap; +function selectedSchemaMember(host: string | null, key: string | null): string | null { + if (host === '@schema') return key; + return host === '@effect' && key === 'Schema' ? '@schema' : null; } -function collectSchemaLocals(program: ESTree.Program, bindings: EffectBindings): SchemaLocals { - const namespaces = new Set(); - const direct = new Map(); - for (const [local, namespace] of bindings.namespaces) { - if (namespace === SCHEMA_NAMESPACE) namespaces.add(local); - } - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - const source = statement.source.value; - if (EFFECT_SCHEMA_MODULE.test(source)) { - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportSpecifier') - direct.set(specifier.local.name, importedName(specifier)); - else if (specifier.type === 'ImportNamespaceSpecifier') - namespaces.add(specifier.local.name); - } +function destructuredSchemaMember( + pattern: ESTree.ObjectPattern, + name: string, + host: string | null, +): string | null | undefined { + for (const property of pattern.properties) { + if ( + property.type !== 'Property' || + property.value.type !== 'Identifier' || + property.value.name !== name + ) continue; - } - if (source !== EFFECT_ROOT_MODULE) continue; - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportSpecifier' && importedName(specifier) === SCHEMA_NAMESPACE) { - namespaces.add(specifier.local.name); - } - } + const key = keyName(property.key, property.computed, { templates: true }); + const result = selectedSchemaMember(host, key); + if (host === '@schema' || result !== null) return result; } - return { namespaces, direct }; + return undefined; } -/** Resolve only lexical imports and immutable same-file aliases; no cross-file or mutation inference. */ -function schemaIdentity( +function identifierSchemaIdentity( context: Context, - input: ESTree.Node, - reexports: readonly string[] = [], - depth = 0, + node: Extract, + reexports: readonly string[], + depth: number, ): string | null { - if (depth > 16) return null; - const node = unwrapExpression(input); - if (node.type === 'MemberExpression') { - const host = schemaIdentity(context, node.object, reexports, depth + 1); - const member = memberName(node); - return host === '@schema' - ? member - : host === '@effect' && member === 'Schema' - ? '@schema' - : null; - } - if (node.type !== 'Identifier') return null; const variable = lookupVariable(context, node); if (!variable) return null; for (const def of variable.defs) { if (def.type === 'ImportBinding') { - const specifier = def.node; - const declaration = def.parent; - if (declaration?.type !== 'ImportDeclaration' || declaration.importKind === 'type') continue; - if (specifier.type === 'ImportSpecifier' && specifier.importKind === 'type') continue; - const source = declaration.source.value; - if (EFFECT_SCHEMA_MODULE.test(source)) { - if (specifier.type === 'ImportNamespaceSpecifier') return '@schema'; - if (specifier.type === 'ImportSpecifier') return importedName(specifier); - } - if (source === 'effect' || matchesGlobs(source, reexports)) { - if (specifier.type === 'ImportNamespaceSpecifier') return '@effect'; - if (specifier.type === 'ImportSpecifier' && importedName(specifier) === 'Schema') - return '@schema'; - } + const identity = importedSchemaIdentity(def, reexports); + if (identity !== null) return identity; } - if (def.type !== 'Variable' || def.node.type !== 'VariableDeclarator' || def.node.init === null) - continue; - const declarator = def.node; - if (declarator.init === null) continue; - if (declarator.parent?.type !== 'VariableDeclaration' || declarator.parent.kind !== 'const') - continue; + const declarator = constantInitializer(def); + if (!declarator?.init) continue; if (declarator.id.type === 'Identifier') return schemaIdentity(context, declarator.init, reexports, depth + 1); if (declarator.id.type !== 'ObjectPattern') continue; const host = schemaIdentity(context, declarator.init, reexports, depth + 1); - for (const property of declarator.id.properties) { - if ( - property.type !== 'Property' || - property.value.type !== 'Identifier' || - property.value.name !== node.name - ) - continue; - const key = - !property.computed && property.key.type === 'Identifier' - ? property.key.name - : property.key.type === 'Literal' && typeof property.key.value === 'string' - ? property.key.value - : property.key.type === 'TemplateLiteral' && property.key.expressions.length === 0 - ? property.key.quasis[0]?.value.cooked - : null; - if (host === '@schema') return key ?? null; - if (host === '@effect' && key === 'Schema') return '@schema'; - } + const identity = destructuredSchemaMember(declarator.id, node.name, host); + if (identity !== undefined) return identity; } return null; } +/** Resolve only lexical imports and immutable same-file aliases; no cross-file or mutation inference. */ +function schemaIdentity( + context: Context, + input: ESTree.Node, + reexports: readonly string[] = [], + depth = 0, +): string | null { + if (depth > 16) return null; + const node = unwrapExpression(input); + if (node.type === 'MemberExpression') + return selectedSchemaMember( + schemaIdentity(context, node.object, reexports, depth + 1), + memberName(node), + ); + return node.type === 'Identifier' + ? identifierSchemaIdentity(context, node, reexports, depth) + : null; +} + export const rule = defineRule({ meta: { type: 'problem', @@ -470,57 +412,30 @@ export const rule = defineRule({ if (isBareJson(current)) return `${SCHEMA_NAMESPACE}.Json`; - if (current.type === 'Identifier') { - if (seen.has(current.name)) return null; - const variable = lookupVariable(context, current); - const definition = variable?.defs.find( - (def) => def.type === 'Variable' && def.node.type === 'VariableDeclarator', - ); - if (!definition || definition.node.type !== 'VariableDeclarator') return null; - const declaration = definition.node.parent; - if (declaration?.type !== 'VariableDeclaration' || declaration.kind !== 'const') - return null; - const init = definition.node.init; - if (!init) return null; - seen.add(current.name); - return jsonDocumentShape(init, depth + 1, seen); - } + if (current.type === 'Identifier') return aliasDocumentShape(current, depth, seen); if (current.type !== 'CallExpression') return null; + return combinatorDocumentShape(current, depth, seen); + }; + + const combinatorDocumentShape = ( + current: ESTree.CallExpression, + depth: number, + seen: Set, + ): string | null => { const combinator = schemaReference(unwrapExpression(current.callee)); if (combinator === null) return null; const args = current.arguments; - if (TRANSPARENT_WRAPPERS.has(combinator)) { - const first = args[0]; - return first === undefined || first.type === 'SpreadElement' - ? null - : jsonDocumentShape(first, depth + 1, seen); - } - + if (TRANSPARENT_WRAPPERS.has(combinator)) return argumentDocumentShape(args[0], depth, seen); if (ARRAY_COMBINATORS.has(combinator)) { - const first = args[0]; - if (first === undefined || first.type === 'SpreadElement') return null; - return jsonDocumentShape(first, depth + 1, seen) === null + return argumentDocumentShape(args[0], depth, seen) === null ? null : `${SCHEMA_NAMESPACE}.${combinator}(${SCHEMA_NAMESPACE}.Json)`; } if (combinator === 'Record') { - // `Schema.Record(key, value)` and the object form `Schema.Record({ key, value })`. - let value: ESTree.Node | null = null; - const first = args[0]; - if (args.length >= 2) { - const second = args[1]; - if (second !== undefined && second.type !== 'SpreadElement') value = second; - } else if (first !== undefined && first.type === 'ObjectExpression') { - for (const property of first.properties) { - if (property.type !== 'Property' || property.computed) continue; - const key = property.key; - const name = key.type === 'Identifier' ? key.name : null; - if (name === 'value') value = property.value; - } - } + const value = recordValue(args); if (value === null) return null; return jsonDocumentShape(value, depth + 1, seen) === null ? null @@ -530,6 +445,30 @@ export const rule = defineRule({ return null; }; + const argumentDocumentShape = ( + argument: ESTree.Node | undefined, + depth: number, + seen: Set, + ): string | null => { + if (argument === undefined || argument.type === 'SpreadElement') return null; + return jsonDocumentShape(argument, depth + 1, seen); + }; + + const aliasDocumentShape = ( + current: Extract, + depth: number, + seen: Set, + ): string | null => { + if (seen.has(current.name)) return null; + const definition = lookupVariable(context, current)?.defs.find( + (def) => def.type === 'Variable' && def.node.type === 'VariableDeclarator', + ); + const declarator = definition && constantInitializer(definition); + if (!declarator?.init) return null; + seen.add(current.name); + return jsonDocumentShape(declarator.init, depth + 1, seen); + }; + const describe = (node: ESTree.Node): string | null => jsonDocumentShape(node, 0, new Set()); @@ -603,24 +542,40 @@ export const rule = defineRule({ ); }; + const reportPipedCodec = (node: ESTree.CallExpression): void => { + // Only actual Effect pipe, with the codec immediately following the schema. + if (node.arguments.length < 2 || !isEffectPipe(node.callee)) return; + const [subject, next] = node.arguments; + if (subject.type === 'SpreadElement' || next.type === 'SpreadElement') return; + const codec = schemaReference(next); + const shape = describe(subject); + if (codec && codecMembers.has(codec) && shape) + context.report({ + node: subject, + messageId: 'jsonCodecArgument', + data: { codec, namespace: 'Schema', shape }, + }); + }; + + const typeQueryReference = (expression: ESTree.TSTypeQuery['exprName']): string | null => { + if (expression.type === 'TSQualifiedName') { + const left = expression.left; + if (left.type !== 'Identifier') return null; + if (!locals.schema.has(left.name) || !resolvesToImport(context, left)) return null; + return jsonMembers.has(expression.right.name) + ? `${left.name}.${expression.right.name}` + : null; + } + if (expression.type !== 'Identifier') return null; + const imported = locals.direct.get(expression.name); + if (imported === undefined || !jsonMembers.has(imported)) return null; + return resolvesToImport(context, expression) ? expression.name : null; + }; + return { CallExpression(node) { const codec = schemaReference(unwrapExpression(node.callee)); - // Only actual Effect pipe, with the codec immediately following the schema. An - // arbitrary intermediate transformation can change the contract and is not inferred. - if (codec === null && node.arguments.length >= 2 && isEffectPipe(node.callee)) { - const [subject, next] = node.arguments; - if (subject.type !== 'SpreadElement' && next.type !== 'SpreadElement') { - const pipedCodec = schemaReference(next); - const shape = describe(subject); - if (pipedCodec && codecMembers.has(pipedCodec) && shape) - context.report({ - node: subject, - messageId: 'jsonCodecArgument', - data: { codec: pipedCodec, namespace: 'Schema', shape }, - }); - } - } + if (codec === null) reportPipedCodec(node); if (codec === null || !codecMembers.has(codec)) return; const first = node.arguments[0]; if (first === undefined || first.type === 'SpreadElement') return; @@ -655,24 +610,7 @@ export const rule = defineRule({ }, TSTypeQuery(node) { - const expression = node.exprName; - let reference: string | null = null; - if (expression.type === 'TSQualifiedName') { - const left = expression.left; - if (left.type !== 'Identifier') return; - if (!locals.namespaces.has(left.name) || !resolvesToImport(context, left)) return; - if (!jsonMembers.has(expression.right.name)) return; - reference = `${left.name}.${expression.right.name}`; - } else if (expression.type === 'Identifier') { - const imported = locals.direct.get(expression.name); - if ( - imported === undefined || - !jsonMembers.has(imported) || - !resolvesToImport(context, expression) - ) - return; - reference = expression.name; - } + const reference = typeQueryReference(node.exprName); if (reference === null) return; if (!inTypeContract(node)) return; context.report({ node, messageId: 'jsonDocumentType', data: { reference } }); diff --git a/app/tools/oxlint/effect-native/rules/no-layer-fresh.ts b/app/tools/oxlint/effect-native/rules/no-layer-fresh.ts index 632c443e2..e0a5f5a30 100644 --- a/app/tools/oxlint/effect-native/rules/no-layer-fresh.ts +++ b/app/tools/oxlint/effect-native/rules/no-layer-fresh.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * Audit finding: **A1** — "Establish one process-level Layer and ManagedRuntime composition model" * (`docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`). A1 records that "some library layers internally @@ -39,10 +40,18 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree } from '@oxlint/plugins'; import { collectEffectBindings, effectMember } from '../shared/effect-imports.ts'; -import { globToRegExp, normalisePath } from '../shared/paths.ts'; +import { matchesGlobs, scopePath } from '../shared/paths.ts'; +import { stringArray } from '../shared/options.ts'; +import { + EXPRESSION_WRAPPERS as TRANSPARENT_WRAPPERS, + unwrapNode as unwrap, + staticString, +} from '../shared/ast.ts'; +import { lookupVariable } from '../shared/bindings.ts'; +import { collectRootNamespaces, collectNamedImports } from '../shared/imports.ts'; const LAYER_NAMESPACE = 'Layer'; const FRESH_MEMBER = 'fresh'; @@ -52,27 +61,9 @@ const EFFECT_LAYER_MODULE = /^effect\/(?:.*\/)?Layer$/u; /** Cheap text probe so a file that only reaches `effect` through `import()` still arms the rule. */ const DYNAMIC_EFFECT_IMPORT = /\bimport\s*\(\s*["'`]effect(?:\/[^"'`]*)?["'`]/u; -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production defaults instead of forcing the - * fixture config to pass loosened options (which `run-on-repo.mts` reuses against the real repo). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; - /** No blessed `Layer.fresh` shape exists in the audit, so nothing is ignored by default. */ const DEFAULT_IGNORE: readonly string[] = []; -/** Wrappers that change types only: the runtime value on either side is identical. */ -const TRANSPARENT_WRAPPERS = new Set([ - 'ParenthesizedExpression', - 'ChainExpression', - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSInstantiationExpression', - 'TSTypeAssertion', -]); - /** * Parent nodes in which an identifier is a *name*, not a reference: object/class members and every * TypeScript signature key. `interface CacheEntry { fresh: boolean }` is not `Layer.fresh`. @@ -95,101 +86,55 @@ interface RuleOptions { readonly ignore: readonly string[]; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { ignore: stringArray(record.ignore, DEFAULT_IGNORE) }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -/** Strip type-only / parenthetical wrappers to reach the expression that actually runs. */ -function unwrap(node: ESTree.Node): ESTree.Node { - let current = node; - while (TRANSPARENT_WRAPPERS.has(current.type)) { - const inner = (current as unknown as { expression?: ESTree.Node }).expression; - if (inner === undefined || inner === null) break; - current = inner; - } - return current; -} - /** Static string of a property key: `x.fresh`, `x["fresh"]`, and the no-substitution template key. */ function staticKey(node: ESTree.Node, computed: boolean): string | null { if (!computed) return node.type === 'Identifier' ? node.name : null; - if (node.type === 'Literal' && typeof node.value === 'string') return node.value; - if ( - node.type === 'TemplateLiteral' && - node.expressions.length === 0 && - node.quasis.length === 1 - ) { - return node.quasis[0]?.value.cooked ?? null; - } - return null; + return staticString(node, { templates: true, singleQuasi: true, rawTemplates: false }); } function memberName(node: ESTree.MemberExpression): string | null { return staticKey(node.property as ESTree.Node, node.computed); } -function lookupVariable( - context: Context, - identifier: Extract, -): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(identifier); - while (scope !== null) { - const variable = scope.set.get(identifier.name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; -} - -/** Locals bound by `import * as X from "effect"` — `X.Layer.fresh` must still be caught. */ -function collectRootNamespaces(program: ESTree.Program): Set { +function collectTypeOnlyLocals(program: ESTree.Program): Set { const locals = new Set(); for (const statement of program.body) { if (statement.type !== 'ImportDeclaration') continue; - if (statement.source.value !== EFFECT_ROOT_MODULE) continue; for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportNamespaceSpecifier') locals.add(specifier.local.name); + if ( + statement.importKind === 'type' || + (specifier.type === 'ImportSpecifier' && specifier.importKind === 'type') + ) { + locals.add(specifier.local.name); + } } } return locals; } -/** Locals bound by `import { fresh as freshLayer } from "effect/Layer"` — bare references must be caught. */ -function collectDirectMemberImports(program: ESTree.Program): Set { - const locals = new Set(); - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - if (!EFFECT_LAYER_MODULE.test(statement.source.value)) continue; - for (const specifier of statement.specifiers) { - if (specifier.type !== 'ImportSpecifier') continue; - const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; - if (imported === FRESH_MEMBER) locals.add(specifier.local.name); - } - } - return locals; +const IMPORT_EXPORT_POSITIONS = new Set([ + 'ImportSpecifier', + 'ImportDefaultSpecifier', + 'ImportNamespaceSpecifier', + 'ExportSpecifier', +]); + +function isNamePosition(node: ESTree.Node): boolean { + const parent = node.parent; + if (parent == null) return true; + if (IMPORT_EXPORT_POSITIONS.has(parent.type)) return true; + const holder = parent as unknown as { + key?: ESTree.Node; + property?: ESTree.Node; + computed?: boolean; + }; + const isKey = holder.key === node || holder.property === node; + return NAME_POSITION_PARENTS.has(parent.type) && isKey && holder.computed !== true; } /** `await import("effect/Layer")` / `import("effect")` → the module specifier, else `null`. */ @@ -238,7 +183,11 @@ export const rule = defineRule({ const program = context.sourceCode.ast; const bindings = collectEffectBindings(program); const rootNamespaces = collectRootNamespaces(program); - const directMembers = collectDirectMemberImports(program); + const directMembers = collectNamedImports( + program, + (source) => EFFECT_LAYER_MODULE.test(source), + new Set([FRESH_MEMBER]), + ); const hasDynamicImport = DYNAMIC_EFFECT_IMPORT.test(context.sourceCode.text); if ( !bindings.importsEffect && @@ -256,18 +205,7 @@ export const rule = defineRule({ } /** Locals introduced by a dynamic `import("effect...")`; their def is a `Variable`, not an import. */ const dynamicLocals = new Set(); - const typeOnlyLocals = new Set(); - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - for (const specifier of statement.specifiers) { - if ( - statement.importKind === 'type' || - (specifier.type === 'ImportSpecifier' && specifier.importKind === 'type') - ) { - typeOnlyLocals.add(specifier.local.name); - } - } - } + const typeOnlyLocals = collectTypeOnlyLocals(program); const isTypePosition = (node: ESTree.Node): boolean => { const parent = node.parent; return ( @@ -328,6 +266,21 @@ export const rule = defineRule({ } }; + const handleRootPattern = (pattern: Extract): void => { + for (const property of pattern.properties) { + if (property.type !== 'Property') continue; + if (staticKey(property.key as ESTree.Node, property.computed === true) !== LAYER_NAMESPACE) + continue; + const value = property.value as ESTree.Node; + if (value.type === 'Identifier') { + layerLocals.add(value.name); + dynamicLocals.add(value.start); + } else if (value.type === 'ObjectPattern') { + reportFreshPatternProperties(value); + } + } + }; + /** Record what a `const … = await import("effect…")` binds, and report direct `fresh` grabs. */ const handleDynamicImport = (id: ESTree.Node, source: string): void => { const isLayerModule = EFFECT_LAYER_MODULE.test(source); @@ -344,18 +297,7 @@ export const rule = defineRule({ return; } if (source !== EFFECT_ROOT_MODULE) return; - for (const property of id.properties) { - if (property.type !== 'Property') continue; - if (staticKey(property.key as ESTree.Node, property.computed === true) !== LAYER_NAMESPACE) - continue; - const value = property.value as ESTree.Node; - if (value.type === 'Identifier') { - layerLocals.add(value.name); - dynamicLocals.add(value.start); - } else if (value.type === 'ObjectPattern') { - reportFreshPatternProperties(value); - } - } + handleRootPattern(id); }; return { @@ -388,19 +330,7 @@ export const rule = defineRule({ Identifier(node) { if (isTypePosition(node)) return; if (directMembers.size === 0 || !directMembers.has(node.name)) return; - const parent = node.parent; - if (parent === null || parent === undefined) return; - // Declaration sites: `import { fresh }`, `export { fresh }`. - if (parent.type === 'ImportSpecifier' || parent.type === 'ImportDefaultSpecifier') return; - if (parent.type === 'ImportNamespaceSpecifier' || parent.type === 'ExportSpecifier') return; - // Name positions: object/class members and TypeScript signature keys are not references. - const holder = parent as unknown as { - key?: ESTree.Node; - property?: ESTree.Node; - computed?: boolean; - }; - const isKey = holder.key === node || holder.property === node; - if (NAME_POSITION_PARENTS.has(parent.type) && isKey && holder.computed !== true) return; + if (isNamePosition(node)) return; if (!resolvesToModuleBinding(node)) return; report(node); }, diff --git a/app/tools/oxlint/effect-native/rules/no-layer-or-die-outside-root.ts b/app/tools/oxlint/effect-native/rules/no-layer-or-die-outside-root.ts index 8bb629785..98e6249c3 100644 --- a/app/tools/oxlint/effect-native/rules/no-layer-or-die-outside-root.ts +++ b/app/tools/oxlint/effect-native/rules/no-layer-or-die-outside-root.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * Audit finding: **A1** — "Establish one process-level Layer and ManagedRuntime composition model" * (`docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`). A1 counts 12 `Layer.orDie` sites while the @@ -39,22 +40,19 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree } from '@oxlint/plugins'; import { collectEffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; +import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; +import { stringArray } from '../shared/options.ts'; +import { importedName } from '../shared/imports.ts'; +import { lookupVariable } from '../shared/bindings.ts'; +import { asNode, keyName as staticKeyName, memberName } from '../shared/ast.ts'; const LAYER_NAMESPACE = 'Layer'; const EFFECT_ROOT_MODULE = 'effect'; const EFFECT_LAYER_MODULE = /^effect\/(?:.*\/)?Layer$/u; -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production `include`/`rootFiles` defaults - * instead of forcing the fixture config to pass loosened options (which `run-on-repo.mts` reuses). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; - const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; const DEFAULT_EXCLUDE: readonly string[] = []; @@ -96,18 +94,8 @@ interface RuleOptions { readonly allowTestFiles: boolean; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); const maxPerRoot = record.maxPerRoot; return { include: stringArray(record.include, DEFAULT_INCLUDE), @@ -123,21 +111,6 @@ function readOptions(context: Context): RuleOptions { }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -function importedName(specifier: ESTree.ImportSpecifier): string { - return specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; -} - /** Strip erased TS value wrappers and parentheses: `(Layer as X)!` → `Layer`. */ function unwrapValue(node: unknown): ESTree.Node | null { let current = node as { type?: string; expression?: unknown } | null | undefined; @@ -163,34 +136,9 @@ function isTypePosition(node: ESTree.Node): boolean { return !TS_VALUE_WRAPPERS.has(parent.type); } -/** Non-computed `.orDie`, or computed `["orDie"]`. */ -function memberName(node: ESTree.MemberExpression): string | null { - if (!node.computed) return node.property.type === 'Identifier' ? node.property.name : null; - const property = node.property; - if (property.type === 'Literal' && typeof property.value === 'string') return property.value; - return null; -} - -/** Non-computed object-pattern / object-literal key name. */ +/** Object-pattern computed keys are deliberately excluded. */ function keyName(node: { computed: boolean; key: ESTree.Node }): string | null { - if (node.computed) return null; - const key = node.key as { type: string; name?: string; value?: unknown }; - if (key.type === 'Identifier' && typeof key.name === 'string') return key.name; - if (key.type === 'Literal' && typeof key.value === 'string') return key.value; - return null; -} - -function lookupVariable( - context: Context, - identifier: Extract, -): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(identifier); - while (scope !== null) { - const variable = scope.set.get(identifier.name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; + return node.computed ? null : staticKeyName(node.key, false, { templates: false }); } /** @@ -206,22 +154,29 @@ function collectDeclarators(program: ESTree.Program): ESTree.VariableDeclarator[ if (current === null || typeof current !== 'object') continue; if (seen.has(current)) continue; seen.add(current); - if (Array.isArray(current)) { - for (const item of current) stack.push(item); - continue; - } - const record = current as Record; - if (record.type === 'VariableDeclarator') - found.push(current as unknown as ESTree.VariableDeclarator); - for (const key of Object.keys(record)) { - if (key === 'parent' || key === 'comments' || key === 'tokens') continue; - const value = record[key]; - if (value !== null && typeof value === 'object') stack.push(value); - } + collectDeclaratorChildren(current, stack, found); } return found; } +function collectDeclaratorChildren( + current: object, + stack: unknown[], + found: ESTree.VariableDeclarator[], +): void { + if (Array.isArray(current)) { + for (const item of current) stack.push(item); + return; + } + const record = current as Record; + if (record.type === 'VariableDeclarator') found.push(current as ESTree.VariableDeclarator); + for (const key of Object.keys(record)) { + if (['parent', 'comments', 'tokens'].includes(key)) continue; + const value = record[key]; + if (value !== null && typeof value === 'object') stack.push(value); + } +} + /** name → start offsets of every binding site that makes that name stand for the tracked thing. */ type BindingMap = Map>; @@ -236,6 +191,84 @@ function addBinding(map: BindingMap, name: string, start: number): boolean { return true; } +type BindingKind = 'layer' | 'barrel' | 'member'; +type BindingMaps = Record; + +function collectImportBindings( + program: ESTree.Program, + options: RuleOptions, + namespaces: ReadonlyMap, + maps: BindingMaps, +): void { + for (const statement of program.body) { + if (statement.type !== 'ImportDeclaration' || statement.importKind === 'type') continue; + const source = statement.source.value; + const isRoot = source === EFFECT_ROOT_MODULE || matchesGlobs(source, options.reexportModules); + const isLayer = EFFECT_LAYER_MODULE.test(source); + for (const specifier of statement.specifiers) { + collectImportSpecifier(specifier, isRoot, isLayer, namespaces, options.members, maps); + } + } +} + +function isLayerNamespace( + namespaces: ReadonlyMap, + name: string, + isLayer: boolean, +): boolean { + return namespaces.get(name) === LAYER_NAMESPACE || isLayer; +} + +function isInScope(path: string, options: RuleOptions): boolean { + if (matchesGlobs(path, options.exclude)) return false; + if (!matchesGlobs(path, options.include)) return false; + return options.allowTestFiles || !isTestFile(path); +} + +function collectImportSpecifier( + specifier: ESTree.ImportDeclaration['specifiers'][number], + isRoot: boolean, + isLayer: boolean, + namespaces: ReadonlyMap, + members: readonly string[], + maps: BindingMaps, +): void { + const local = specifier.local; + if (specifier.type === 'ImportNamespaceSpecifier') { + if (isRoot) addBinding(maps.barrel, local.name, local.start); + else if (isLayerNamespace(namespaces, local.name, isLayer)) + addBinding(maps.layer, local.name, local.start); + return; + } + if (specifier.type !== 'ImportSpecifier' || specifier.importKind === 'type') return; + const imported = importedName(specifier); + if (isRoot && imported === LAYER_NAMESPACE) addBinding(maps.layer, local.name, local.start); + if (isLayer && members.includes(imported)) addBinding(maps.member, local.name, local.start); +} + +function isIdentifierNamePosition(node: Extract): boolean { + const parent = node.parent; + if (parent == null) return true; + if ( + [ + 'ImportSpecifier', + 'ImportDefaultSpecifier', + 'ImportNamespaceSpecifier', + 'ExportSpecifier', + ].includes(parent.type) + ) + return true; + if (parent.type === 'MemberExpression') + return !parent.computed && parent.property.start === node.start; + if ( + parent.type === 'Property' || + parent.type === 'PropertyDefinition' || + parent.type === 'MethodDefinition' + ) + return !parent.computed && parent.key.start === node.start; + return false; +} + export const rule = defineRule({ meta: { type: 'problem', @@ -284,9 +317,7 @@ export const rule = defineRule({ create(context) { const options = readOptions(context); const path = scopePath(context.filename); - if (matchesGlobs(path, options.exclude)) return {}; - if (!matchesGlobs(path, options.include)) return {}; - if (!options.allowTestFiles && isTestFile(path)) return {}; + if (!isInScope(path, options)) return {}; const program = context.sourceCode.ast; const bindings = collectEffectBindings(program); @@ -298,34 +329,11 @@ export const rule = defineRule({ /** Locals standing for `Layer.orDie` itself (`import { orDie } from "effect/Layer"`). */ const memberBindings: BindingMap = new Map(); - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - // `import type { Layer } from "effect"` is erased: it can never produce a defect. - if ((statement as { importKind?: string }).importKind === 'type') continue; - const source = statement.source.value; - const isEffectRoot = source === EFFECT_ROOT_MODULE; - const isReexport = matchesGlobs(source, options.reexportModules); - const isLayerModule = EFFECT_LAYER_MODULE.test(source); - for (const specifier of statement.specifiers) { - const local = specifier.local; - if (specifier.type === 'ImportNamespaceSpecifier') { - if (isEffectRoot || isReexport) addBinding(barrelBindings, local.name, local.start); - else if (bindings.namespaces.get(local.name) === LAYER_NAMESPACE || isLayerModule) { - addBinding(layerBindings, local.name, local.start); - } - continue; - } - if (specifier.type !== 'ImportSpecifier') continue; - if ((specifier as { importKind?: string }).importKind === 'type') continue; - const imported = importedName(specifier); - if ((isEffectRoot || isReexport) && imported === LAYER_NAMESPACE) { - addBinding(layerBindings, local.name, local.start); - } - if (isLayerModule && options.members.includes(imported)) { - addBinding(memberBindings, local.name, local.start); - } - } - } + collectImportBindings(program, options, bindings.namespaces, { + layer: layerBindings, + barrel: barrelBindings, + member: memberBindings, + }); const declarators = collectDeclarators(program); @@ -390,74 +398,73 @@ export const rule = defineRule({ return variable.defs.some((definition) => definition.name.start === identifier.start); }; - /** Bind every name introduced by `pattern` to `kind`; returns whether anything was new. */ - const bindPattern = (pattern: unknown, kind: 'layer' | 'barrel' | 'member'): boolean => { - const target = pattern as - | { type?: string; name?: string; start?: number; properties?: unknown[]; left?: unknown } - | null - | undefined; - if (target === null || target === undefined || typeof target.type !== 'string') return false; + const maps: BindingMaps = { + layer: layerBindings, + barrel: barrelBindings, + member: memberBindings, + }; + + const propertyKind = (name: string | null, kind: BindingKind): BindingKind | null => { + if (kind === 'barrel' && name === LAYER_NAMESPACE) return 'layer'; + if (kind === 'layer' && name !== null && options.members.includes(name)) return 'member'; + return null; + }; + + const bindProperty = (property: unknown, kind: BindingKind): boolean => { + const entry = asNode(property); + if (entry?.type !== 'Property' || entry.key === undefined) return false; + const nextKind = propertyKind( + keyName({ computed: entry.computed === true, key: entry.key }), + kind, + ); + return nextKind === null ? false : bindPattern(entry.value, nextKind); + }; + + /** Bind names while preserving assignment and nested object-pattern semantics. */ + const bindPattern = (pattern: unknown, kind: BindingKind): boolean => { + const target = asNode(pattern); + if (target === null) return false; if (target.type === 'AssignmentPattern') return bindPattern(target.left, kind); - if ( - target.type === 'Identifier' && - typeof target.name === 'string' && - typeof target.start === 'number' - ) { - const map = - kind === 'layer' ? layerBindings : kind === 'barrel' ? barrelBindings : memberBindings; - return addBinding(map, target.name, target.start); + if (target.type === 'Identifier') { + if (typeof target.name !== 'string' || typeof target.start !== 'number') return false; + return addBinding(maps[kind], target.name, target.start); } - if (target.type !== 'ObjectPattern' || !Array.isArray(target.properties)) return false; - if (kind === 'member') return false; - let changed = false; - for (const property of target.properties) { - const entry = property as { - type?: string; - computed?: boolean; - key?: ESTree.Node; - value?: unknown; - }; - if (entry.type !== 'Property' || entry.key === undefined) continue; - const name = keyName({ computed: entry.computed === true, key: entry.key }); - if (name === null) continue; - if (kind === 'barrel' && name === LAYER_NAMESPACE) - changed = bindPattern(entry.value, 'layer') || changed; - else if (kind === 'layer' && options.members.includes(name)) { - changed = bindPattern(entry.value, 'member') || changed; - } - } - return changed; + if (target.type !== 'ObjectPattern' || !Array.isArray(target.properties) || kind === 'member') + return false; + return target.properties.reduce( + (changed: boolean, property: unknown) => bindProperty(property, kind) || changed, + false, + ); + }; + + const identifierKind = ( + node: Extract, + ): BindingKind | null => { + if (resolvesTo(layerBindings, node)) return 'layer'; + if (resolvesTo(barrelBindings, node)) return 'barrel'; + return resolvesTo(memberBindings, node) ? 'member' : null; }; - /** One alias-propagation pass over every declarator; returns whether anything was learned. */ + const aliasKind = (init: ESTree.Node): BindingKind | null => { + if (init.type === 'Identifier') return identifierKind(init); + if (init.type !== 'MemberExpression') return null; + const name = memberName(init); + if (name === null) return null; + const object = unwrapValue(init.object); + if (object?.type !== 'Identifier') return null; + if (name === LAYER_NAMESPACE && resolvesTo(barrelBindings, object)) return 'layer'; + if (options.members.includes(name) && resolvesTo(layerBindings, object)) return 'member'; + return null; + }; + + /** One pass; evaluate every declarator even after an earlier binding changed. */ const propagateAliases = (): boolean => { let changed = false; for (const declarator of declarators) { const init = unwrapValue(declarator.init); if (init === null) continue; - if (init.type === 'Identifier') { - if (resolvesTo(layerBindings, init)) - changed = bindPattern(declarator.id, 'layer') || changed; - else if (resolvesTo(barrelBindings, init)) - changed = bindPattern(declarator.id, 'barrel') || changed; - else if (resolvesTo(memberBindings, init)) - changed = bindPattern(declarator.id, 'member') || changed; - continue; - } - if (init.type !== 'MemberExpression') continue; - const name = memberName(init); - if (name === null) continue; - const object = unwrapValue(init.object); - if (object === null || object.type !== 'Identifier') continue; - // `const L = Effect.Layer` / `const { orDie } = Effect.Layer` - if (name === LAYER_NAMESPACE && resolvesTo(barrelBindings, object)) { - changed = bindPattern(declarator.id, 'layer') || changed; - continue; - } - // `const die = Layer.orDie` - if (options.members.includes(name) && resolvesTo(layerBindings, object)) { - changed = bindPattern(declarator.id, 'member') || changed; - } + const kind = aliasKind(init); + if (kind !== null) changed = bindPattern(declarator.id, kind) || changed; } return changed; }; @@ -508,31 +515,7 @@ export const rule = defineRule({ Identifier(node) { if (!candidateMemberNames.has(node.name)) return; if (isTypePosition(node)) return; - const parent = node.parent; - if (parent === null || parent === undefined) return; - // Declaration sites and non-reference positions are not calls. - if (parent.type === 'ImportSpecifier' || parent.type === 'ImportDefaultSpecifier') return; - if (parent.type === 'ImportNamespaceSpecifier' || parent.type === 'ExportSpecifier') return; - if ( - parent.type === 'MemberExpression' && - !parent.computed && - parent.property.start === node.start - ) - return; - if (parent.type === 'Property' && !parent.computed && parent.key.start === node.start) - return; - if ( - parent.type === 'PropertyDefinition' && - !parent.computed && - parent.key.start === node.start - ) - return; - if ( - parent.type === 'MethodDefinition' && - !parent.computed && - parent.key.start === node.start - ) - return; + if (isIdentifierNamePosition(node)) return; if (isDeclarationSite(node)) return; candidates.push({ node, diff --git a/app/tools/oxlint/effect-native/rules/no-layer-provide-in-library.ts b/app/tools/oxlint/effect-native/rules/no-layer-provide-in-library.ts index 52687e4c5..d17127935 100644 --- a/app/tools/oxlint/effect-native/rules/no-layer-provide-in-library.ts +++ b/app/tools/oxlint/effect-native/rules/no-layer-provide-in-library.ts @@ -1,3 +1,4 @@ +import { isNonReferencePosition } from '../shared/reference-positions.ts'; /** * effect-native/no-layer-provide-in-library * @@ -372,6 +373,49 @@ export const rule = defineRule({ reports.push({ node, messageId: 'layerProvideInLibrary', data: { member } }); } + function collectDirectReferences(): void { + // 4. Bare references to `import { provide } from "effect/Layer"` locals. + for (const identifier of identifierCandidates) { + if (!resolvesToImport(identifier, directMembers)) continue; + queue(identifier, directMembers.get(identifier.name) ?? identifier.name); + } + } + + function addLayerAliases(pattern: ESTree.Node): boolean { + let changed = false; + for (const identifier of patternIdentifiers(pattern)) { + if (layerAliasBindings.has(identifier.start)) continue; + layerAliasBindings.add(identifier.start); + changed = true; + } + return changed; + } + + function collectLayerAliases(declarator: ESTree.VariableDeclarator): boolean { + const init = declarator.init; + if (init === null) return false; + if (isLayerNamespaceExpression(init)) return addLayerAliases(declarator.id); + if (declarator.id.type !== 'ObjectPattern') return false; + if (init.type !== 'Identifier' || !resolvesToImport(init, effectRoots)) return false; + let changed = false; + for (const property of declarator.id.properties) { + if (property.type !== 'Property' || patternKeyName(property) !== LAYER_NAMESPACE) continue; + if (addLayerAliases(property.value)) changed = true; + } + return changed; + } + + function collectDestructuredMembers(declarator: ESTree.VariableDeclarator): void { + const init = declarator.init; + if (init === null || declarator.id.type !== 'ObjectPattern') return; + if (!isLayerNamespaceExpression(init)) return; + for (const property of declarator.id.properties) { + if (property.type !== 'Property') continue; + const name = patternKeyName(property); + if (name !== null && members.has(name)) queue(property, name); + } + } + return { MemberExpression(node) { if (isTypePosition(node)) return; @@ -384,16 +428,7 @@ export const rule = defineRule({ Identifier(node) { if (isTypePosition(node)) return; if (directMembers.size === 0 || !directMembers.has(node.name)) return; - const parent = node.parent; - if (parent === null || parent === undefined) return; - // Declaration sites and non-reference positions are not uses of the escape hatch. - if (parent.type === 'ImportSpecifier' || parent.type === 'ImportDefaultSpecifier') return; - if (parent.type === 'ImportNamespaceSpecifier' || parent.type === 'ExportSpecifier') return; - if (parent.type === 'MemberExpression' && parent.property === node && !parent.computed) - return; - if (parent.type === 'Property' && parent.key === node && !parent.computed) return; - if (parent.type === 'PropertyDefinition' && parent.key === node && !parent.computed) return; - if (parent.type === 'MethodDefinition' && parent.key === node && !parent.computed) return; + if (isNonReferencePosition(node)) return; identifierCandidates.push(node); }, VariableDeclarator(node) { @@ -401,49 +436,14 @@ export const rule = defineRule({ }, // A pure re-export composes no layer; A1 governs provision, not barrel vocabulary. 'Program:exit'() { - // 1. Fixed point over local rebindings of the `Layer` namespace. let changed = true; while (changed) { changed = false; for (const declarator of declarators) { - const init = declarator.init; - if (init === null) continue; - // `const L = Layer` / `const L = Effect.Layer` / `const L2 = L`. - if (isLayerNamespaceExpression(init)) { - for (const identifier of patternIdentifiers(declarator.id)) { - if (layerAliasBindings.has(identifier.start)) continue; - layerAliasBindings.add(identifier.start); - changed = true; - } - continue; - } - // `const { Layer } = Effect` / `const { Layer: L } = Effect`. - if (declarator.id.type !== 'ObjectPattern') continue; - if (init.type !== 'Identifier' || !resolvesToImport(init, effectRoots)) continue; - for (const property of declarator.id.properties) { - if (property.type !== 'Property') continue; - if (patternKeyName(property) !== LAYER_NAMESPACE) continue; - for (const identifier of patternIdentifiers(property.value)) { - if (layerAliasBindings.has(identifier.start)) continue; - layerAliasBindings.add(identifier.start); - changed = true; - } - } - } - } - - // 2. `const { provide, provideMerge } = ` drops the namespace entirely. - for (const declarator of declarators) { - const init = declarator.init; - if (init === null || declarator.id.type !== 'ObjectPattern') continue; - if (!isLayerNamespaceExpression(init)) continue; - for (const property of declarator.id.properties) { - if (property.type !== 'Property') continue; - const name = patternKeyName(property); - if (name === null || !members.has(name)) continue; - queue(property, name); + if (collectLayerAliases(declarator)) changed = true; } } + for (const declarator of declarators) collectDestructuredMembers(declarator); // 3. `Layer.provide` in every spelling, confirmed against scope. for (const candidate of memberCandidates) { @@ -451,11 +451,7 @@ export const rule = defineRule({ queue(candidate.node, candidate.member); } - // 4. Bare references to `import { provide } from "effect/Layer"` locals. - for (const identifier of identifierCandidates) { - if (!resolvesToImport(identifier, directMembers)) continue; - queue(identifier, directMembers.get(identifier.name) ?? identifier.name); - } + collectDirectReferences(); reports.sort((left, right) => left.node.start - right.node.start); for (const report of reports) { diff --git a/app/tools/oxlint/effect-native/rules/no-literal-union-type-alias.ts b/app/tools/oxlint/effect-native/rules/no-literal-union-type-alias.ts index bfd76f76d..81a5e01b7 100644 --- a/app/tools/oxlint/effect-native/rules/no-literal-union-type-alias.ts +++ b/app/tools/oxlint/effect-native/rules/no-literal-union-type-alias.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * Audit findings: **B5** — "Adopt Effect's ADTs and temporal model consistently" ("Closed * vocabularies and timestamps are repeatedly re-declared", "Highest-value targets are service @@ -82,14 +83,9 @@ import { effectMember, type EffectBindings, } from '../shared/effect-imports.ts'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; - -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production defaults instead of forcing the - * fixture config to pass loosened options (which `run-on-repo.mts` reuses against the real repo). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; +import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; +import { booleanOption as boolean, positiveInteger, stringArray } from '../shared/options.ts'; +import { asNode as sharedAsNode } from '../shared/ast.ts'; /** A2/B5 apply everywhere first-party TypeScript is authored. */ const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; @@ -116,26 +112,8 @@ interface RuleOptions { readonly allowedNames: readonly string[]; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - -function boolean(value: unknown, fallback: boolean): boolean { - return typeof value === 'boolean' ? value : fallback; -} - -function positiveInteger(value: unknown, fallback: number): number { - return typeof value === 'number' && Number.isInteger(value) && value >= 1 ? value : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { minMembers: positiveInteger(record.minMembers, DEFAULT_MIN_MEMBERS), include: stringArray(record.include, DEFAULT_INCLUDE), @@ -148,24 +126,9 @@ function readOptions(context: Context): RuleOptions { }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - type AnyNode = Record & { readonly type: string }; -function asNode(value: unknown): AnyNode | null { - return typeof value === 'object' && - value !== null && - typeof (value as { type?: unknown }).type === 'string' - ? (value as AnyNode) - : null; -} +const asNode: (value: unknown) => AnyNode | null = sharedAsNode; /** Strip `TSParenthesizedType` wrappers (`('a') | ('b')`). */ function unwrap(node: AnyNode): AnyNode { @@ -178,39 +141,28 @@ function unwrap(node: AnyNode): AnyNode { return current; } -/** The literal text of a string-literal type member, or `null` when the member is not one. */ -function stringLiteralMember(node: AnyNode): string | null { - if (node.type === 'TSLiteralType') { - const literal = asNode(node.literal); - if (literal === null) return null; - if (literal.type === 'Literal' || literal.type === 'StringLiteral') { - return typeof literal.value === 'string' ? literal.value : null; - } - // `` type A = `abc` `` — a template literal with no interpolation is a closed literal. - if (literal.type === 'TemplateLiteral') { - const expressions = Array.isArray(literal.expressions) ? literal.expressions : []; - if (expressions.length > 0) return null; - const quasis = Array.isArray(literal.quasis) ? literal.quasis : []; - const cooked = asNode(quasis[0])?.value; - const raw = - typeof cooked === 'object' && cooked !== null - ? (cooked as { raw?: unknown }).raw - : undefined; - return typeof raw === 'string' ? raw : ''; - } - return null; - } - // Some parsers surface a bare `` `abc` `` type as TSTemplateLiteralType instead. - if (node.type === 'TSTemplateLiteralType') { - const types = Array.isArray(node.types) ? node.types : []; - if (types.length > 0) return null; - const quasis = Array.isArray(node.quasis) ? node.quasis : []; - const cooked = asNode(quasis[0])?.value; - const raw = - typeof cooked === 'object' && cooked !== null ? (cooked as { raw?: unknown }).raw : undefined; - return typeof raw === 'string' ? raw : ''; +/** Preserve raw template spelling and the rule’s empty-text fallback for malformed quasis. */ +function rawTemplateText(node: AnyNode, interpolationKey: 'expressions' | 'types'): string | null { + const interpolations = Array.isArray(node[interpolationKey]) ? node[interpolationKey] : []; + if (interpolations.length > 0) return null; + const quasis = Array.isArray(node.quasis) ? node.quasis : []; + const value = asNode(quasis[0])?.value; + const raw = + typeof value === 'object' && value !== null ? (value as { raw?: unknown }).raw : undefined; + return typeof raw === 'string' ? raw : ''; +} + +function stringExpression(node: AnyNode | null): string | null { + if (node === null) return null; + if (node.type === 'Literal' || node.type === 'StringLiteral') { + return typeof node.value === 'string' ? node.value : null; } - return null; + return node.type === 'TemplateLiteral' ? rawTemplateText(node, 'expressions') : null; +} + +function stringLiteralMember(node: AnyNode): string | null { + if (node.type === 'TSLiteralType') return stringExpression(asNode(node.literal)); + return node.type === 'TSTemplateLiteralType' ? rawTemplateText(node, 'types') : null; } function isNullish(node: AnyNode): boolean { @@ -309,21 +261,10 @@ function collectSchemaLiteralNames( if (!bindings.importsEffect) return names; const addDeclarator = (value: unknown): void => { const declarator = asNode(value); - if (declarator === null || declarator.type !== 'VariableDeclarator') return; - const id = asNode(declarator.id); - const init = asNode(declarator.init); - if (id?.type !== 'Identifier' || typeof id.name !== 'string' || init?.type !== 'CallExpression') - return; - const callee = asNode(init.callee); - if (callee === null) return; - const member = effectMember(callee as unknown as ESTree.Node, bindings); - if ( - member !== null && - member.namespace === SCHEMA_NAMESPACE && - SCHEMA_LITERAL_MEMBERS.has(member.member) - ) { - names.add(id.name); - } + if (declarator?.type !== 'VariableDeclarator') return; + const name = identifierName(declarator.id); + if (name === null || !isSchemaLiteralCall(declarator.init, bindings)) return; + names.add(name); }; for (const statement of program.body) { const node = asNode(statement); @@ -342,42 +283,58 @@ function collectSchemaLiteralNames( * *not* this finding: their runtime values are not the vocabulary, so `Schema.Literals` is not the * mechanical replacement. */ -function stringEnumMembers(node: AnyNode): readonly string[] | null { +function enumMembers(node: AnyNode): readonly unknown[] { + if (Array.isArray(node.members)) return node.members; const body = asNode(node.body); - const rawMembers = Array.isArray(node.members) - ? node.members - : body !== null && Array.isArray(body.members) - ? body.members - : null; - if (rawMembers === null || rawMembers.length === 0) return null; + return Array.isArray(body?.members) ? body.members : []; +} + +function stringEnumMembers(node: AnyNode): readonly string[] | null { + const members = enumMembers(node); + if (members.length === 0) return null; const values: string[] = []; - for (const entry of rawMembers) { - const member = asNode(entry); - if (member === null) return null; - const initializer = asNode(member.initializer); - if (initializer === null) return null; - if (initializer.type === 'Literal' || initializer.type === 'StringLiteral') { - if (typeof initializer.value !== 'string') return null; - values.push(initializer.value); - continue; - } - if (initializer.type === 'TemplateLiteral') { - const expressions = Array.isArray(initializer.expressions) ? initializer.expressions : []; - if (expressions.length > 0) return null; - const quasis = Array.isArray(initializer.quasis) ? initializer.quasis : []; - const cooked = asNode(quasis[0])?.value; - const raw = - typeof cooked === 'object' && cooked !== null - ? (cooked as { raw?: unknown }).raw - : undefined; - values.push(typeof raw === 'string' ? raw : ''); - continue; - } - return null; + for (const entry of members) { + const value = stringExpression(asNode(asNode(entry)?.initializer)); + if (value === null) return null; + values.push(value); } return values; } +function identifierName(value: unknown): string | null { + const id = asNode(value); + return id?.type === 'Identifier' && typeof id.name === 'string' ? id.name : null; +} + +function isSchemaLiteralCall(value: unknown, bindings: EffectBindings): boolean { + const init = asNode(value); + if (init?.type !== 'CallExpression') return false; + const callee = asNode(init.callee); + if (callee === null) return false; + const member = effectMember(callee as unknown as ESTree.Node, bindings); + return ( + member !== null && + member.namespace === SCHEMA_NAMESPACE && + SCHEMA_LITERAL_MEMBERS.has(member.member) + ); +} + +function eligibleName(node: ESTree.Node, options: RuleOptions): string | null { + if (options.ignoreAmbient && isAmbient(node)) return null; + const name = identifierName((node as unknown as AnyNode).id); + return name !== null && !options.allowedNames.includes(name) ? name : null; +} + +function aliasLiterals(raw: AnyNode, options: RuleOptions): readonly string[] | null { + if (raw.typeParameters !== null && raw.typeParameters !== undefined) return null; + const annotation = asNode(raw.typeAnnotation); + if (annotation === null) return null; + const union = unwrap(annotation); + if (union.type !== 'TSUnionType') return null; + const { literals, closed } = analyseUnion(union, options.ignoreNullishMembers); + return closed && literals.length >= options.minMembers ? literals : null; +} + /** Candidate const names that would own the same vocabulary as an alias called `name`. */ function schemaOwnerCandidates(name: string): readonly string[] { return [name, `${name}Schema`, `${name}Literals`, `${name}s`, name.replace(/Schema$/u, '')]; @@ -462,20 +419,10 @@ export const rule = defineRule({ return { TSTypeAliasDeclaration(node) { const raw = node as unknown as AnyNode; - if (raw.typeParameters !== null && raw.typeParameters !== undefined) return; - if (options.ignoreAmbient && isAmbient(node)) return; - - const id = asNode(raw.id); - const name = id?.type === 'Identifier' && typeof id.name === 'string' ? id.name : null; - if (name === null || options.allowedNames.includes(name)) return; - - const annotation = asNode(raw.typeAnnotation); - if (annotation === null) return; - const union = unwrap(annotation); - if (union.type !== 'TSUnionType') return; - - const { literals, closed } = analyseUnion(union, options.ignoreNullishMembers); - if (!closed || literals.length < options.minMembers) return; + const name = eligibleName(node, options); + if (name === null) return; + const literals = aliasLiterals(raw, options); + if (literals === null) return; schemaOwners ??= collectSchemaLiteralNames(program, bindings); const owner = schemaOwnerCandidates(name).find( @@ -483,7 +430,7 @@ export const rule = defineRule({ ); context.report({ - node: (id ?? raw) as unknown as ESTree.Node, + node: (asNode(raw.id) ?? raw) as unknown as ESTree.Node, messageId: owner === undefined ? 'literalUnionAlias' : 'duplicatesSchemaLiterals', data: { name, @@ -497,17 +444,14 @@ export const rule = defineRule({ TSEnumDeclaration(node) { if (!options.includeEnums) return; const raw = node as unknown as AnyNode; - if (options.ignoreAmbient && isAmbient(node)) return; - - const id = asNode(raw.id); - const name = id?.type === 'Identifier' && typeof id.name === 'string' ? id.name : null; - if (name === null || options.allowedNames.includes(name)) return; + const name = eligibleName(node, options); + if (name === null) return; const values = stringEnumMembers(raw); if (values === null || values.length < options.minMembers) return; context.report({ - node: (id ?? raw) as unknown as ESTree.Node, + node: (asNode(raw.id) ?? raw) as unknown as ESTree.Node, messageId: 'literalEnum', data: { name, count: String(values.length), members: preview(values) }, }); diff --git a/app/tools/oxlint/effect-native/rules/no-local-defect-seam.ts b/app/tools/oxlint/effect-native/rules/no-local-defect-seam.ts index 70ff617fb..bb1820477 100644 --- a/app/tools/oxlint/effect-native/rules/no-local-defect-seam.ts +++ b/app/tools/oxlint/effect-native/rules/no-local-defect-seam.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * Audit findings: **A4** — "Rebuild the error system around typed channels and contract-owned Problem * Details" and **A6** — "Activate real observability at the runtime roots" @@ -52,21 +53,27 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; - -import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isScriptFile, isTestFile, normalisePath } from '../shared/paths.ts'; - -const EFFECT_ROOT_MODULE = 'effect'; -/** `effect/Cause`, `effect/unstable/.../Effect`, ... — the trailing segment is the namespace. */ -const EFFECT_SUBMODULE = /^effect\/(?:.*\/)?(?[A-Za-z][A-Za-z0-9_]*)$/u; - -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production defaults instead of forcing the - * fixture config to pass loosened options (which `run-on-repo.mts` reuses against the repository). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; +import type { Context, ESTree } from '@oxlint/plugins'; + +import { lookupVariable } from '../shared/bindings.ts'; +import { collectEffectBindings } from '../shared/effect-imports.ts'; +import { effectOrigin } from '../shared/effect-identity.ts'; +import { + collectDirectMemberImports, + collectNamespaceLocals, + splitMembers, +} from '../shared/imports.ts'; +import { stringArray } from '../shared/options.ts'; +import { isScriptFile, isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; +import { isInTypePosition, isNonReferencePosition } from '../shared/reference-positions.ts'; + +const RUNTIME_TS_EXPRESSIONS = new Set([ + 'TSAsExpression', + 'TSSatisfiesExpression', + 'TSNonNullExpression', + 'TSInstantiationExpression', + 'TSTypeAssertion', +]); const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**']; @@ -120,18 +127,8 @@ interface RuleOptions { readonly includeScripts: boolean; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { include: stringArray(record.include, DEFAULT_INCLUDE), ignore: stringArray(record.ignore, DEFAULT_IGNORE), @@ -143,246 +140,11 @@ function readOptions(context: Context): RuleOptions { }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -function importedName(specifier: ESTree.ImportSpecifier): string { - return specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; -} - -/** `Effect.catchCause` → `["Effect", "catchCause"]`; unknown namespaces are dropped by the caller. */ -function splitMembers(members: readonly string[]): { - byNamespace: ReadonlyMap>; - namespaces: ReadonlySet; -} { - const byNamespace = new Map>(); - for (const entry of members) { - const dot = entry.indexOf('.'); - if (dot <= 0 || dot === entry.length - 1) continue; - const namespace = entry.slice(0, dot); - const member = entry.slice(dot + 1); - const bucket = byNamespace.get(namespace) ?? new Set(); - bucket.add(member); - byNamespace.set(namespace, bucket); - } - return { byNamespace, namespaces: new Set(byNamespace.keys()) }; -} - -/** - * Locals standing for a watched Effect namespace (`Effect`, `Cause`, ...) and locals standing for the - * whole Effect barrel (`import * as E from "effect"` → `E.Cause.hasDies`). The `effect`/`effect/*` - * half comes from the shared binding collector; `reexportModules` covers verbatim re-export barrels. - */ -function collectNamespaceLocals( - program: ESTree.Program, - bindings: EffectBindings, - watched: ReadonlySet, - reexportModules: readonly string[], -): { namespaced: ReadonlyMap; barrel: ReadonlySet } { - const namespaced = new Map(); - const barrel = new Set(); - for (const [local, namespace] of bindings.namespaces) { - if (watched.has(namespace)) namespaced.set(local, namespace); - } - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - const source = statement.source.value; - const isEffectRoot = source === EFFECT_ROOT_MODULE; - const isReexport = matchesGlobs(source, reexportModules); - if (!isEffectRoot && !isReexport) continue; - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportNamespaceSpecifier') barrel.add(specifier.local.name); - else if (specifier.type === 'ImportSpecifier') { - const imported = importedName(specifier); - if (watched.has(imported)) namespaced.set(specifier.local.name, imported); - } - } - } - return { namespaced, barrel }; -} - -/** - * Locals bound by `import { hasDies } from "effect/Cause"` — bare references must be caught. Maps the - * local name to the qualified `Namespace.member` the import resolves to. - */ -function collectDirectMemberImports( - program: ESTree.Program, - byNamespace: ReadonlyMap>, -): ReadonlyMap { - const locals = new Map(); - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - const namespace = EFFECT_SUBMODULE.exec(statement.source.value)?.groups?.namespace; - if (namespace === undefined) continue; - const members = byNamespace.get(namespace); - if (members === undefined) continue; - for (const specifier of statement.specifiers) { - if (specifier.type !== 'ImportSpecifier') continue; - const imported = importedName(specifier); - if (members.has(imported)) locals.set(specifier.local.name, `${namespace}.${imported}`); - } - } - return locals; -} - -/** Non-computed `.hasDies`, or computed `["hasDies"]`. */ -function memberName(node: ESTree.MemberExpression): string | null { - if (!node.computed) return node.property.type === 'Identifier' ? node.property.name : null; - const property = node.property; - if (property.type === 'Literal' && typeof property.value === 'string') return property.value; - return null; -} - -function lookupVariable( - context: Context, - identifier: Extract, -): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(identifier); - while (scope !== null) { - const variable = scope.set.get(identifier.name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; -} - -/** - * `true` when the identifier still resolves to an `import` binding. Unresolved names fall back to - * `true` because the module-level import declaration already proved the binding exists; only a local - * shadow (parameter, `const`, catch clause, ...) rejects the match. - */ -function resolvesToImport( - context: Context, - identifier: Extract, -): boolean { - const variable = lookupVariable(context, identifier); - if (variable === null) return true; - if (variable.defs.length === 0) return true; - return variable.defs.some((definition) => definition.type === 'ImportBinding'); -} - -/** Declaration and property-key positions are not references to the imported value. */ -function isReferencePosition(node: Extract): boolean { - const parent = node.parent; - if (parent === null || parent === undefined) return false; - if (parent.type === 'ImportSpecifier' || parent.type === 'ImportDefaultSpecifier') return false; - if (parent.type === 'ImportNamespaceSpecifier' || parent.type === 'ExportSpecifier') return false; - if (parent.type === 'MemberExpression' && parent.property === node && !parent.computed) - return false; - if (parent.type === 'Property' && parent.key === node && !parent.computed) return false; - if (parent.type === 'PropertyDefinition' && parent.key === node && !parent.computed) return false; - if (parent.type === 'MethodDefinition' && parent.key === node && !parent.computed) return false; - return true; -} - -// Resolve runtime identity, not spelling. Only immutable same-file aliases are followed; -// dynamic imports, mutable rebinding and arbitrary cross-module re-exports remain unknown. -function effectOrigin( - context: Context, - input: ESTree.Node, - barrels: readonly string[], - depth = 0, -): readonly string[] | null { - if (depth > 24) return null; - let node = input; - while ( - [ - 'ParenthesizedExpression', - 'ChainExpression', - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSInstantiationExpression', - 'TSTypeAssertion', - ].includes(node.type) - ) { - node = (node as { expression: ESTree.Node }).expression; - } - const keyOf = (key: ESTree.Node, computed: boolean): string | null => { - if (!computed && key.type === 'Identifier') return key.name; - if (key.type === 'Literal' && typeof key.value === 'string') return key.value; - if (key.type === 'TemplateLiteral' && key.expressions.length === 0) - return key.quasis[0]?.value.cooked ?? null; - return null; - }; - if (node.type === 'MemberExpression') { - const key = keyOf(node.property, node.computed); - const base = effectOrigin(context, node.object, barrels, depth + 1); - return base && key !== null ? [...base, key] : null; - } - if (node.type !== 'Identifier') return null; - let scope: ReturnType | null = - context.sourceCode.getScope(node); - while (scope) { - const variable = scope.set.get(node.name); - const defs = variable?.defs.filter( - (def) => - !['TSInterfaceDeclaration', 'TSTypeAliasDeclaration', 'TSTypeParameter'].includes( - def.node.type, - ), - ); - if (!variable || !defs?.length) { - scope = scope.upper; - continue; - } - if (defs.length !== 1) return null; - const def = defs[0]!; - if (def.type === 'ImportBinding') { - const spec = def.node; - const declaration = def.parent?.type === 'ImportDeclaration' ? def.parent : spec.parent; - if ( - declaration?.type !== 'ImportDeclaration' || - declaration.importKind === 'type' || - (spec as { importKind?: string }).importKind === 'type' - ) - return null; - const source = declaration.source.value; - const root = source === 'effect' || barrels.some((glob) => globToRegExp(glob).test(source)); - if (!root && !source.startsWith('effect/')) return null; - const base = root ? [] : [source.split('/').at(-1)!]; - if (spec.type === 'ImportNamespaceSpecifier' || spec.type === 'ImportDefaultSpecifier') - return base; - if (spec.type !== 'ImportSpecifier') return null; - return [ - ...base, - spec.imported.type === 'Identifier' ? spec.imported.name : spec.imported.value, - ]; - } - const declaration = def.node; - if ( - declaration.type !== 'VariableDeclarator' || - !declaration.init || - declaration.parent?.type !== 'VariableDeclaration' || - declaration.parent.kind !== 'const' - ) - return null; - if (variable.references.some((reference) => reference.isWrite() && !reference.init)) - return null; - const base = effectOrigin(context, declaration.init, barrels, depth + 1); - if (!base) return null; - if (declaration.id.type === 'Identifier') return base; - if (declaration.id.type !== 'ObjectPattern') return null; - for (const property of declaration.id.properties) { - if ( - property.type !== 'Property' || - property.value.type !== 'Identifier' || - property.value.name !== node.name - ) - continue; - const key = keyOf(property.key, property.computed); - return key === null ? null : [...base, key]; - } - return null; - } - return null; +function isIncludedPath(path: string, options: RuleOptions): boolean { + if (matchesGlobs(path, options.ignore) || matchesGlobs(path, options.seamPaths)) return false; + if (!matchesGlobs(path, options.include)) return false; + if (!options.includeTests && isTestFile(path)) return false; + return options.includeScripts || !isScriptFile(path); } export const rule = defineRule({ @@ -430,11 +192,7 @@ export const rule = defineRule({ create(context) { const options = readOptions(context); const path = scopePath(context.filename); - if (matchesGlobs(path, options.ignore)) return {}; - if (matchesGlobs(path, options.seamPaths)) return {}; - if (!matchesGlobs(path, options.include)) return {}; - if (!options.includeTests && isTestFile(path)) return {}; - if (!options.includeScripts && isScriptFile(path)) return {}; + if (!isIncludedPath(path, options)) return {}; const { byNamespace, namespaces: watched } = splitMembers(options.members); if (byNamespace.size === 0) return {}; @@ -466,7 +224,7 @@ export const rule = defineRule({ return { MemberExpression: inspect, Identifier(node) { - if (!isReferencePosition(node)) return; + if (isNonReferencePosition(node)) return; const variable = lookupVariable(context, node); if ( !variable?.references.some( @@ -475,21 +233,7 @@ export const rule = defineRule({ ) return; // Type queries and type-member names are not runtime seam references. - let ancestor = node.parent; - while (ancestor && ancestor.type !== 'Program') { - if ( - ancestor.type.startsWith('TS') && - ![ - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSInstantiationExpression', - 'TSTypeAssertion', - ].includes(ancestor.type) - ) - return; - ancestor = ancestor.parent; - } + if (isInTypePosition(node, RUNTIME_TS_EXPRESSIONS)) return; inspect(node); }, }; diff --git a/app/tools/oxlint/effect-native/rules/no-manual-config-in-scaffold-templates.ts b/app/tools/oxlint/effect-native/rules/no-manual-config-in-scaffold-templates.ts index 132e4103d..4d2a16af9 100644 --- a/app/tools/oxlint/effect-native/rules/no-manual-config-in-scaffold-templates.ts +++ b/app/tools/oxlint/effect-native/rules/no-manual-config-in-scaffold-templates.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * Audit findings: **A8** — "Fix the generators before generating more code" — and **A3** — "Replace * ambient configuration with Config, ConfigProvider, and Redacted" @@ -58,16 +59,13 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree } from '@oxlint/plugins'; +import type { Context } from '@oxlint/plugins'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; - -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production defaults instead of forcing the - * fixture config to pass loosened options (which `run-on-repo.mts` reuses against the real repo). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; +import { booleanOption, compilePatterns, stringArray } from '../shared/options.ts'; +import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; +import { snippet } from '../shared/reporting.ts'; +import { driverText, emittedText, maskText, reportNode } from '../shared/scaffold-text.ts'; +import type { StringNode } from '../shared/scaffold-text.ts'; /** Files whose template literals are emitted as source code for someone else's repository. */ const DEFAULT_TEMPLATE_PATHS: readonly string[] = [ @@ -119,131 +117,64 @@ interface RuleOptions { readonly ignoreTestFiles: boolean; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - -function boolean(value: unknown, fallback: boolean): boolean { - return typeof value === 'boolean' ? value : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { templatePaths: stringArray(record.templatePaths, DEFAULT_TEMPLATE_PATHS), patterns: stringArray(record.patterns, DEFAULT_PATTERNS), exclude: stringArray(record.exclude, DEFAULT_EXCLUDE), - ignoreTestFiles: boolean(record.ignoreTestFiles, true), + ignoreTestFiles: booleanOption(record.ignoreTestFiles, true), }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -/** Compile the option sources, silently dropping any that is not a valid regular expression. */ -function compilePatterns(sources: readonly string[]): readonly RegExp[] { - const compiled: RegExp[] = []; - for (const source of sources) { - try { - compiled.push(new RegExp(source, 'gu')); - } catch { - // A malformed user pattern must not take the whole lint run down; ignore it. - } - } - return compiled; -} - -/** One-line, length-capped echo of the offending template text for the diagnostic message. */ -function snippet(text: string): string { - const collapsed = text.replaceAll(/\s+/gu, ' ').trim(); - return collapsed.length > SNIPPET_LIMIT ? `${collapsed.slice(0, SNIPPET_LIMIT - 1)}…` : collapsed; -} - interface Match { readonly start: number; readonly end: number; readonly text: string; } -/** Lexical template inspection, not a type checker or an evaluator of interpolations. - * Mask comments and (optionally) strings without moving offsets. Dynamic generated fragments, - * regex literals and arbitrary helper-returned source cannot be fully reconstructed here. */ -function maskText(text: string, strings = false): string { - return text.replace( - /\/\*[\s\S]*?\*\/|\/\/[^\r\n]*|'(?:\\[\s\S]|[^'\\])*'|"(?:\\[\s\S]|[^"\\])*"|`(?:\\[\s\S]|[^`\\])*`/gu, - (value) => (value.startsWith('/') || strings ? value.replace(/[^\r\n]/g, ' ') : value), - ); +interface TemplateSource { + readonly code: string; + readonly syntax: string; + readonly config: boolean; } -/** Log/prose/shell arguments belong to the generator driver, not the emitted module. */ -function driverText(node: ESTree.Node): boolean { - if ( - node.parent?.type === 'ImportDeclaration' || - node.parent?.type === 'ImportExpression' || - node.parent?.type === 'ExportNamedDeclaration' || - node.parent?.type === 'ExportAllDeclaration' - ) - return true; - let current = node; - while (current.parent !== null && current.parent !== undefined) { - const parent = current.parent; - if (parent.type === 'CallExpression' || parent.type === 'NewExpression') { - const callee = parent.callee; - if ( - callee.type === 'Identifier' && - /^(?:Error|TypeError|exec|execSync|execFile|execFileSync|spawn|spawnSync)$/.test( - callee.name, - ) - ) - return true; - if ( - callee.type === 'MemberExpression' && - callee.object.type === 'Identifier' && - callee.object.name === 'console' - ) - return true; - return false; - } - if ( - ['VariableDeclarator', 'ReturnStatement', 'TemplateLiteral', 'Program'].includes(parent.type) - ) - return false; - current = parent; - } - return false; + +function templateSource(text: string): TemplateSource { + const syntax = maskText(text, true); + // Audit D preserves ordinary URL construction and recursive JSON normalization. + const config = + /\b(?:ONTOS_[A-Z_]+|process\s*\.\s*env|import\s*\.\s*meta\s*\.\s*env|\w*[Jj][Ww][Kk]\w*|\w*[Cc]onfig\w*|issuer|environment)\b/u.test( + syntax, + ); + return { code: maskText(text), syntax, config }; } -type StringNode = Extract; -/** Interpolations are opaque identifier placeholders, not evaluated generator code. */ -function emittedText(node: StringNode): string { - return node.type === 'TemplateLiteral' - ? node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join('_') - : typeof node.value === 'string' - ? node.value - : ''; +function isConfigurationMatch(match: RegExpExecArray, source: TemplateSource): boolean { + const text = match[0]; + // Matches starting inside emitted strings are data, not executable syntax. + if (source.syntax[match.index] === ' ') return false; + if (!source.config && /^(?:new\s+URL|Array\s*\.|typeof|as\s+Record)/u.test(text)) return false; + if (!/^new\s+URL/u.test(text)) return true; + return /^(?:issuer|endpoint|process\s*\.|environment\s*[.[])/iu.test( + source.code.slice(match.index + text.length).trimStart(), + ); } -/** Report the containing quasi (or whole literal across quasis), not a guessed raw offset. - * Cooked text normalises CRLF and escapes, so its character offsets are not source offsets. */ -function reportNode(node: StringNode, start: number, end: number): ESTree.Node { - if (node.type !== 'TemplateLiteral') return node; - let offset = 0; - for (const quasi of node.quasis) { - const length = (quasi.value.cooked ?? quasi.value.raw).length; - if (start >= offset && end <= offset + length) return quasi; - offset += length + 1; + +function collectMatches(patterns: readonly RegExp[], source: TemplateSource): Match[] { + const found: Match[] = []; + for (const pattern of patterns) { + pattern.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = pattern.exec(source.code)) !== null) { + if (match[0].length === 0) { + pattern.lastIndex++; + continue; + } + if (isConfigurationMatch(match, source)) + found.push({ start: match.index, end: match.index + match[0].length, text: match[0] }); + } } - return node; + return found.sort((a, b) => a.start - b.start || b.end - a.end); } export const rule = defineRule({ @@ -302,37 +233,7 @@ export const rule = defineRule({ if (driverText(node)) return; const text = emittedText(node); if (text.length === 0) return; - const code = maskText(text); - const syntax = maskText(text, true); - // Ordinary URL construction and recursive JSON normalization are explicitly preserved - // by audit D / Existing patterns. Restrict these ambiguous shapes to config/JWK text. - const config = - /\b(?:ONTOS_[A-Z_]+|process\s*\.\s*env|import\s*\.\s*meta\s*\.\s*env|\w*[Jj][Ww][Kk]\w*|\w*[Cc]onfig\w*|issuer|environment)\b/u.test( - syntax, - ); - const found: Match[] = []; - for (const pattern of patterns) { - pattern.lastIndex = 0; - let match: RegExpExecArray | null; - while ((match = pattern.exec(code)) !== null) { - if (match[0].length === 0) { - pattern.lastIndex++; - continue; - } - // Matches starting inside emitted strings are data, not executable syntax. - if (syntax[match.index] === ' ') continue; - if (!config && /^(?:new\s+URL|Array\s*\.|typeof|as\s+Record)/u.test(match[0])) continue; - if ( - /^new\s+URL/u.test(match[0]) && - !/^(?:issuer|endpoint|process\s*\.|environment\s*[.[])/iu.test( - code.slice(match.index + match[0].length).trimStart(), - ) - ) - continue; - found.push({ start: match.index, end: match.index + match[0].length, text: match[0] }); - } - } - found.sort((a, b) => a.start - b.start || b.end - a.end); + const found = collectMatches(patterns, templateSource(text)); let end = -1; for (const match of found) { if (match.start < end) continue; @@ -340,7 +241,7 @@ export const rule = defineRule({ context.report({ node: reportNode(node, match.start, match.end), messageId: 'manualConfigInTemplate', - data: { snippet: snippet(match.text) }, + data: { snippet: snippet(match.text, SNIPPET_LIMIT) }, }); } } diff --git a/app/tools/oxlint/effect-native/rules/no-manual-cookie-serialization.ts b/app/tools/oxlint/effect-native/rules/no-manual-cookie-serialization.ts index 3fbdd9331..f2fbc637a 100644 --- a/app/tools/oxlint/effect-native/rules/no-manual-cookie-serialization.ts +++ b/app/tools/oxlint/effect-native/rules/no-manual-cookie-serialization.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * Audit finding: **C1** — "Remove remaining hand-owned serialization" * (`docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`). C1 names cookie construction explicitly and @@ -50,17 +51,13 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope } from '@oxlint/plugins'; +import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; - -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production defaults instead of forcing the - * fixture config to pass loosened options (which `run-on-repo.mts` reuses). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; +import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; +import { stringArray } from '../shared/options.ts'; +import { unwrap as unwrapExpression, staticString, keyName } from '../shared/ast.ts'; +import { lookupVariable } from '../shared/bindings.ts'; const DEFAULT_PATHS = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; @@ -101,8 +98,6 @@ const COOKIE_ATTRIBUTE = /** `__Secure-` / `__Host-` cookie name prefixes carry an attribute contract in the name. */ const COOKIE_NAME_PREFIX = /^__(?:Secure|Host)-/u; -const STRING_ARRAY_TYPES = new Set(['Array', 'ReadonlyArray']); - interface RuleOptions { readonly paths: readonly string[]; readonly allowPaths: readonly string[]; @@ -113,18 +108,8 @@ interface RuleOptions { readonly contractNames: readonly string[]; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { paths: stringArray(record.paths, DEFAULT_PATHS), allowPaths: stringArray(record.allowPaths, []), @@ -136,28 +121,9 @@ function readOptions(context: Context): RuleOptions { }; } -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -/** See through parentheses, `as`/`satisfies` casts, `!` and optional-chain wrappers. */ +/** Preserve the rule's nullable, unlimited-depth expression unwrapping. */ function unwrap(node: ESTree.Node | null | undefined): ESTree.Node | null { - let current: ESTree.Node | null = node ?? null; - for (;;) { - if (current === null) return null; - if (current.type === 'ParenthesizedExpression') current = current.expression; - else if (current.type === 'TSAsExpression') current = current.expression; - else if (current.type === 'TSSatisfiesExpression') current = current.expression; - else if (current.type === 'TSNonNullExpression') current = current.expression; - else if (current.type === 'TSTypeAssertion' || current.type === 'TSInstantiationExpression') - current = current.expression; - else if (current.type === 'ChainExpression') current = current.expression; - else return current; - } + return unwrapExpression(node); } /** Non-computed `.foo`, or computed `["foo"]`. */ @@ -168,13 +134,12 @@ function memberName(node: ESTree.MemberExpression): string | null { } function literalString(node: ESTree.Node | null | undefined): string | null { - const target = unwrap(node); - if (target === null) return null; - if (target.type === 'Literal' && typeof target.value === 'string') return target.value; - if (target.type === 'TemplateLiteral' && target.expressions.length === 0) { - return target.quasis[0]?.value.cooked ?? target.quasis[0]?.value.raw ?? null; - } - return null; + return staticString(node, { + unwrap: {}, + templates: true, + rawTemplates: true, + singleQuasi: false, + }); } function isCookieText(text: string): boolean { @@ -243,28 +208,30 @@ function isCookieOwnedValue( isCookieNamespaceMember(context, target, bindings, namespaces) || isCookieOwnedValue(context, target.object, bindings, namespaces, depth + 1) ); - case 'LogicalExpression': - return ( - isCookieOwnedValue(context, target.left, bindings, namespaces, depth + 1) && - (literalString(target.right) === '' || - isCookieOwnedValue(context, target.right, bindings, namespaces, depth + 1)) + default: + return isOwnedComposite(target, (child) => + isCookieOwnedValue(context, child, bindings, namespaces, depth + 1), ); + } +} + +function isOwnedComposite( + target: ESTree.Node, + owned: (node: ESTree.Node | null) => boolean, +): boolean { + switch (target.type) { + case 'LogicalExpression': + return owned(target.left) && (literalString(target.right) === '' || owned(target.right)); case 'AwaitExpression': case 'YieldExpression': - return isCookieOwnedValue(context, target.argument, bindings, namespaces, depth + 1); + return owned(target.argument); case 'ConditionalExpression': - return ( - isCookieOwnedValue(context, target.consequent, bindings, namespaces, depth + 1) && - isCookieOwnedValue(context, target.alternate, bindings, namespaces, depth + 1) - ); + return owned(target.consequent) && owned(target.alternate); case 'ArrayExpression': return ( target.elements.length > 0 && target.elements.every( - (element) => - element !== null && - element.type !== 'SpreadElement' && - isCookieOwnedValue(context, element, bindings, namespaces, depth + 1), + (element) => element !== null && element.type !== 'SpreadElement' && owned(element), ) ); default: @@ -282,10 +249,7 @@ function isHandBuiltValue(node: ESTree.Node | null, depth = 0): boolean { case 'TemplateLiteral': return true; case 'BinaryExpression': - return ( - target.operator === '+' && - (isHandBuiltValue(target.left, depth + 1) || isHandBuiltValue(target.right, depth + 1)) - ); + return isHandBuiltConcatenation(target, depth); case 'ConditionalExpression': return ( isHandBuiltValue(target.consequent, depth + 1) || @@ -303,6 +267,16 @@ function isHandBuiltValue(node: ESTree.Node | null, depth = 0): boolean { } } +function isHandBuiltConcatenation( + target: ESTree.BinaryExpression | ESTree.PrivateInExpression, + depth: number, +): boolean { + return ( + target.operator === '+' && + (isHandBuiltValue(target.left, depth + 1) || isHandBuiltValue(target.right, depth + 1)) + ); +} + function propertyKeyName(node: ESTree.Node): string | null { if ( node.type !== 'Property' && @@ -310,14 +284,11 @@ function propertyKeyName(node: ESTree.Node): string | null { node.type !== 'PropertyDefinition' ) return null; - if (node.computed) { - const key = unwrap(node.key); - return literalString(key); - } - const key = node.key; - if (key.type === 'Identifier') return key.name; - if (key.type === 'Literal' && typeof key.value === 'string') return key.value; - return null; + return keyName( + node.key, + node.computed, + node.computed ? { unwrap: {}, templates: true, rawTemplates: true } : { templates: false }, + ); } /** The header name a header-writing call targets, plus the argument holding the written value. */ @@ -329,11 +300,18 @@ function headerWrite( if (callee === null || callee.type !== 'MemberExpression') return null; const method = memberName(callee); if (method === null || !HEADER_WRITERS.has(method)) return null; - for (const [index, argument] of node.arguments.entries()) { + return cookieHeaderArgument(context, node.arguments); +} + +function cookieHeaderArgument( + context: Context, + args: ESTree.CallExpression['arguments'], +): { name: string; value: ESTree.Node | null } | null { + for (const [index, argument] of args.entries()) { if (argument.type === 'SpreadElement') continue; const name = constantString(context, argument); if (name === null || name.toLowerCase() !== SET_COOKIE_HEADER) continue; - const next = node.arguments[index + 1]; + const next = args[index + 1]; if (next === undefined || next.type === 'SpreadElement') return null; return { name, value: next }; } @@ -347,23 +325,21 @@ function constantString(context: Context, input: ESTree.Node, depth = 0): string if (literal !== null) return literal; const node = unwrap(input); if (node?.type !== 'Identifier') return null; - let scope: Scope | null = context.sourceCode.getScope(node); - while (scope) { - const variable = scope.set.get(node.name); - if (variable) { - for (const def of variable.defs) { - if (def.type !== 'Variable' || def.node.type !== 'VariableDeclarator' || !def.node.init) - continue; - if (def.node.parent?.type === 'VariableDeclaration' && def.node.parent.kind === 'const') - return constantString(context, def.node.init, depth + 1); - } - return null; - } - scope = scope.upper; + const variable = lookupVariable(context, node); + for (const def of variable?.defs ?? []) { + const initializer = constantInitializer(def); + if (initializer !== null) return constantString(context, initializer, depth + 1); } return null; } +function constantInitializer(def: Variable['defs'][number]): ESTree.Node | null { + if (def.type !== 'Variable' || def.node.type !== 'VariableDeclarator') return null; + if (def.node.parent?.type !== 'VariableDeclaration' || def.node.parent.kind !== 'const') + return null; + return def.node.init ?? null; +} + export const rule = defineRule({ meta: { type: 'problem', diff --git a/app/tools/oxlint/effect-native/rules/no-manual-error-handling-in-scaffold-templates.ts b/app/tools/oxlint/effect-native/rules/no-manual-error-handling-in-scaffold-templates.ts index d4875c238..b7635f51f 100644 --- a/app/tools/oxlint/effect-native/rules/no-manual-error-handling-in-scaffold-templates.ts +++ b/app/tools/oxlint/effect-native/rules/no-manual-error-handling-in-scaffold-templates.ts @@ -1,3 +1,4 @@ +import { maskText, driverText, emittedText, reportNode } from '../shared/scaffold-text.ts'; /** * effect-native/no-manual-error-handling-in-scaffold-templates * @@ -205,74 +206,7 @@ function collectMatches(text: string, patterns: readonly RegExp[]): readonly Mat return kept; } -/** Lexical template inspection, not a type checker or an evaluator of interpolations. - * Mask comments and (optionally) strings without moving offsets. Dynamic generated fragments, - * regex literals and arbitrary helper-returned source cannot be fully reconstructed here. */ -function maskText(text: string, strings = false): string { - return text.replace( - /\/\*[\s\S]*?\*\/|\/\/[^\r\n]*|'(?:\\[\s\S]|[^'\\])*'|"(?:\\[\s\S]|[^"\\])*"|`(?:\\[\s\S]|[^`\\])*`/gu, - (value) => (value.startsWith('/') || strings ? value.replace(/[^\r\n]/g, ' ') : value), - ); -} -/** Log/prose/shell arguments belong to the generator driver, not the emitted module. */ -function driverText(node: ESTree.Node): boolean { - if ( - node.parent?.type === 'ImportDeclaration' || - node.parent?.type === 'ImportExpression' || - node.parent?.type === 'ExportNamedDeclaration' || - node.parent?.type === 'ExportAllDeclaration' - ) - return true; - let current = node; - while (current.parent !== null && current.parent !== undefined) { - const parent = current.parent; - if (parent.type === 'CallExpression' || parent.type === 'NewExpression') { - const callee = parent.callee; - if ( - callee.type === 'Identifier' && - /^(?:Error|TypeError|exec|execSync|execFile|execFileSync|spawn|spawnSync)$/.test( - callee.name, - ) - ) - return true; - if ( - callee.type === 'MemberExpression' && - callee.object.type === 'Identifier' && - callee.object.name === 'console' - ) - return true; - return false; - } - if ( - ['VariableDeclarator', 'ReturnStatement', 'TemplateLiteral', 'Program'].includes(parent.type) - ) - return false; - current = parent; - } - return false; -} - type StringNode = Extract; -/** Interpolations are opaque identifier placeholders, not evaluated generator code. */ -function emittedText(node: StringNode): string { - return node.type === 'TemplateLiteral' - ? node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join('_') - : typeof node.value === 'string' - ? node.value - : ''; -} -/** Report the containing quasi (or whole literal across quasis), not a guessed raw offset. - * Cooked text normalises CRLF and escapes, so its character offsets are not source offsets. */ -function reportNode(node: StringNode, start: number, end: number): ESTree.Node { - if (node.type !== 'TemplateLiteral') return node; - let offset = 0; - for (const quasi of node.quasis) { - const length = (quasi.value.cooked ?? quasi.value.raw).length; - if (start >= offset && end <= offset + length) return quasi; - offset += length + 1; - } - return node; -} export const rule = defineRule({ meta: { diff --git a/app/tools/oxlint/effect-native/rules/no-manual-identity-annotations.ts b/app/tools/oxlint/effect-native/rules/no-manual-identity-annotations.ts index 82edff041..201972423 100644 --- a/app/tools/oxlint/effect-native/rules/no-manual-identity-annotations.ts +++ b/app/tools/oxlint/effect-native/rules/no-manual-identity-annotations.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * Audit A6 (`docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`) calls for one outer * instrumentation seam and ambient identity annotations, replacing copied per-handler records. @@ -18,19 +19,22 @@ import { defineRule } from '@oxlint/plugins'; import { fileURLToPath } from 'node:url'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; - -import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; - -const EFFECT_ROOT_MODULE = 'effect'; - -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production defaults instead of forcing the - * fixture config to pass loosened options (which `run-on-repo.mts` reuses against the repository). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; +import type { Context, ESTree, Variable } from '@oxlint/plugins'; + +import { collectEffectBindings } from '../shared/effect-imports.ts'; +import { isTestFile, matchesGlobs, scopePath as legacyScopePath } from '../shared/paths.ts'; +import { stringArray } from '../shared/options.ts'; +import { + unwrapNode as unwrap, + staticString, + memberName as staticMemberName, +} from '../shared/ast.ts'; +import { lookupVariable, resolvesToImport } from '../shared/bindings.ts'; +import { + splitMembers, + collectNamespaceLocals, + collectDirectMemberImports, +} from '../shared/imports.ts'; const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**']; @@ -100,18 +104,8 @@ interface RuleOptions { readonly includeScripts: boolean; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { include: stringArray(record.include, DEFAULT_INCLUDE), ignore: stringArray(record.ignore, DEFAULT_IGNORE), @@ -133,33 +127,7 @@ function scopePath(filename: string): string { /(?:^|\/)tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\/(.*)$/u.exec(unified); if (fixture?.[1]) return fixture[1]; const root = fileURLToPath(new URL('../../../../', import.meta.url)).replaceAll('\\', '/'); - return unified.startsWith(root) - ? unified.slice(root.length) - : normalisePath(unified).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -function importedName(specifier: ESTree.ImportSpecifier): string { - return specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; -} - -/** `Effect.annotateLogs` → `["Effect", "annotateLogs"]`; malformed entries are dropped. */ -function splitMembers(members: readonly string[]): Map> { - const byNamespace = new Map>(); - for (const entry of members) { - const dot = entry.indexOf('.'); - if (dot <= 0 || dot === entry.length - 1) continue; - const namespace = entry.slice(0, dot); - const bucket = byNamespace.get(namespace) ?? new Set(); - bucket.add(entry.slice(dot + 1)); - byNamespace.set(namespace, bucket); - } - return byNamespace; + return unified.startsWith(root) ? unified.slice(root.length) : legacyScopePath(unified); } /** `x-correlation-id`, `correlation_id` and `correlationId` all collapse to `correlationid`. */ @@ -182,129 +150,73 @@ function identityKeyFor(key: string, identities: ReadonlyMap): s return null; } -/** Non-computed `.annotateLogs`, or computed `["annotateLogs"]`. */ +/** Computed annotation members accept cooked static templates and expression wrappers. */ function memberName(node: ESTree.MemberExpression): string | null { - if (!node.computed) return node.property.type === 'Identifier' ? node.property.name : null; + if (!node.computed) return staticMemberName(node); return literalString(node.property); } -function unwrap(node: ESTree.Node): ESTree.Node { - while ( - [ - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSTypeAssertion', - 'TSNonNullExpression', - 'TSInstantiationExpression', - 'ChainExpression', - 'ParenthesizedExpression', - ].includes(node.type) - ) { - node = (node as unknown as { expression: ESTree.Node }).expression; - } - return node; +function literalString(node: ESTree.Node | null | undefined): string | null { + return staticString(node, { unwrap: {}, singleQuasi: true }); } -function literalString(node: ESTree.Node | null | undefined): string | null { - if (node === null || node === undefined) return null; - node = unwrap(node); - if (node.type === 'Literal' && typeof node.value === 'string') return node.value; - if ( - node.type === 'TemplateLiteral' && - node.expressions.length === 0 && - node.quasis.length === 1 - ) { - return node.quasis[0]?.value.cooked ?? null; - } - return null; +function excludedPath(path: string, options: RuleOptions): boolean { + if (/\.d\.[cm]?ts$/u.test(path)) return true; + if (/(?:^|\/)(?:dist(?:-[^/]+)?|build|\.output|node_modules)\//u.test(path)) return true; + if (matchesGlobs(path, options.ignore) || matchesGlobs(path, options.seamFiles)) return true; + if (!matchesGlobs(path, options.include)) return true; + if (!options.includeTests && isTestFile(path)) return true; + return !options.includeScripts && /(?:^|\/)scripts\//u.test(path); +} + +function qualifiedMember(base: string | null, key: string | null): string | null { + if (base === null || key === null) return null; + return base === '$root' ? key : `${base}.${key}`; } -function lookupVariable( - context: Context, - identifier: Extract, -): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(identifier); - while (scope !== null) { - const variable = scope.set.get(identifier.name); - if (variable !== undefined) return variable; - scope = scope.upper; +function destructuredMember( + pattern: ESTree.Node, + name: string, + base: string | null, +): string | null { + if (pattern.type === 'Identifier') return base; + if (pattern.type !== 'ObjectPattern' || base === null) return null; + for (const property of pattern.properties) { + if ( + property.type !== 'Property' || + property.value.type !== 'Identifier' || + property.value.name !== name + ) + continue; + const key = + !property.computed && property.key.type === 'Identifier' + ? property.key.name + : literalString(property.key); + return qualifiedMember(base, key); } return null; } -/** - * `true` when the identifier still resolves to an `import` binding. Unresolved names fall back to - * `true` because a module-level import declaration already proved the binding exists; only a local - * shadow (parameter, `const`, catch clause, ...) rejects the match. - */ -function resolvesToImport( - context: Context, - identifier: Extract, -): boolean { - const variable = lookupVariable(context, identifier); - if (variable === null) return true; - if (variable.defs.length === 0) return true; - return variable.defs.some( - (definition) => - definition.type === 'ImportBinding' && - definition.parent?.type === 'ImportDeclaration' && - definition.parent.importKind !== 'type' && - (definition.node.type !== 'ImportSpecifier' || definition.node.importKind !== 'type'), - ); +function objectArgument( + args: ESTree.CallExpression['arguments'], +): ESTree.ObjectExpression | undefined { + for (const raw of args) { + const argument = unwrap(raw); + if (argument.type === 'ObjectExpression') return argument; + } + return undefined; } -/** - * Locals standing for a watched Effect namespace (`Effect`, aliased or submodule-imported) and locals - * standing for the whole Effect barrel (`import * as E from "effect"` → `E.Effect.annotateLogs`). - */ -function collectNamespaceLocals( - program: ESTree.Program, - bindings: EffectBindings, - watched: ReadonlySet, - reexportModules: readonly string[], -): { namespaced: ReadonlyMap; barrel: ReadonlySet } { - const namespaced = new Map(); - const barrel = new Set(); - for (const [local, namespace] of bindings.namespaces) { - if (watched.has(namespace)) namespaced.set(local, namespace); - } - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - const source = statement.source.value; - if (source !== EFFECT_ROOT_MODULE && !matchesGlobs(source, reexportModules)) continue; - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportNamespaceSpecifier') barrel.add(specifier.local.name); - else if (specifier.type === 'ImportSpecifier') { - const imported = importedName(specifier); - if (watched.has(imported)) namespaced.set(specifier.local.name, imported); - } - } - } - return { namespaced, barrel }; +function annotationKeyArgument(args: ESTree.CallExpression['arguments']): ESTree.Node | undefined { + if (args.length === 2) return args[0]; + if (args.length === 3) return args[1]; + return undefined; } -/** `effect/Effect`, `effect/unstable/.../Effect` — the trailing segment names the namespace. */ -const EFFECT_SUBMODULE = /^effect\/(?:.*\/)?(?[A-Za-z][A-Za-z0-9_]*)$/u; - -/** Locals bound by `import { annotateLogs } from "effect/Effect"`, mapped to `Namespace.member`. */ -function collectDirectMemberImports( - program: ESTree.Program, - byNamespace: ReadonlyMap>, -): ReadonlyMap { - const locals = new Map(); - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - const namespace = EFFECT_SUBMODULE.exec(statement.source.value)?.groups?.namespace; - if (namespace === undefined) continue; - const members = byNamespace.get(namespace); - if (members === undefined) continue; - for (const specifier of statement.specifiers) { - if (specifier.type !== 'ImportSpecifier') continue; - const imported = importedName(specifier); - if (members.has(imported)) locals.set(specifier.local.name, `${namespace}.${imported}`); - } - } - return locals; +function opaqueArgument(args: ESTree.CallExpression['arguments']): ESTree.Node | undefined { + if (args.length === 1) return args[0]; + if (args.length === 2) return args[1]; + return undefined; } export const rule = defineRule({ @@ -366,20 +278,14 @@ export const rule = defineRule({ create(context) { const options = readOptions(context); const path = scopePath(context.filename); - if ( - /\.d\.[cm]?ts$/u.test(path) || - /(?:^|\/)(?:dist(?:-[^/]+)?|build|\.output|node_modules)\//u.test(path) - ) - return {}; - if (matchesGlobs(path, options.ignore)) return {}; - if (matchesGlobs(path, options.seamFiles)) return {}; - if (!matchesGlobs(path, options.include)) return {}; - if (!options.includeTests && isTestFile(path)) return {}; - if (!options.includeScripts && /(?:^|\/)scripts\//u.test(path)) return {}; - - const annotationByNamespace = splitMembers(options.annotationMembers); - const spanByNamespace = splitMembers(options.spanMembers); - const allByNamespace = splitMembers([...options.annotationMembers, ...options.spanMembers]); + if (excludedPath(path, options)) return {}; + + const annotationByNamespace = splitMembers(options.annotationMembers).byNamespace; + const spanByNamespace = splitMembers(options.spanMembers).byNamespace; + const allByNamespace = splitMembers([ + ...options.annotationMembers, + ...options.spanMembers, + ]).byNamespace; if (allByNamespace.size === 0) return {}; const identities = new Map(); @@ -395,61 +301,53 @@ export const rule = defineRule({ watched, options.reexportModules, ); - const directMembers = collectDirectMemberImports(program, allByNamespace); + const directMembers = new Map( + [...collectDirectMemberImports(program, allByNamespace)].map( + ([local, { namespace, member }]) => [local, `${namespace}.${member}`], + ), + ); if (namespaced.size === 0 && barrel.size === 0 && directMembers.size === 0) return {}; + const resolveAlias = (variable: Variable, name: string, seen: Set): string | null => { + if ( + seen.has(variable) || + variable.references.some((reference) => reference.isWrite() && !reference.init) + ) + return null; + seen.add(variable); + const definition = variable.defs[0]; + if ( + definition?.type !== 'Variable' || + definition.node.type !== 'VariableDeclarator' || + !definition.node.init + ) + return null; + const declaration = definition.node; + return destructuredMember(declaration.id, name, resolveCallee(declaration.init!, seen)); + }; + + const resolveIdentifier = ( + callee: Extract, + seen: Set, + ): string | null => { + const variable = lookupVariable(context, callee); + if (variable && !resolvesToImport(context, callee, true)) + return resolveAlias(variable, callee.name, seen); + return ( + directMembers.get(callee.name) ?? + namespaced.get(callee.name) ?? + (barrel.has(callee.name) ? '$root' : null) + ); + }; + /** Resolve immutable aliases by their lexical definitions, never by a global name table. */ const resolveCallee = (input: ESTree.Node, seen = new Set()): string | null => { const callee = unwrap(input); - if (callee.type === 'Identifier') { - const variable = lookupVariable(context, callee); - if (variable && !resolvesToImport(context, callee)) { - if ( - seen.has(variable) || - variable.references.some((reference) => reference.isWrite() && !reference.init) - ) - return null; - seen.add(variable); - const definition = variable.defs[0]; - if ( - definition?.type !== 'Variable' || - definition.node.type !== 'VariableDeclarator' || - !definition.node.init - ) - return null; - const declaration = definition.node; - const base = resolveCallee(declaration.init!, seen); - if (declaration.id.type === 'Identifier') return base; - if (declaration.id.type !== 'ObjectPattern' || base === null) return null; - for (const property of declaration.id.properties) { - if ( - property.type !== 'Property' || - property.value.type !== 'Identifier' || - property.value.name !== callee.name - ) - continue; - const key = - !property.computed && property.key.type === 'Identifier' - ? property.key.name - : literalString(property.key); - return key === null ? null : base === '$root' ? key : `${base}.${key}`; - } - return null; - } - return ( - directMembers.get(callee.name) ?? - namespaced.get(callee.name) ?? - (barrel.has(callee.name) ? '$root' : null) - ); - } + if (callee.type === 'Identifier') return resolveIdentifier(callee, seen); if (callee.type !== 'MemberExpression') return null; const member = memberName(callee); const base = resolveCallee(callee.object, seen); - return member === null || base === null - ? null - : base === '$root' - ? member - : `${base}.${member}`; + return qualifiedMember(base, member); }; const reportIdentity = (node: ESTree.Node, key: string, member: string): void => { @@ -484,15 +382,13 @@ export const rule = defineRule({ */ const inspectAnnotationCall = (node: ESTree.CallExpression, member: string): void => { const args = node.arguments; - if (args.length === 0) return; - for (const index of [0, 1]) { - const argument = args[index] === undefined ? undefined : unwrap(args[index]!); - if (argument === undefined || argument.type !== 'ObjectExpression') continue; - inspectRecord(argument, member); + const record = objectArgument(args.slice(0, 2)); + if (record !== undefined) { + inspectRecord(record, member); return; } // `("key", value)` data-last, `(effect, "key", value)` data-first. - const keyNode = args.length === 2 ? args[0] : args.length === 3 ? args[1] : undefined; + const keyNode = annotationKeyArgument(args); const key = literalString(keyNode); if (key !== null && keyNode !== undefined) { const identity = identityKeyFor(key, identities); @@ -500,27 +396,24 @@ export const rule = defineRule({ return; } // No literal record and no literal key: a helper produced the annotations. - const opaque = args.length === 1 ? args[0] : args.length === 2 ? args[1] : undefined; + const opaque = opaqueArgument(args); if (opaque !== undefined && opaque.type !== 'ObjectExpression') reportOpaque(opaque, member); }; /** `withSpan(name, { attributes })` / `withSpan(effect, name, { attributes })`. */ const inspectSpanCall = (node: ESTree.CallExpression, member: string): void => { - for (const rawArgument of node.arguments) { - const argument = unwrap(rawArgument); - if (argument.type !== 'ObjectExpression') continue; - for (const property of argument.properties) { - if (property.type === 'SpreadElement') continue; - const key = - property.computed || property.key.type !== 'Identifier' - ? literalString(property.key) - : property.key.name; - if (key !== 'attributes') continue; - const value = unwrap(property.value); - if (value.type === 'ObjectExpression') inspectRecord(value, member); - else reportOpaque(value, member); - } - return; + const argument = objectArgument(node.arguments); + if (argument === undefined) return; + for (const property of argument.properties) { + if (property.type === 'SpreadElement') continue; + const key = + property.computed || property.key.type !== 'Identifier' + ? literalString(property.key) + : property.key.name; + if (key !== 'attributes') continue; + const value = unwrap(property.value); + if (value.type === 'ObjectExpression') inspectRecord(value, member); + else reportOpaque(value, member); } }; diff --git a/app/tools/oxlint/effect-native/rules/no-manual-route-param-parsing.ts b/app/tools/oxlint/effect-native/rules/no-manual-route-param-parsing.ts index ff7db98c1..efb06e9a4 100644 --- a/app/tools/oxlint/effect-native/rules/no-manual-route-param-parsing.ts +++ b/app/tools/oxlint/effect-native/rules/no-manual-route-param-parsing.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * Audit findings: **A9** — "Preserve typed Effects through the frontend" (target: "Schema-driven * route/search parameters through `Schema.standardSchemaV1`" and "Form codecs derived from payload @@ -45,16 +46,13 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree, Variable } from '@oxlint/plugins'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; - -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production `routeGlobs` defaults instead of - * forcing the fixture config to pass loosened options (which `run-on-repo.mts` reuses). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; +import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; +import { stringArray } from '../shared/options.ts'; +import { unwrap as unwrapAst, memberName as astMemberName, keyName } from '../shared/ast.ts'; +import { lookupVariable, resolvesToImport } from '../shared/bindings.ts'; +import { importedName } from '../shared/imports.ts'; /** Route modules: the frontend seam A9 names. Nested `routes/` directories are covered too. */ const DEFAULT_ROUTE_GLOBS = [ @@ -99,18 +97,8 @@ interface RuleOptions { readonly allowTestFiles: boolean; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { routeGlobs: stringArray(record.routeGlobs, DEFAULT_ROUTE_GLOBS), exclude: stringArray(record.exclude, DEFAULT_EXCLUDE), @@ -123,64 +111,24 @@ function readOptions(context: Context): RuleOptions { }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} +const ROUTE_WRAPPERS = new Set([ + 'ParenthesizedExpression', + 'TSAsExpression', + 'TSSatisfiesExpression', + 'TSNonNullExpression', + 'ChainExpression', +]); -/** See through parentheses, `as`/`satisfies` casts, `!` and optional-chain wrappers. */ function unwrap(node: ESTree.Node | null | undefined): ESTree.Node | null { - let current: ESTree.Node | null = node ?? null; - for (;;) { - if (current === null) return null; - if (current.type === 'ParenthesizedExpression') current = current.expression; - else if (current.type === 'TSAsExpression') current = current.expression; - else if (current.type === 'TSSatisfiesExpression') current = current.expression; - else if (current.type === 'TSNonNullExpression') current = current.expression; - else if (current.type === 'ChainExpression') current = current.expression; - else return current; - } + return unwrapAst(node, { wrappers: ROUTE_WRAPPERS }); } -/** Non-computed `.searchParams`, or computed `["searchParams"]`. */ function memberName(node: ESTree.MemberExpression): string | null { - if (!node.computed) return node.property.type === 'Identifier' ? node.property.name : null; - const property = unwrap(node.property); - if (property !== null && property.type === 'Literal' && typeof property.value === 'string') - return property.value; - return null; + return astMemberName(node, { templates: false, unwrap: { wrappers: ROUTE_WRAPPERS } }); } -/** The static name of an object/pattern key: `key`, `"key"` — never a computed one. */ function propertyKeyName(property: Extract): string | null { - if (property.computed) return null; - const key = property.key; - if (key.type === 'Identifier') return key.name; - if (key.type === 'Literal' && typeof key.value === 'string') return key.value; - return null; -} - -function importedName(specifier: ESTree.ImportSpecifier): string { - return specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; -} - -function lookupVariable( - context: Context, - identifier: Extract, -): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(identifier); - while (scope !== null) { - const variable = scope.set.get(identifier.name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; + return property.computed ? null : keyName(property.key, false, { templates: false }); } /** @@ -279,13 +227,7 @@ function isUrlExpression(context: Context, node: ESTree.Node | null, depth = 0): if (target === null || depth >= MAX_ALIAS_DEPTH) return false; if (target.type === 'NewExpression') return globalConstructorName(context, target) === URL_CONSTRUCTOR; - if (target.type === 'CallExpression') { - const callee = unwrap(target.callee); - if (callee === null || callee.type !== 'MemberExpression') return false; - const name = memberName(callee); - if (name === null || !URL_STATIC_FACTORIES.has(name)) return false; - return globalName(context, callee.object) === URL_CONSTRUCTOR; - } + if (target.type === 'CallExpression') return isUrlFactoryCall(context, target); if (target.type === 'ConditionalExpression') { return ( isUrlExpression(context, target.consequent, depth + 1) || @@ -301,6 +243,14 @@ function isUrlExpression(context: Context, node: ESTree.Node | null, depth = 0): return false; } +function isUrlFactoryCall(context: Context, target: ESTree.CallExpression): boolean { + const callee = unwrap(target.callee); + if (callee === null || callee.type !== 'MemberExpression') return false; + const name = memberName(callee); + if (name === null || !URL_STATIC_FACTORIES.has(name)) return false; + return globalName(context, callee.object) === URL_CONSTRUCTOR; +} + /** `true` when the binding was ever written with a URL expression, directly or through an alias. */ function isUrlBinding( context: Context, @@ -358,43 +308,63 @@ function isFalseValue(context: Context, node: ESTree.Node | null, depth = 0): bo * `{ strict: false }` as the hook's first argument — as a literal, through a binding * (`const untyped = { strict: false } as const`) or spread in from one. */ +function spreadMaySetStrict(entry: ESTree.ObjectExpression['properties'][number]): boolean { + return ( + entry.type === 'SpreadElement' || + (entry.type === 'Property' && propertyKeyName(entry) === 'strict') + ); +} + +function nextStrictValue( + context: Context, + property: ESTree.ObjectExpression['properties'][number], + value: boolean | undefined, + depth: number, +): boolean | undefined { + if (property.type === 'SpreadElement') { + // Unknown later spreads may overwrite strict; never infer false through them. + const spread = resolveObject(context, property.argument, depth + 1); + if (spread === null) return undefined; + return spread.properties.some(spreadMaySetStrict) + ? strictValue(context, spread, depth + 1) + : value; + } + if (property.type !== 'Property') return value; + if (property.computed) return undefined; + if (propertyKeyName(property) !== 'strict') return value; + return isFalseValue(context, property.value) ? false : undefined; +} + function strictValue(context: Context, node: ESTree.Node | null, depth = 0): boolean | undefined { const object = resolveObject(context, node, depth); if (object === null || depth >= MAX_ALIAS_DEPTH) return undefined; let value: boolean | undefined; - for (const property of object.properties) { - if (property.type === 'SpreadElement') { - // Unknown later spreads may overwrite strict; never infer false through them. - const spread = resolveObject(context, property.argument, depth + 1); - if (spread === null) value = undefined; - else if ( - spread.properties.some( - (entry) => - entry.type === 'SpreadElement' || - (entry.type === 'Property' && propertyKeyName(entry) === 'strict'), - ) - ) { - value = strictValue(context, spread, depth + 1); - } - } else if (property.type === 'Property') { - if (property.computed) value = undefined; - else if (propertyKeyName(property) === 'strict') { - value = isFalseValue(context, property.value) ? false : undefined; - } - } - } + for (const property of object.properties) + value = nextStrictValue(context, property, value, depth); return value; } -/** A mutation or serialization is output construction, not an input read. */ -function isOutputUse(context: Context, node: ESTree.Node, seen: Set = new Set()): boolean { +function outputUseNode(node: ESTree.Node): ESTree.Node { let current = node; - while ( - current.parent != null && - unwrap(current.parent)?.start === node.start && - unwrap(current.parent)?.end === node.end - ) + while (current.parent != null) { + const inner = unwrap(current.parent); + if (inner === null || inner.start !== node.start || inner.end !== node.end) break; current = current.parent; + } + return current; +} + +function isOutputMethodCall(parent: ESTree.Node | null | undefined, current: ESTree.Node): boolean { + if (parent?.type !== 'MemberExpression' || parent.object.start !== current.start) return false; + const method = memberName(parent); + if (method === null || !OUTPUT_METHODS.has(method)) return false; + const call = parent.parent; + return call?.type === 'CallExpression' && call.callee.start === parent.start; +} + +/** A mutation or serialization is output construction, not an input read. */ +function isOutputUse(context: Context, node: ESTree.Node, seen: Set = new Set()): boolean { + const current = outputUseNode(node); const parent = current.parent; if ( parent?.type === 'VariableDeclarator' && @@ -403,11 +373,7 @@ function isOutputUse(context: Context, node: ESTree.Node, seen: Set = ne ) { return isOutputBinding(context, parent.id, seen); } - if (parent?.type !== 'MemberExpression' || parent.object.start !== current.start) return false; - const method = memberName(parent); - if (method === null || !OUTPUT_METHODS.has(method)) return false; - const call = parent.parent; - return call?.type === 'CallExpression' && call.callee.start === parent.start; + return isOutputMethodCall(parent, current); } function isOutputBinding( @@ -430,6 +396,23 @@ interface HookBindings { readonly moduleObjects: ReadonlySet; } +function collectHookSpecifiers( + statement: ESTree.ImportDeclaration, + hooks: readonly string[], + locals: Map, + moduleObjects: Set, +): void { + for (const specifier of statement.specifiers) { + if (specifier.type !== 'ImportSpecifier') { + moduleObjects.add(specifier.local.name); + continue; + } + if (specifier.importKind === 'type') continue; + const imported = importedName(specifier); + if (hooks.includes(imported)) locals.set(specifier.local.name, imported); + } +} + function collectHookBindings( program: ESTree.Program, hooks: readonly string[], @@ -440,28 +423,11 @@ function collectHookBindings( for (const statement of program.body) { if (statement.type !== 'ImportDeclaration') continue; if (statement.importKind === 'type' || !matchesGlobs(statement.source.value, modules)) continue; - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportSpecifier') { - if (specifier.importKind === 'type') continue; - const imported = importedName(specifier); - if (hooks.includes(imported)) locals.set(specifier.local.name, imported); - } else moduleObjects.add(specifier.local.name); - } + collectHookSpecifiers(statement, hooks, locals, moduleObjects); } return { locals, moduleObjects }; } -/** `true` when the identifier still resolves to the `import` binding (not a local shadow). */ -function resolvesToImport( - context: Context, - identifier: Extract, -): boolean { - const variable = lookupVariable(context, identifier); - if (variable === null) return true; - if (variable.defs.length === 0) return true; - return variable.defs.some((definition) => definition.type === 'ImportBinding'); -} - /** The hook name a call expression targets, or `null`. Handles aliases and `Router.useParams(...)`. */ function hookCallName( context: Context, @@ -477,6 +443,15 @@ function hookCallName( return resolvesToImport(context, callee) ? imported : null; } if (callee.type !== 'MemberExpression') return null; + return moduleHookName(context, callee, bindings, hooks); +} + +function moduleHookName( + context: Context, + callee: ESTree.MemberExpression, + bindings: HookBindings, + hooks: readonly string[], +): string | null { const name = memberName(callee); if (name === null || !hooks.includes(name)) return null; const object = unwrap(callee.object); diff --git a/app/tools/oxlint/effect-native/rules/no-manual-tag-comparison.ts b/app/tools/oxlint/effect-native/rules/no-manual-tag-comparison.ts index 5742b2dad..6d801125e 100644 --- a/app/tools/oxlint/effect-native/rules/no-manual-tag-comparison.ts +++ b/app/tools/oxlint/effect-native/rules/no-manual-tag-comparison.ts @@ -95,14 +95,9 @@ import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; import { collectEffectBindings } from '../shared/effect-imports.ts'; import type { EffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; - -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production `include` defaults instead of - * forcing the fixture config to pass loosened options (which `run-on-repo.mts` reuses verbatim). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; +import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; +import { optionRecord, stringArray } from '../shared/options.ts'; +import { unwrapNode, templateText, asNamedMember } from '../shared/ast.ts'; const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; @@ -190,18 +185,8 @@ interface RuleOptions { readonly reexportModules: readonly string[]; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { include: stringArray(record.include, DEFAULT_INCLUDE), ignore: stringArray(record.ignore, DEFAULT_IGNORE), @@ -216,45 +201,9 @@ function readOptions(context: Context): RuleOptions { }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -/** Strip the wrappers that never change what an expression denotes. */ +/** Preserve this rule's bounded transparent-wrapper traversal. */ function unwrap(node: ESTree.Node): ESTree.Node { - let current: ESTree.Node = node; - for (let depth = 0; depth < MAX_DEPTH; depth += 1) { - if (current.type === 'ChainExpression') { - current = current.expression; - continue; - } - if (current.type === 'TSNonNullExpression' || current.type === 'ParenthesizedExpression') { - current = current.expression; - continue; - } - if ( - current.type === 'TSAsExpression' || - current.type === 'TSSatisfiesExpression' || - current.type === 'TSInstantiationExpression' || - current.type === 'TSTypeAssertion' - ) { - current = current.expression; - continue; - } - return current; - } - return current; -} - -function templateText(node: ESTree.TemplateLiteral): string | null { - const quasi = node.quasis[0]; - if (node.quasis.length !== 1 || quasi === undefined) return null; - return quasi.value.cooked ?? quasi.value.raw; + return unwrapNode(node, { maxDepth: MAX_DEPTH }); } /** A statically known string operand (`'X'`, `"X"`, `` `X` ``), or null. */ @@ -282,6 +231,10 @@ function resolveVariable(context: Context, name: string, from: ESTree.Node): Var return null; } +function isNamedIdentifier(node: ESTree.Node, name: string): boolean { + return node.type === 'Identifier' && node.name === name; +} + /** Single-assignment `const NAME = ` initialiser for an identifier reference, or null. */ function constInitialiser(context: Context, node: ESTree.Node): ESTree.Node | null { const expression = unwrap(node); @@ -293,7 +246,7 @@ function constInitialiser(context: Context, node: ESTree.Node): ESTree.Node | nu if (def === undefined || def.type !== 'Variable') return null; const declarator = def.node as ESTree.Node; if (declarator.type !== 'VariableDeclarator') return null; - if (declarator.id.type !== 'Identifier' || declarator.id.name !== expression.name) return null; + if (!isNamedIdentifier(declarator.id, expression.name)) return null; return declarator.init ?? null; } @@ -316,14 +269,9 @@ function staticString(context: Context, node: ESTree.Node): string | null { /** The `_tag` member access itself (`x._tag`, `x?._tag`, `x!._tag`, `x["_tag"]`, `x[KEY]`), or null. */ function asTagMember(context: Context, node: ESTree.Node): ESTree.MemberExpression | null { - const expression = unwrap(node); - if (expression.type !== 'MemberExpression') return null; - const property = expression.property; - if (!expression.computed) { - return property.type === 'Identifier' && property.name === TAG_PROPERTY ? expression : null; - } - if (property.type === 'PrivateIdentifier') return null; - return staticString(context, property) === TAG_PROPERTY ? expression : null; + return asNamedMember(node, TAG_PROPERTY, (key) => staticString(context, key), { + maxDepth: MAX_DEPTH, + }); } /** Non-computed `.x` or computed `["x"]` property name of a member expression. */ @@ -372,10 +320,12 @@ function combinatorName( return null; const namespace = bindings.namespaces.get(object.name); if (namespace === undefined) return null; - if (namespace === 'Effect' && ERROR_COMBINATORS.has(member)) return `${namespace}.${member}`; - if (namespace === 'Schedule' && SCHEDULE_COMBINATORS.has(member)) return `${namespace}.${member}`; - if (namespace === 'Match' && MATCH_COMBINATORS.has(member)) return `${namespace}.${member}`; - return null; + const members = new Map([ + ['Effect', ERROR_COMBINATORS], + ['Schedule', SCHEDULE_COMBINATORS], + ['Match', MATCH_COMBINATORS], + ]).get(namespace); + return members?.has(member) ? `${namespace}.${member}` : null; } const FUNCTION_TYPES = new Set([ @@ -437,25 +387,10 @@ function patternBindsTag( ): boolean { if (pattern === null || pattern === undefined || depth > MAX_DEPTH) return false; switch (pattern.type) { - case 'ObjectPattern': { - for (const property of pattern.properties) { - if (property.type === 'RestElement') continue; - const value = property.value as ESTree.Node; - if (isTagKey(property as ESTree.Node & { key?: ESTree.Node; computed?: boolean })) { - if (bindsName(value, name, depth + 1)) return true; - continue; - } - // A nested pattern may still reach `_tag` one level down: `{ reason: { _tag } }`. - if (patternBindsTag(value, name, depth + 1)) return true; - } - return false; - } - case 'ArrayPattern': { - for (const element of pattern.elements) { - if (patternBindsTag(element as ESTree.Node | null, name, depth + 1)) return true; - } - return false; - } + case 'ObjectPattern': + return pattern.properties.some((property) => propertyBindsTag(property, name, depth)); + case 'ArrayPattern': + return pattern.elements.some((element) => patternBindsTag(element, name, depth + 1)); case 'AssignmentPattern': return patternBindsTag(pattern.left as ESTree.Node, name, depth + 1); case 'RestElement': @@ -465,6 +400,18 @@ function patternBindsTag( } } +function propertyBindsTag(property: ESTree.Node, name: string, depth: number): boolean { + if (property.type === 'RestElement') return false; + const entry = property as ESTree.Node & { + key?: ESTree.Node; + computed?: boolean; + value: ESTree.Node; + }; + return isTagKey(entry) + ? bindsName(entry.value, name, depth + 1) + : patternBindsTag(entry.value, name, depth + 1); +} + /** `true` when a (possibly defaulted) binding target is exactly the identifier `name`. */ function bindsName(target: ESTree.Node | null | undefined, name: string, depth = 0): boolean { if (target === null || target === undefined || depth > MAX_DEPTH) return false; @@ -500,26 +447,29 @@ function tagAliasOrigin(context: Context, node: ESTree.Node): ESTree.Node | null for (const def of variable.defs) { const declaration = def.node as ESTree.Node | undefined; if (declaration === undefined) continue; - if (declaration.type === 'VariableDeclarator') { - const id = declaration.id as ESTree.Node; - const init = (declaration.init ?? null) as ESTree.Node | null; - // `const tag = error._tag` - if (id.type === 'Identifier' && id.name === expression.name && init !== null) { - const member = asTagMember(context, init); - if (member !== null) return member.object as ESTree.Node; - continue; - } - // `const { _tag } = error`, `const { _tag: classification } = error`, `for (const { _tag } of …)` - if (patternBindsTag(id, expression.name)) return init; - continue; - } - for (const pattern of definitionPatterns(declaration)) { - if (patternBindsTag(pattern, expression.name)) return null; - } + const origin = declarationTagOrigin(context, declaration, expression.name); + if (origin !== undefined) return origin; } return undefined; } +function declarationTagOrigin( + context: Context, + declaration: ESTree.Node, + name: string, +): ESTree.Node | null | undefined { + if (declaration.type !== 'VariableDeclarator') { + return definitionPatterns(declaration).some((pattern) => patternBindsTag(pattern, name)) + ? null + : undefined; + } + const init = declaration.init ?? null; + if (isNamedIdentifier(declaration.id, name) && init !== null) { + return asTagMember(context, init)?.object ?? undefined; + } + return patternBindsTag(declaration.id, name) ? init : undefined; +} + /** A resolved tag read: the node whose source text names it, for the diagnostic message. */ interface TagReference { readonly origin: ESTree.Node | null; @@ -550,29 +500,34 @@ function tagReference( return { origin, fallback: expression.name }; } - if (expression.type === 'CallExpression') { - const callee = unwrap(expression.callee); - // `String(error._tag)` — laundering the tag through a wrapper leaves it a tag. - if (callee.type === 'Identifier' && callee.name === 'String') { - const variable = resolveVariable(context, callee.name, callee); - if (variable !== null && variable.defs.length > 0) return null; - const argument = expression.arguments[0] as ESTree.Node | undefined; - if (argument !== undefined && argument.type !== 'SpreadElement') { - return tagReference(context, argument, options, depth + 1); - } - return null; - } - // `error._tag.slice(0, 8)` — string surgery on the tag is still the tag. - if (callee.type === 'MemberExpression') { - const method = memberPropertyName(callee); - if (method !== null && STRING_TRANSFORMS.has(method)) { - return tagReference(context, callee.object as ESTree.Node, options, depth + 1); - } - } - return null; + return expression.type === 'CallExpression' + ? callTagReference(context, expression, options, depth) + : null; +} + +function callTagReference( + context: Context, + expression: ESTree.CallExpression, + options: RuleOptions, + depth: number, +): TagReference | null { + const callee = unwrap(expression.callee); + if (isNamedIdentifier(callee, 'String')) { + const variable = resolveVariable(context, 'String', callee); + if (variable !== null && variable.defs.length > 0) return null; + const argument = firstArgument(expression); + return argument === null ? null : tagReference(context, argument, options, depth + 1); } + if (callee.type !== 'MemberExpression') return null; + const method = memberPropertyName(callee); + return method !== null && STRING_TRANSFORMS.has(method) + ? tagReference(context, callee.object, options, depth + 1) + : null; +} - return null; +function firstArgument(node: ESTree.CallExpression): ESTree.Node | null { + const argument = node.arguments[0]; + return argument === undefined || argument.type === 'SpreadElement' ? null : argument; } /** The text used to name the compared value in the diagnostic. */ @@ -612,6 +567,13 @@ function isRegexReceiver(context: Context, node: ESTree.Node, depth = 0): boolea return initialiser === null ? false : isRegexReceiver(context, initialiser, depth + 1); } +function containerElements(expression: ESTree.Node): ESTree.ArrayExpression['elements'] | null { + if (expression.type === 'ArrayExpression') return expression.elements; + if (expression.type !== 'NewExpression' || expression.arguments.length !== 1) return null; + const first = unwrap(expression.arguments[0] as ESTree.Node); + return first.type === 'ArrayExpression' ? first.elements : null; +} + const REPLACEMENTS = "`Match.value(x).pipe(Match.tag('Tag', onTag), Match.exhaustive)`, `Schema.is(TaggedError)(x)`, or " + '`Effect.catchTag`/`Effect.catchTags` on the error channel'; @@ -710,15 +672,7 @@ export const rule = defineRule({ /** `[…]`/`new Set([…])` of nothing but Effect's own ADT tags — the sibling rule's territory. */ const containerIsAdtOnly = (node: ESTree.Node): boolean => { const expression = unwrap(node); - const elements = - expression.type === 'ArrayExpression' - ? expression.elements - : expression.type === 'NewExpression' && expression.arguments.length === 1 - ? (() => { - const first = unwrap(expression.arguments[0] as ESTree.Node); - return first.type === 'ArrayExpression' ? first.elements : null; - })() - : null; + const elements = containerElements(expression); if (elements === null || elements.length === 0) return false; let sawTag = false; for (const element of elements) { @@ -730,178 +684,195 @@ export const rule = defineRule({ return sawTag; }; - return { - BinaryExpression(node) { - if ((node.left as ESTree.Node).type === 'PrivateIdentifier') return; - - if (node.operator === 'in') { - // `'_tag' in value` — a hand-rolled shape test for the discriminant. - if (staticString(context, node.left) === TAG_PROPERTY) { - if (suppressed(node)) return; - context.report({ - node, - messageId: 'tagPresenceCheck', - data: { text: describe(context, node.right) }, - }); - return; - } - // `error._tag in HANDLERS` — membership against a hand-maintained dispatch map. - if (!options.includeMembershipProbes) return; - const reference = tagOf(node.left); - if (reference === null) return; - if (suppressed(node)) return; - context.report({ - node, - messageId: 'tagMembershipProbe', - data: { - text: referenceText(context, reference), - probe: `\`in ${describe(context, node.right)}\``, - }, - }); - return; - } + function checkIn(node: ESTree.BinaryExpression) { + // `'_tag' in value` — a hand-rolled shape test for the discriminant. + if (staticString(context, node.left) === TAG_PROPERTY) { + if (suppressed(node)) return; + context.report({ + node, + messageId: 'tagPresenceCheck', + data: { text: describe(context, node.right) }, + }); + return; + } + // `error._tag in HANDLERS` — membership against a hand-maintained dispatch map. + if (!options.includeMembershipProbes) return; + const reference = tagOf(node.left); + if (reference === null) return; + if (suppressed(node)) return; + context.report({ + node, + messageId: 'tagMembershipProbe', + data: { + text: referenceText(context, reference), + probe: `\`in ${describe(context, node.right)}\``, + }, + }); + return; + } + function equalityOperands(first: ESTree.Node, second: ESTree.Node) { + const left = tagOf(first); + const reference = left ?? tagOf(second); + const other = left === null ? first : second; + if (reference === null || tagOf(other) !== null) return null; + const tag = asStringLiteral(other); + if (tag !== null && exempt.has(tag)) return null; + return { reference, other, tag }; + } + function checkBinary(node: ESTree.BinaryExpression) { + if ((node.left as ESTree.Node).type === 'PrivateIdentifier') return; - if (!EQUALITY_OPERATORS.has(node.operator)) return; + if (node.operator === 'in') return checkIn(node); - let reference = tagOf(node.left); - let other: ESTree.Node = node.right; - if (reference === null) { - reference = tagOf(node.right); - other = node.left; - } - if (reference === null) return; - // `a._tag === b._tag` is an identity test, not a case analysis over a closed vocabulary. - if (tagOf(other) !== null) return; + if (!EQUALITY_OPERATORS.has(node.operator)) return; - const tag = asStringLiteral(other); - // Effect's own ADT tags belong to `no-raw-effect-adt-tag-check`; `allowTags` is the escape hatch. - if (tag !== null && exempt.has(tag)) return; - if (suppressed(node)) return; + const operands = equalityOperands(node.left, node.right); + if (operands === null) return; + const { reference, other, tag } = operands; + if (suppressed(node)) return; - const text = referenceText(context, reference); - if (tag === null) { + const text = referenceText(context, reference); + if (tag === null) { + context.report({ + node, + messageId: 'tagEqualityDynamic', + data: { text, operator: node.operator, other: describe(context, other) }, + }); + return; + } + context.report({ + node, + messageId: 'tagEquality', + data: { text, operator: node.operator, tag }, + }); + } + function checkShape(node: ESTree.CallExpression): boolean { + // `Object.hasOwn(error, '_tag')` / `Reflect.has(error, '_tag')` — `'_tag' in error` by another name. + const shapeProbe = + globalNamespaceCall(context, node, 'Object', 'hasOwn') || + globalNamespaceCall(context, node, 'Reflect', 'has'); + if (shapeProbe && node.arguments.length >= 2) { + const target = node.arguments[0] as ESTree.Node; + const key = node.arguments[1] as ESTree.Node; + if (target.type !== 'SpreadElement' && staticString(context, key) === TAG_PROPERTY) { + if (suppressed(node)) return true; context.report({ node, - messageId: 'tagEqualityDynamic', - data: { text, operator: node.operator, other: describe(context, other) }, + messageId: 'tagPresenceCheck', + data: { text: describe(context, target) }, }); - return; } + return true; + } + + return false; + } + function checkEqualityCall(node: ESTree.CallExpression): boolean { + // `Object.is(error._tag, 'X')` — equality without an equality operator. + if (globalNamespaceCall(context, node, 'Object', 'is') && node.arguments.length === 2) { + const first = node.arguments[0] as ESTree.Node; + const second = node.arguments[1] as ESTree.Node; + if (first.type === 'SpreadElement' || second.type === 'SpreadElement') return true; + const operands = equalityOperands(first, second); + if (operands === null) return true; + const { reference } = operands; + if (suppressed(node)) return true; context.report({ node, - messageId: 'tagEquality', - data: { text, operator: node.operator, tag }, + messageId: 'tagEqualityCall', + data: { + callee: describe(context, node.callee as ESTree.Node), + text: referenceText(context, reference), + }, }); - }, - - CallExpression(node) { - // `Object.hasOwn(error, '_tag')` / `Reflect.has(error, '_tag')` — `'_tag' in error` by another name. - const shapeProbe = - globalNamespaceCall(context, node, 'Object', 'hasOwn') || - globalNamespaceCall(context, node, 'Reflect', 'has'); - if (shapeProbe && node.arguments.length >= 2) { - const target = node.arguments[0] as ESTree.Node; - const key = node.arguments[1] as ESTree.Node; - if (target.type !== 'SpreadElement' && staticString(context, key) === TAG_PROPERTY) { - if (suppressed(node)) return; - context.report({ - node, - messageId: 'tagPresenceCheck', - data: { text: describe(context, target) }, - }); - } - return; - } + return true; + } - // `Object.is(error._tag, 'X')` — equality without an equality operator. - if (globalNamespaceCall(context, node, 'Object', 'is') && node.arguments.length === 2) { - const first = node.arguments[0] as ESTree.Node; - const second = node.arguments[1] as ESTree.Node; - if (first.type === 'SpreadElement' || second.type === 'SpreadElement') return; - let reference = tagOf(first); - let other: ESTree.Node = second; - if (reference === null) { - reference = tagOf(second); - other = first; - } - if (reference === null) return; - if (tagOf(other) !== null) return; - const literal = asStringLiteral(other); - if (literal !== null && exempt.has(literal)) return; - if (suppressed(node)) return; + return false; + } + function checkStringProbe( + node: ESTree.CallExpression, + receiver: ESTree.Node, + method: string, + ): boolean { + // `error._tag.startsWith('Contacts')`, `String(error._tag).endsWith('Problem')`. + if (STRING_PROBES.has(method)) { + const reference = tagOf(receiver); + if (reference !== null) { + if (suppressed(node)) return true; context.report({ node, - messageId: 'tagEqualityCall', - data: { - callee: describe(context, node.callee as ESTree.Node), - text: referenceText(context, reference), - }, + messageId: 'tagStringProbe', + data: { text: referenceText(context, reference), method: `.${method}(…)` }, }); - return; + return true; } + } - const callee = unwrap(node.callee); - if (callee.type !== 'MemberExpression') return; - const method = memberPropertyName(callee); - if (method === null) return; - const receiver = callee.object as ESTree.Node; - - // `error._tag.startsWith('Contacts')`, `String(error._tag).endsWith('Problem')`. - if (STRING_PROBES.has(method)) { - const reference = tagOf(receiver); + return false; + } + function checkRegexProbe( + node: ESTree.CallExpression, + receiver: ESTree.Node, + method: string, + ): boolean { + // `/^Contacts/u.test(error._tag)` — the mirrored spelling of the same naming-convention probe. + if (REGEX_PROBES.has(method) && isRegexReceiver(context, receiver)) { + const argument = node.arguments[0] as ESTree.Node | undefined; + if (argument !== undefined && argument.type !== 'SpreadElement') { + const reference = tagOf(argument); if (reference !== null) { - if (suppressed(node)) return; + if (suppressed(node)) return true; context.report({ node, messageId: 'tagStringProbe', - data: { text: referenceText(context, reference), method: `.${method}(…)` }, + data: { + text: referenceText(context, reference), + method: `${describe(context, receiver)}.${method}(…)`, + }, }); - return; + return true; } } + } - // `/^Contacts/u.test(error._tag)` — the mirrored spelling of the same naming-convention probe. - if (REGEX_PROBES.has(method) && isRegexReceiver(context, receiver)) { - const argument = node.arguments[0] as ESTree.Node | undefined; - if (argument !== undefined && argument.type !== 'SpreadElement') { - const reference = tagOf(argument); - if (reference !== null) { - if (suppressed(node)) return; - context.report({ - node, - messageId: 'tagStringProbe', - data: { - text: referenceText(context, reference), - method: `${describe(context, receiver)}.${method}(…)`, - }, - }); - return; - } - } - } + return false; + } + function checkMembership(node: ESTree.CallExpression, receiver: ESTree.Node, method: string) { + // `KNOWN_TAGS.includes(error._tag)`, `TAG_SET.has(error._tag)`. + if (options.includeMembershipProbes && MEMBERSHIP_METHODS.has(method)) { + const argument = firstArgument(node); + if (argument === null) return; + const reference = tagOf(argument); + if (reference === null) return; + // A receiver that is itself the tag is the string probe above, already handled. + if (tagOf(receiver) !== null) return; + if (containerIsAdtOnly(receiver)) return; + const initialiser = constInitialiser(context, receiver); + if (initialiser !== null && containerIsAdtOnly(initialiser)) return; + if (suppressed(node)) return; + context.report({ + node, + messageId: 'tagMembershipProbe', + data: { + text: referenceText(context, reference), + probe: `\`${describe(context, receiver)}.${method}(…)\``, + }, + }); + } + } + function checkCall(node: ESTree.CallExpression) { + if (checkShape(node) || checkEqualityCall(node)) return; + const callee = unwrap(node.callee); + if (callee.type !== 'MemberExpression') return; + const method = memberPropertyName(callee); + if (method === null) return; + const receiver = callee.object as ESTree.Node; - // `KNOWN_TAGS.includes(error._tag)`, `TAG_SET.has(error._tag)`. - if (options.includeMembershipProbes && MEMBERSHIP_METHODS.has(method)) { - const argument = node.arguments[0] as ESTree.Node | undefined; - if (argument === undefined || argument.type === 'SpreadElement') return; - const reference = tagOf(argument); - if (reference === null) return; - // A receiver that is itself the tag is the string probe above, already handled. - if (tagOf(receiver) !== null) return; - if (containerIsAdtOnly(receiver)) return; - const initialiser = constInitialiser(context, receiver); - if (initialiser !== null && containerIsAdtOnly(initialiser)) return; - if (suppressed(node)) return; - context.report({ - node, - messageId: 'tagMembershipProbe', - data: { - text: referenceText(context, reference), - probe: `\`${describe(context, receiver)}.${method}(…)\``, - }, - }); - } - }, - }; + if (checkStringProbe(node, receiver, method)) return; + if (checkRegexProbe(node, receiver, method)) return; + checkMembership(node, receiver, method); + } + return { BinaryExpression: checkBinary, CallExpression: checkCall }; }, }); diff --git a/app/tools/oxlint/effect-native/rules/no-native-error-construction.ts b/app/tools/oxlint/effect-native/rules/no-native-error-construction.ts index 1b6ee89d3..51e9fd4b6 100644 --- a/app/tools/oxlint/effect-native/rules/no-native-error-construction.ts +++ b/app/tools/oxlint/effect-native/rules/no-native-error-construction.ts @@ -110,17 +110,12 @@ import { defineRule } from '@oxlint/plugins'; import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; import { bindingsFor } from '../shared/effect-imports.ts'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; +import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; +import { booleanOption as boolean, stringList } from '../shared/options.ts'; +import { keyName, memberName, unwrapNode } from '../shared/ast.ts'; type AnyNode = ESTree.Node; -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the production defaults instead of forcing the fixture - * config to loosen options (`run-on-repo.mts` reuses that same config against the real repository). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; - /** Globals through which the ambient error constructors can be reached as a property. */ const CONTAINER_GLOBALS = new Set(['globalThis', 'global', 'window', 'self', 'frames']); @@ -174,16 +169,6 @@ const DEFAULTS: RuleOptions = { requireEffectImport: false, }; -function stringList(value: unknown, fallback: readonly string[]): readonly string[] { - return Array.isArray(value) && value.every((entry) => typeof entry === 'string') - ? (value as readonly string[]) - : fallback; -} - -function boolean(value: unknown, fallback: boolean): boolean { - return typeof value === 'boolean' ? value : fallback; -} - function readOptions(raw: unknown): RuleOptions { const given = (raw ?? {}) as Partial>; const include = stringList(given.include, DEFAULTS.include); @@ -198,47 +183,12 @@ function readOptions(raw: unknown): RuleOptions { }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -/** Wrappers that do not change which value an expression evaluates to. */ -const TRANSPARENT_WRAPPERS = new Set([ - 'ParenthesizedExpression', - 'ChainExpression', - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSInstantiationExpression', - 'TSTypeAssertion', -]); - function unwrap(node: AnyNode): AnyNode { - let current = node; - for (let depth = 0; depth < 8; depth += 1) { - if (!TRANSPARENT_WRAPPERS.has(current.type)) return current; - const inner = (current as { expression?: AnyNode }).expression; - if (inner === undefined) return current; - current = inner; - } - return current; + return unwrapNode(node, { maxDepth: 8 }); } -/** `Error.captureStackTrace` / `Error["captureStackTrace"]` → the string; a dynamic key → `null`. */ function staticPropertyName(node: ESTree.MemberExpression): string | null { - const property = node.property as AnyNode; - if (!node.computed) - return property.type === 'Identifier' ? (property as ESTree.IdentifierName).name : null; - if (property.type === 'TemplateLiteral' && property.expressions.length === 0) - return property.quasis[0]?.value.cooked ?? null; - if (property.type !== 'Literal') return null; - const value = (property as { value?: unknown }).value; - return typeof value === 'string' ? value : null; + return memberName(node, { templates: true }); } function resolveVariable(context: Context, name: string, from: AnyNode): Variable | null { @@ -360,65 +310,70 @@ export const rule = defineRule({ * Accepts the bare unshadowed global and the same global reached through a container global * (`globalThis.Error`, `window["TypeError"]`), through parens, `as` casts and optional chains. */ - const nativeErrorName = (node: AnyNode, depth = 0): string | null => { - if (depth > 24) return null; - const inner = unwrap(node); - if (inner.type === 'Identifier') { - const name = (inner as ESTree.IdentifierReference).name; - if (constructors.has(name) && isUnshadowedGlobal(context, inner, name)) return name; - const variable = resolveVariable(context, name, inner); - if ( - !variable || - variable.defs.length !== 1 || - variable.references.some((r) => r.isWrite() && !r.init) - ) - return null; - const declaration = variable.defs[0]?.node; - if ( - declaration?.type !== 'VariableDeclarator' || - !declaration.init || - declaration.parent?.type !== 'VariableDeclaration' || - declaration.parent.kind !== 'const' - ) - return null; - if (declaration.id.type === 'Identifier') - return nativeErrorName(declaration.init, depth + 1); - const container = unwrap(declaration.init); + const isContainer = (node: AnyNode): boolean => + node.type === 'Identifier' && + CONTAINER_GLOBALS.has(node.name) && + isUnshadowedGlobal(context, node, node.name); + + const immutableDeclaration = (identifier: Extract) => { + const variable = resolveVariable(context, identifier.name, identifier); + if ( + !variable || + variable.defs.length !== 1 || + variable.references.some((reference) => reference.isWrite() && !reference.init) + ) + return null; + const declaration = variable.defs[0]!.node; + if ( + declaration?.type !== 'VariableDeclarator' || + !declaration.init || + declaration.parent?.type !== 'VariableDeclaration' || + declaration.parent.kind !== 'const' + ) + return null; + return declaration; + }; + + const destructuredErrorName = (pattern: ESTree.ObjectPattern, name: string): string | null => { + for (const property of pattern.properties) { if ( - container.type !== 'Identifier' || - !CONTAINER_GLOBALS.has(container.name) || - !isUnshadowedGlobal(context, container, container.name) || - declaration.id.type !== 'ObjectPattern' + property.type !== 'Property' || + property.value.type !== 'Identifier' || + property.value.name !== name ) - return null; - for (const property of declaration.id.properties) { - if ( - property.type !== 'Property' || - property.value.type !== 'Identifier' || - property.value.name !== name - ) - continue; - const key = - !property.computed && property.key.type === 'Identifier' - ? property.key.name - : property.key.type === 'Literal' - ? property.key.value - : property.key.type === 'TemplateLiteral' && property.key.expressions.length === 0 - ? property.key.quasis[0]?.value.cooked - : null; - if (typeof key === 'string' && constructors.has(key)) return key; - } - return null; + continue; + const key = keyName(property.key, property.computed, { templates: true }); + if (key !== null && constructors.has(key)) return key; } - if (inner.type !== 'MemberExpression') return null; - const member = inner as ESTree.MemberExpression; + return null; + }; + + const identifierErrorName = ( + identifier: Extract, + depth: number, + ): string | null => { + const name = identifier.name; + if (constructors.has(name) && isUnshadowedGlobal(context, identifier, name)) return name; + const declaration = immutableDeclaration(identifier); + if (!declaration?.init) return null; + if (declaration.id.type === 'Identifier') return nativeErrorName(declaration.init, depth + 1); + if (declaration.id.type !== 'ObjectPattern' || !isContainer(unwrap(declaration.init))) + return null; + return destructuredErrorName(declaration.id, name); + }; + + const memberErrorName = (member: ESTree.MemberExpression): string | null => { const name = staticPropertyName(member); if (name === null || !constructors.has(name)) return null; - const container = unwrap(member.object as AnyNode); - if (container.type !== 'Identifier') return null; - const containerName = (container as ESTree.IdentifierReference).name; - if (!CONTAINER_GLOBALS.has(containerName)) return null; - return isUnshadowedGlobal(context, container, containerName) ? name : null; + return isContainer(unwrap(member.object)) ? name : null; + }; + + const nativeErrorName = (node: AnyNode, depth = 0): string | null => { + if (depth > 24) return null; + const inner = unwrap(node); + if (inner.type === 'Identifier') return identifierErrorName(inner, depth); + if (inner.type === 'MemberExpression') return memberErrorName(inner); + return null; }; return { diff --git a/app/tools/oxlint/effect-native/rules/no-native-json-parse.ts b/app/tools/oxlint/effect-native/rules/no-native-json-parse.ts index 8cb97cff0..0d2f2a633 100644 --- a/app/tools/oxlint/effect-native/rules/no-native-json-parse.ts +++ b/app/tools/oxlint/effect-native/rules/no-native-json-parse.ts @@ -70,28 +70,22 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope } from '@oxlint/plugins'; - -import { isTestFile, matchesAny, normalisePath } from '../shared/paths.ts'; +import type { ESTree } from '@oxlint/plugins'; + +import { + EXPRESSION_WRAPPERS, + keyName, + memberName, + parentOf, + skipWrappers, + unwrapNode, +} from '../shared/ast.ts'; +import { isJsonHost, jsonExpressionSnippet } from '../shared/json-globals.ts'; +import { booleanOption, stringList } from '../shared/options.ts'; +import { isTestFile, matchesAny, workspacePath } from '../shared/paths.ts'; type AnyNode = ESTree.Node; -const WORKSPACE_MARKERS: readonly string[] = ['/apps/', '/verticals/', '/packages/', '/scripts/']; - -/** - * Absolute filename → the workspace-relative path the scope globs are written against. - * - * The *last* workspace marker wins so real sources (`/apps/x/api/index.ts`) and the plugin's - * own fixtures (`tools/.../fixtures//invalid/apps/...`) classify identically; `normalisePath` - * alone would stop at the enclosing `tools/` segment. - */ -function workspacePath(filename: string): string { - const unified = filename.replaceAll('\\', '/'); - let best = -1; - for (const marker of WORKSPACE_MARKERS) best = Math.max(best, unified.lastIndexOf(marker)); - return best === -1 ? normalisePath(unified) : unified.slice(best + 1); -} - /** Globals that expose the ambient `JSON` object as a property (`globalThis.JSON.parse`). */ const CONTAINER_GLOBALS = new Set(['globalThis', 'global', 'window', 'self', 'frames']); @@ -114,104 +108,38 @@ const DEFAULTS: RuleOptions = { includePaths: DEFAULT_INCLUDE_PATHS, }; -function stringList(value: unknown, fallback: readonly string[]): readonly string[] { - return Array.isArray(value) && value.every((entry) => typeof entry === 'string') - ? (value as readonly string[]) - : fallback; -} - function readOptions(raw: unknown): RuleOptions { const given = (raw ?? {}) as Partial>; const includePaths = stringList(given.includePaths, DEFAULTS.includePaths); return { allowPaths: stringList(given.allowPaths, DEFAULTS.allowPaths), - ignoreTestFiles: - typeof given.ignoreTestFiles === 'boolean' ? given.ignoreTestFiles : DEFAULTS.ignoreTestFiles, + ignoreTestFiles: booleanOption(given.ignoreTestFiles, DEFAULTS.ignoreTestFiles), includePaths: includePaths.length > 0 ? includePaths : DEFAULTS.includePaths, }; } -/** Wrappers that do not change which value an expression evaluates to. */ -const TRANSPARENT_WRAPPERS = new Set([ - 'ParenthesizedExpression', - 'ChainExpression', - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSInstantiationExpression', - 'TSTypeAssertion', -]); +const UNWRAP_OPTIONS = { wrappers: EXPRESSION_WRAPPERS, maxDepth: 8, sequence: true }; +const STRING_OPTIONS = { + templates: true, + rawTemplates: false, + singleQuasi: false, + unwrap: UNWRAP_OPTIONS, +}; -/** Strip parentheses, `as`/`!` casts and optional-chain wrappers from an expression. */ function unwrap(node: AnyNode): AnyNode { - let current = node; - for (let depth = 0; depth < 8; depth += 1) { - if (current.type === 'SequenceExpression') { - current = current.expressions.at(-1)!; - continue; - } - if (!TRANSPARENT_WRAPPERS.has(current.type)) return current; - const inner = (current as { expression?: AnyNode }).expression; - if (inner === undefined) return current; - current = inner; - } - return current; + return unwrapNode(node, UNWRAP_OPTIONS); } -function parentOf(node: AnyNode): AnyNode | null { - return (node as { parent?: AnyNode | null }).parent ?? null; -} - -/** `JSON.parse` / `JSON["parse"]` → `"parse"`; a dynamic key → `null`. */ function staticPropertyName(node: ESTree.MemberExpression): string | null { - const property = unwrap(node.property as AnyNode); - if (!node.computed) - return property.type === 'Identifier' ? (property as ESTree.IdentifierName).name : null; - if (property.type === 'TemplateLiteral' && property.expressions.length === 0) - return property.quasis[0]?.value.cooked ?? null; - if (property.type !== 'Literal') return null; - const value = (property as { value?: unknown }).value; - return typeof value === 'string' ? value : null; -} - -/** `true` when `node` is the global `name` — not a local, parameter, class or imported binding. */ -function isUnshadowedGlobal(context: Context, node: AnyNode, name: string): boolean { - if (node.type !== 'Identifier') return false; - if ((node as ESTree.IdentifierReference).name !== name) return false; - let scope: Scope | null = context.sourceCode.getScope(node); - while (scope !== null) { - const variable = scope.set.get(name); - const valueBinding = variable?.defs.some((definition) => { - const def = definition as unknown as { - type: string; - node?: { importKind?: string }; - parent?: { importKind?: string }; - }; - return ( - def.type !== 'Type' && - !( - def.type === 'ImportBinding' && - (def.node?.importKind === 'type' || def.parent?.importKind === 'type') - ) - ); - }); - if (valueBinding) return false; - // A type-only binding does not hide an outer value binding with this name. - scope = scope.upper; - } - return true; + if (!node.computed && unwrap(node.property).type !== 'Identifier') return null; + return memberName(node, STRING_OPTIONS); } -function keyName(key: AnyNode): string | null { - key = unwrap(key); - if (key.type === 'TemplateLiteral' && key.expressions.length === 0) - return key.quasis[0]?.value.cooked ?? null; - if (key.type === 'Identifier') return (key as ESTree.IdentifierName).name; - if (key.type === 'Literal') { - const value = (key as { value?: unknown }).value; - return typeof value === 'string' ? value : null; - } - return null; +/** A single-input call whose callee is the supplied expression, through transparent wrappers. */ +function singleInputCall(node: AnyNode): ESTree.CallExpression | null { + const { node: callee, parent } = skipWrappers(node, EXPRESSION_WRAPPERS); + if (parent?.type !== 'CallExpression' || parent.callee !== callee) return null; + return parent.arguments.length === 1 ? parent : null; } /** Effect-native rule: JSON text is decoded by `Schema.fromJsonString`, never by `JSON.parse`. */ @@ -265,32 +193,32 @@ export const rule = defineRule({ if (matchesAny(path, options.allowPaths)) return {}; if (options.ignoreTestFiles && isTestFile(path)) return {}; - const printed = (node: AnyNode): string => { - const text = context.sourceCode.getText(node).replace(/\s+/gu, ' ').trim(); - return text.length > 72 ? `${text.slice(0, 69)}...` : text; - }; - const report = (node: AnyNode, messageId: string): void => { - context.report({ node, messageId, data: { expression: printed(node) } }); + context.report({ + node, + messageId, + data: { expression: jsonExpressionSnippet(context.sourceCode.getText(node)) }, + }); }; - /** `true` when this expression evaluates to the ambient `JSON` global. */ - const isJsonGlobal = (node: AnyNode): boolean => { - const inner = unwrap(node); - if (inner.type === 'Identifier') return isUnshadowedGlobal(context, inner, 'JSON'); - if (inner.type === 'MemberExpression') { - // `globalThis.JSON`, `window["JSON"]`. - const member = inner as ESTree.MemberExpression; - if (staticPropertyName(member) !== 'JSON') return false; - const container = unwrap(member.object as AnyNode); - if (container.type !== 'Identifier') return false; - const containerName = (container as ESTree.IdentifierReference).name; - return ( - CONTAINER_GLOBALS.has(containerName) && - isUnshadowedGlobal(context, container, containerName) - ); - } - return false; + const isJsonGlobal = (node: AnyNode): boolean => + isJsonHost(context, node, { + containers: CONTAINER_GLOBALS, + unwrap, + memberName: staticPropertyName, + }); + + const isRoundTrip = (node: ESTree.MemberExpression): boolean => { + const call = singleInputCall(node); + if (call === null) return false; + const input = unwrap(call.arguments[0] as AnyNode); + if (input.type !== 'CallExpression' || input.arguments.length !== 1) return false; + const encoder = unwrap(input.callee as AnyNode); + return ( + encoder.type === 'MemberExpression' && + staticPropertyName(encoder) === 'stringify' && + isJsonGlobal(encoder.object as AnyNode) + ); }; return { @@ -300,26 +228,7 @@ export const rule = defineRule({ if (!isJsonGlobal(node.object as AnyNode)) return; // A direct JSON round-trip is an in-memory copy, not an external document decode // (audit D native-object boundary). This does NOT prove it safe or equivalent to structuredClone. - let callee: AnyNode = node; - while (parentOf(callee) && TRANSPARENT_WRAPPERS.has(parentOf(callee)!.type)) - callee = parentOf(callee)!; - const call = parentOf(callee); - if ( - call?.type === 'CallExpression' && - call.callee === callee && - call.arguments.length === 1 - ) { - const input = unwrap(call.arguments[0] as AnyNode); - if (input.type === 'CallExpression' && input.arguments.length === 1) { - const encoder = unwrap(input.callee as AnyNode); - if ( - encoder.type === 'MemberExpression' && - staticPropertyName(encoder) === 'stringify' && - isJsonGlobal(encoder.object as AnyNode) - ) - return; - } - } + if (isRoundTrip(node)) return; report(node as unknown as AnyNode, 'nativeJsonParse'); }, @@ -336,7 +245,7 @@ export const rule = defineRule({ if (source === null || !isJsonGlobal(source)) return; for (const property of node.properties) { if (property.type !== 'Property') continue; - if (keyName((property as { key: AnyNode }).key) !== 'parse') continue; + if (keyName(property.key, false, STRING_OPTIONS) !== 'parse') continue; report(property as unknown as AnyNode, 'nativeJsonParseBinding'); } }, diff --git a/app/tools/oxlint/effect-native/rules/no-native-json-stringify.ts b/app/tools/oxlint/effect-native/rules/no-native-json-stringify.ts index 57b085e34..c5211a6ac 100644 --- a/app/tools/oxlint/effect-native/rules/no-native-json-stringify.ts +++ b/app/tools/oxlint/effect-native/rules/no-native-json-stringify.ts @@ -65,42 +65,25 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope } from '@oxlint/plugins'; +import type { Context, ESTree } from '@oxlint/plugins'; -import { isTestFile, matchesAny, normalisePath } from '../shared/paths.ts'; +import { jsonExpressionSnippet } from '../shared/json-globals.ts'; +import { inJsonRuleScope } from '../shared/json-rule-scope.ts'; +import { + EXPRESSION_WRAPPERS, + identityUnwrap as unwrap, + keyName as sharedKeyName, + parentOf, + skipWrappers, + staticString, +} from '../shared/ast.ts'; +import { isUnshadowedGlobal } from '../shared/bindings.ts'; type AnyNode = ESTree.Node; -const WORKSPACE_MARKERS: readonly string[] = ['/apps/', '/verticals/', '/packages/', '/scripts/']; - -/** - * Absolute filename → the workspace-relative path the scope globs are written against. - * - * The *last* workspace marker wins so that real sources (`/apps/x/api/index.ts`) and the - * plugin's own fixtures (`tools/.../tests/fixtures//invalid/apps/...`) classify identically; - * `normalisePath` alone would stop at the enclosing `tools/` segment. - */ -function workspacePath(filename: string): string { - const unified = filename.replaceAll('\\', '/'); - let best = -1; - for (const marker of WORKSPACE_MARKERS) best = Math.max(best, unified.lastIndexOf(marker)); - return best === -1 ? normalisePath(unified) : unified.slice(best + 1); -} - /** Globals that can be used to reach the `JSON` bag indirectly (`globalThis.JSON.stringify`). */ const CONTAINER_GLOBALS = new Set(['globalThis', 'global', 'window', 'self']); -/** Wrappers that do not change "is this expression the callee / argument of its parent". */ -const TRANSPARENT_PARENTS = new Set([ - 'ParenthesizedExpression', - 'ChainExpression', - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSInstantiationExpression', - 'TSTypeAssertion', -]); - /** Comparison operators that turn serialized text into a structural-equality verdict. */ const COMPARISON_OPERATORS = new Set(['===', '!==', '==', '!=', '<', '>', '<=', '>=']); @@ -118,148 +101,101 @@ const DEFAULT_INCLUDE_PATHS: readonly string[] = [ '**/*.config.{ts,mts,cts,js,mjs,cjs}', ]; -interface RuleOptions { - readonly allowPaths: readonly string[]; - readonly ignoreTestFiles: boolean; - readonly includePaths: readonly string[]; +/** JSON member keys accept static cooked templates, but never dynamic computed identifiers. */ +function staticPropertyName(node: ESTree.MemberExpression): string | null { + const property = unwrap(node.property); + if (node.computed) return staticString(property); + return property.type === 'Identifier' ? property.name : null; +} + +function keyName(key: AnyNode): string | null { + return sharedKeyName(key, false, { unwrap: { wrappers: EXPRESSION_WRAPPERS, sequence: true } }); } -const DEFAULTS: RuleOptions = { - allowPaths: [], - ignoreTestFiles: true, - includePaths: DEFAULT_INCLUDE_PATHS, -}; +const OWNER_WRAPPERS = new Set([ + 'ParenthesizedExpression', + 'ChainExpression', + 'TSAsExpression', + 'TSSatisfiesExpression', + 'TSNonNullExpression', +]); -function stringList(value: unknown, fallback: readonly string[]): readonly string[] { - return Array.isArray(value) && value.every((entry) => typeof entry === 'string') - ? (value as readonly string[]) - : fallback; +function assignmentName(left: AnyNode): string | null { + if (left.type === 'Identifier') return left.name; + return left.type === 'MemberExpression' ? staticPropertyName(left) : null; } -function readOptions(raw: unknown): RuleOptions { - const given = (raw ?? {}) as Partial>; - const includePaths = stringList(given.includePaths, DEFAULTS.includePaths); - return { - allowPaths: stringList(given.allowPaths, DEFAULTS.allowPaths), - ignoreTestFiles: - typeof given.ignoreTestFiles === 'boolean' ? given.ignoreTestFiles : DEFAULTS.ignoreTestFiles, - includePaths: includePaths.length > 0 ? includePaths : DEFAULTS.includePaths, - }; +function directOwnerName(node: AnyNode): string | null { + switch (node.type) { + case 'VariableDeclarator': + return node.id.type === 'Identifier' ? node.id.name : null; + case 'Property': + case 'PropertyDefinition': + return keyName(node.key); + case 'AssignmentExpression': + return assignmentName(node.left); + default: + return null; + } } -/** Value-preserving wrappers, including the final value of a sequence expression. */ -function unwrap(node: AnyNode): AnyNode { - let current = node; - for (;;) { - if (current.type === 'SequenceExpression') { - current = current.expressions.at(-1)!; - continue; - } - if (!TRANSPARENT_PARENTS.has(current.type)) return current; - const inner = (current as { expression?: AnyNode }).expression; - if (inner === undefined) return current; - current = inner; +/** Name of the binding / property / assignment target this expression flows into. */ +function ownerName(node: AnyNode): string | null { + let current: AnyNode | null = parentOf(node); + for (let depth = 0; current !== null && depth < 10; depth += 1) { + if (!OWNER_WRAPPERS.has(current.type)) return directOwnerName(current); + current = parentOf(current); } + return null; } -function parentOf(node: AnyNode): AnyNode | null { - return (node as { parent?: AnyNode | null }).parent ?? null; +function isGlobalContainer(context: Context, node: AnyNode): boolean { + const container = unwrap(node); + return ( + container.type === 'Identifier' && + CONTAINER_GLOBALS.has(container.name) && + isUnshadowedGlobal(context, container, container.name, true) + ); } -/** Climb through parentheses/type wrappers; returns the outermost equivalent node and its parent. */ -function skipWrappers(node: AnyNode): { readonly node: AnyNode; readonly parent: AnyNode | null } { - let current = node; - let parent = parentOf(current); - while (parent !== null && TRANSPARENT_PARENTS.has(parent.type)) { - current = parent; - parent = parentOf(current); - } - return { node: current, parent }; +function isJsonHost(context: Context, node: AnyNode): boolean { + const host = unwrap(node); + if (host.type === 'Identifier') return isUnshadowedGlobal(context, host, 'JSON', true); + return ( + host.type === 'MemberExpression' && + staticPropertyName(host) === 'JSON' && + isGlobalContainer(context, host.object) + ); } -/** `JSON.stringify` / `JSON["stringify"]` → `"stringify"`; a dynamic key → `null`. */ -function staticPropertyName(node: ESTree.MemberExpression): string | null { - const property = unwrap(node.property as AnyNode); - if (!node.computed) - return property.type === 'Identifier' ? (property as ESTree.IdentifierName).name : null; - if (property.type === 'TemplateLiteral' && property.expressions.length === 0) - return property.quasis[0]?.value.cooked ?? null; - if (property.type !== 'Literal') return null; - const value = (property as { value?: unknown }).value; - return typeof value === 'string' ? value : null; +function isKeyedCall(consumer: ESTree.CallExpression, result: AnyNode): boolean { + if (consumer.arguments[0] !== result || consumer.callee.type !== 'MemberExpression') return false; + const method = staticPropertyName(consumer.callee); + return method !== null && KEYED_METHODS.has(method); } -/** `true` when `node` is the global `name` — not a local, parameter, class or imported binding. */ -function isUnshadowedGlobal(context: Context, node: AnyNode, name: string): boolean { - if (node.type !== 'Identifier') return false; - if ((node as ESTree.IdentifierReference).name !== name) return false; - let scope: Scope | null = context.sourceCode.getScope(node); - while (scope !== null) { - const variable = scope.set.get(name); - const valueBinding = variable?.defs.some((definition) => { - const def = definition as unknown as { - type: string; - node?: { importKind?: string }; - parent?: { importKind?: string }; - }; - return ( - def.type !== 'Type' && - !( - def.type === 'ImportBinding' && - (def.node?.importKind === 'type' || def.parent?.importKind === 'type') - ) - ); - }); - if (valueBinding) return false; - // A type-only binding does not hide an outer value binding with this name. - scope = scope.upper; - } - return true; +function isKeyConsumer(consumer: AnyNode | null, result: AnyNode): boolean { + if (consumer?.type === 'CallExpression') return isKeyedCall(consumer, result); + return consumer?.type === 'MemberExpression' && consumer.computed && consumer.property === result; } -function keyName(key: AnyNode): string | null { - key = unwrap(key); - if (key.type === 'TemplateLiteral' && key.expressions.length === 0) - return key.quasis[0]?.value.cooked ?? null; - if (key.type === 'Identifier') return (key as ESTree.IdentifierName).name; - if (key.type === 'Literal') { - const value = (key as { value?: unknown }).value; - return typeof value === 'string' ? value : null; - } - return null; +function callMessage(call: AnyNode): string { + const { node: result, parent: consumer } = skipWrappers(call); + if (consumer?.type === 'BinaryExpression' && COMPARISON_OPERATORS.has(consumer.operator)) + return 'jsonStringifyEquality'; + if (isKeyConsumer(consumer, result)) return 'jsonStringifyIdentityKey'; + const owner = ownerName(call); + return owner !== null && IDENTITY_NAME.test(owner) + ? 'jsonStringifyIdentityKey' + : 'nativeJsonStringify'; } -/** Name of the binding / property / assignment target this expression flows into. */ -function ownerName(node: AnyNode): string | null { - let current: AnyNode | null = parentOf(node); - for (let depth = 0; current !== null && depth < 10; depth += 1) { - switch (current.type) { - case 'VariableDeclarator': { - const id = (current as ESTree.VariableDeclarator).id as AnyNode; - return id.type === 'Identifier' ? (id as ESTree.BindingIdentifier).name : null; - } - case 'Property': - case 'PropertyDefinition': - return keyName((current as { key: AnyNode }).key); - case 'AssignmentExpression': { - const left = (current as ESTree.AssignmentExpression).left as AnyNode; - if (left.type === 'Identifier') return (left as ESTree.IdentifierReference).name; - if (left.type === 'MemberExpression') - return staticPropertyName(left as ESTree.MemberExpression); - return null; - } - case 'ParenthesizedExpression': - case 'ChainExpression': - case 'TSAsExpression': - case 'TSSatisfiesExpression': - case 'TSNonNullExpression': - current = parentOf(current); - continue; - default: - return null; - } - } - return null; +/** Called references anchor at their call; point-free references anchor at capture. */ +function classify(reference: AnyNode): { readonly node: AnyNode; readonly messageId: string } { + const { node: callee, parent } = skipWrappers(reference); + if (parent?.type !== 'CallExpression' || parent.callee !== callee) + return { node: reference, messageId: 'jsonStringifyReference' }; + return { node: parent, messageId: callMessage(parent) }; } /** Effect-native rule: serialization is owned by a Schema codec, never re-decided per call site. */ @@ -311,98 +247,21 @@ export const rule = defineRule({ ], }, create(context) { - const options = readOptions(context.options[0]); - const path = workspacePath(context.filename); - if (!matchesAny(path, options.includePaths)) return {}; - if (matchesAny(path, options.allowPaths)) return {}; - if (options.ignoreTestFiles && isTestFile(path)) return {}; - - const printed = (node: AnyNode): string => { - const text = context.sourceCode.getText(node).replace(/\s+/gu, ' ').trim(); - return text.length > 72 ? `${text.slice(0, 69)}...` : text; - }; + if (!inJsonRuleScope(context.filename, context.options[0], DEFAULT_INCLUDE_PATHS)) return {}; const report = (node: AnyNode, messageId: string): void => { - context.report({ node, messageId, data: { expression: printed(node) } }); - }; - - /** `true` when this expression evaluates to the ambient `JSON` global. */ - const isJsonHost = (node: AnyNode): boolean => { - const inner = unwrap(node); - if (inner.type === 'Identifier') return isUnshadowedGlobal(context, inner, 'JSON'); - if (inner.type === 'MemberExpression') { - // `globalThis.JSON`, `window["JSON"]`. - const member = inner as ESTree.MemberExpression; - if (staticPropertyName(member) !== 'JSON') return false; - const container = unwrap(member.object as AnyNode); - if (container.type !== 'Identifier') return false; - const containerName = (container as ESTree.IdentifierReference).name; - return ( - CONTAINER_GLOBALS.has(containerName) && - isUnshadowedGlobal(context, container, containerName) - ); - } - return false; - }; - - /** - * Decide which diagnostic a `JSON.stringify` reference earns, and which node to anchor it to: - * the enclosing call when it is called here, the reference itself when it is passed around. - */ - const classify = ( - reference: AnyNode, - ): { readonly node: AnyNode; readonly messageId: string } => { - const { node: callee, parent } = skipWrappers(reference); - if ( - parent === null || - parent.type !== 'CallExpression' || - (parent as ESTree.CallExpression).callee !== callee - ) { - return { node: reference, messageId: 'jsonStringifyReference' }; - } - const call = parent as unknown as AnyNode; - const { node: result, parent: consumer } = skipWrappers(call); - if (consumer !== null) { - if ( - consumer.type === 'BinaryExpression' && - COMPARISON_OPERATORS.has((consumer as ESTree.BinaryExpression).operator) - ) { - return { node: call, messageId: 'jsonStringifyEquality' }; - } - // `map.set(JSON.stringify(key), value)` / `cache.get(JSON.stringify(key))`. - if (consumer.type === 'CallExpression') { - const consumerCall = consumer as ESTree.CallExpression; - if ((consumerCall.arguments[0] as AnyNode | undefined) === result) { - const consumerCallee = consumerCall.callee as AnyNode; - if (consumerCallee.type === 'MemberExpression') { - const method = staticPropertyName(consumerCallee as ESTree.MemberExpression); - if (method !== null && KEYED_METHODS.has(method)) { - return { node: call, messageId: 'jsonStringifyIdentityKey' }; - } - } - } - } - // `bucket[JSON.stringify(key)] = value`. - if ( - consumer.type === 'MemberExpression' && - (consumer as ESTree.MemberExpression).computed && - ((consumer as ESTree.MemberExpression).property as AnyNode) === result - ) { - return { node: call, messageId: 'jsonStringifyIdentityKey' }; - } - } - const owner = ownerName(call); - if (owner !== null && IDENTITY_NAME.test(owner)) { - return { node: call, messageId: 'jsonStringifyIdentityKey' }; - } - return { node: call, messageId: 'nativeJsonStringify' }; + context.report({ + node, + messageId, + data: { expression: jsonExpressionSnippet(context.sourceCode.getText(node)) }, + }); }; return { // `JSON.stringify(...)`, `JSON["stringify"]`, `globalThis.JSON?.stringify?.(...)`. MemberExpression(node) { if (staticPropertyName(node) !== 'stringify') return; - if (!isJsonHost(node.object as AnyNode)) return; + if (!isJsonHost(context, node.object as AnyNode)) return; const outcome = classify(node as unknown as AnyNode); report(outcome.node, outcome.messageId); }, @@ -417,7 +276,7 @@ export const rule = defineRule({ : parent.type === 'AssignmentExpression' ? ((parent as ESTree.AssignmentExpression).right as AnyNode) : null; - if (source === null || !isJsonHost(source)) return; + if (source === null || !isJsonHost(context, source)) return; for (const property of node.properties) { if (property.type !== 'Property') continue; if (keyName((property as { key: AnyNode }).key) !== 'stringify') continue; diff --git a/app/tools/oxlint/effect-native/rules/no-native-timers.ts b/app/tools/oxlint/effect-native/rules/no-native-timers.ts index 8b6fd1014..45eb81d00 100644 --- a/app/tools/oxlint/effect-native/rules/no-native-timers.ts +++ b/app/tools/oxlint/effect-native/rules/no-native-timers.ts @@ -152,25 +152,29 @@ interface RuleOptions { readonly testClockIndicators?: readonly string[]; } +function withDefault(value: T | null | undefined, fallback: T): T { + return value ?? fallback; +} + function readOptions(raw: RuleOptions | undefined): Required { const value = raw ?? {}; return { - includeTests: value.includeTests ?? true, - includeScripts: value.includeScripts ?? false, - requireTestClockInTests: value.requireTestClockInTests ?? true, - allowNodeTestMockTimers: value.allowNodeTestMockTimers ?? true, - adapterFiles: value.adapterFiles ?? [], - ignore: value.ignore ?? DEFAULT_IGNORE_PATHS, - includePaths: value.includePaths ?? DEFAULT_INCLUDE_PATHS, - testPaths: value.testPaths ?? [], - productionPaths: value.productionPaths ?? [], - timerGlobals: value.timerGlobals ?? DEFAULT_TIMER_GLOBALS, - globalObjects: value.globalObjects ?? DEFAULT_GLOBAL_OBJECTS, - timerModules: value.timerModules ?? DEFAULT_TIMER_MODULES, - effectTimeMembers: value.effectTimeMembers ?? DEFAULT_EFFECT_TIME_MEMBERS, - clockMembers: value.clockMembers ?? DEFAULT_CLOCK_MEMBERS, - dateTimeMembers: value.dateTimeMembers ?? DEFAULT_DATE_TIME_MEMBERS, - testClockIndicators: value.testClockIndicators ?? DEFAULT_TEST_CLOCK_INDICATORS, + includeTests: withDefault(value.includeTests, true), + includeScripts: withDefault(value.includeScripts, false), + requireTestClockInTests: withDefault(value.requireTestClockInTests, true), + allowNodeTestMockTimers: withDefault(value.allowNodeTestMockTimers, true), + adapterFiles: withDefault(value.adapterFiles, []), + ignore: withDefault(value.ignore, DEFAULT_IGNORE_PATHS), + includePaths: withDefault(value.includePaths, DEFAULT_INCLUDE_PATHS), + testPaths: withDefault(value.testPaths, []), + productionPaths: withDefault(value.productionPaths, []), + timerGlobals: withDefault(value.timerGlobals, DEFAULT_TIMER_GLOBALS), + globalObjects: withDefault(value.globalObjects, DEFAULT_GLOBAL_OBJECTS), + timerModules: withDefault(value.timerModules, DEFAULT_TIMER_MODULES), + effectTimeMembers: withDefault(value.effectTimeMembers, DEFAULT_EFFECT_TIME_MEMBERS), + clockMembers: withDefault(value.clockMembers, DEFAULT_CLOCK_MEMBERS), + dateTimeMembers: withDefault(value.dateTimeMembers, DEFAULT_DATE_TIME_MEMBERS), + testClockIndicators: withDefault(value.testClockIndicators, DEFAULT_TEST_CLOCK_INDICATORS), }; } @@ -229,6 +233,32 @@ function resolve(context: Context, node: AnyNode, name: string): Resolution { return 'global'; } +function isIncludedFile(filename: string, options: Required): boolean { + if (!matchesAny(filename, options.includePaths)) return false; + if (matchesAny(filename, options.ignore)) return false; + if (matchesAny(filename, options.adapterFiles)) return false; + return !isScriptFile(filename) || options.includeScripts; +} + +function isTestPath(filename: string, options: Required): boolean { + if (matchesAny(filename, options.testPaths)) return true; + if (matchesAny(filename, options.productionPaths)) return false; + return isTestFile(filename); +} + +/** Stop at dynamic members, retaining the unresolved root for callers to reject. */ +function memberRoot(input: AnyNode): { root: AnyNode; chain: string[] } { + let root = unwrap(input); + const chain: string[] = []; + while (root.type === 'MemberExpression') { + const key = staticKey(root.property, root.computed); + if (key === null) break; + chain.push(key); + root = unwrap(root.object); + } + return { root, chain }; +} + export const rule = defineRule({ meta: { type: 'problem', @@ -386,16 +416,8 @@ export const rule = defineRule({ create(context) { const options = readOptions(context.options[0] as RuleOptions | undefined); const filename = context.filename; - if (!matchesAny(filename, options.includePaths)) return {}; - if (matchesAny(filename, options.ignore)) return {}; - if (matchesAny(filename, options.adapterFiles)) return {}; - if (isScriptFile(filename) && !options.includeScripts) return {}; - - const inTest = matchesAny(filename, options.testPaths) - ? true - : matchesAny(filename, options.productionPaths) - ? false - : isTestFile(filename); + if (!isIncludedFile(filename, options)) return {}; + const inTest = isTestPath(filename, options); if (inTest && !options.includeTests) return {}; const timerGlobals = new Set(options.timerGlobals); @@ -447,6 +469,107 @@ export const rule = defineRule({ return false; } + function isClockImport(source: string, imported: string): boolean { + if (imported === 'TestClock') return source === 'effect/testing'; + return testClockIndicators.has(imported); + } + + function collectClockEvidence(source: string, values: ESTree.ImportDeclaration['specifiers']) { + for (const specifier of values) { + if (specifier.type === 'ImportNamespaceSpecifier' && source === 'effect/testing') + testingNamespaces.add(specifier.local.name); + if (specifier.type !== 'ImportSpecifier') continue; + const imported = + specifier.imported.type === 'Identifier' + ? specifier.imported.name + : specifier.imported.value; + if (isClockImport(source, imported)) hasTestClock = true; + if (source === 'node:test' && imported === 'mock') nodeTestMocks.add(specifier.local.name); + } + } + + function collectTimerBindings(node: ESTree.ImportDeclaration) { + for (const specifier of node.specifiers) { + if ( + specifier.type === 'ImportNamespaceSpecifier' || + specifier.type === 'ImportDefaultSpecifier' + ) { + timerBindings.set(specifier.local.name, NAMESPACE_BINDING); + continue; + } + if (specifier.type !== 'ImportSpecifier') continue; + if (specifier.importKind === 'type') continue; + const imported = + specifier.imported.type === 'Identifier' + ? specifier.imported.name + : specifier.imported.value; + timerBindings.set(specifier.local.name, imported); + } + } + + function collectTimeImports(node: ESTree.ImportDeclaration, source: string) { + if (!checkEffectTime) return; + const submodule = SUBMODULE_SOURCE.exec(source)?.[1]; + if (submodule === undefined) return; + for (const specifier of node.specifiers) { + if (specifier.type !== 'ImportSpecifier') continue; + if (specifier.importKind === 'type') continue; + const imported = + specifier.imported.type === 'Identifier' + ? specifier.imported.name + : specifier.imported.value; + if (!isRealTimeMember(submodule, imported)) continue; + timeSites.push({ node: specifier, callee: `${submodule}.${imported}` }); + } + } + + function collectIdentifierCall( + node: ESTree.CallExpression, + callee: Extract, + ) { + const name = callee.name; + const resolution = resolve(context, callee, name); + if (resolution === 'import') { + if (timerBindings.has(name)) bindingSites.push({ node: callee, callee: name }); + return; + } + if (resolution !== 'global' || name !== 'require' || node.arguments[0] === undefined) return; + const source = staticKey(node.arguments[0], true); + if (source !== null && timerModules.has(source)) importSites.push({ node, callee: source }); + } + + function enablesMockTimers(object: AnyNode, member: string): boolean { + if (member !== 'enable' || object.type !== 'MemberExpression') return false; + if (staticKey(object.property, object.computed) !== 'timers') return false; + const root = unwrap(object.object); + return ( + root.type === 'Identifier' && + nodeTestMocks.has(root.name) && + resolve(context, root, root.name) === 'import' + ); + } + + function isGlobalTimerMember(node: ESTree.MemberExpression): boolean { + const { root, chain } = memberRoot(node.object); + return ( + root.type === 'Identifier' && + globalObjects.has(root.name) && + resolve(context, root, root.name) === 'global' && + timerGlobals.has(staticKey(node.property, node.computed) ?? '') && + chain.every((key) => globalObjects.has(key)) + ); + } + + function isTestClockMember(node: ESTree.MemberExpression): boolean { + const object = unwrap(node.object); + return ( + object.type === 'Identifier' && + testingNamespaces.has(object.name) && + staticKey(node.property, node.computed) === 'TestClock' && + resolve(context, object, object.name) === 'import' + ); + } + return { Program(node) { bindings = collectEffectBindings(node); @@ -496,133 +619,34 @@ export const rule = defineRule({ (specifier) => specifier.type !== 'ImportSpecifier' || specifier.importKind !== 'type', ); if (node.specifiers.length > 0 && values.length === 0) return; - for (const specifier of values) { - if (specifier.type === 'ImportNamespaceSpecifier' && source === 'effect/testing') - testingNamespaces.add(specifier.local.name); - if (specifier.type !== 'ImportSpecifier') continue; - const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; - if (source === 'effect/testing' && imported === 'TestClock') hasTestClock = true; - if (testClockIndicators.has(imported) && imported !== 'TestClock') hasTestClock = true; - if (source === 'node:test' && imported === 'mock') - nodeTestMocks.add(specifier.local.name); - } + collectClockEvidence(source, values); if (timerModules.has(source)) { importSites.push({ node, callee: source }); - for (const specifier of node.specifiers) { - if ( - specifier.type === 'ImportNamespaceSpecifier' || - specifier.type === 'ImportDefaultSpecifier' - ) { - timerBindings.set(specifier.local.name, NAMESPACE_BINDING); - continue; - } - if (specifier.type !== 'ImportSpecifier') continue; - if (specifier.importKind === 'type') continue; - const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; - timerBindings.set(specifier.local.name, imported); - } + collectTimerBindings(node); return; } - if (!checkEffectTime) return; - const submodule = SUBMODULE_SOURCE.exec(source)?.[1]; - if (submodule === undefined) return; - for (const specifier of node.specifiers) { - if (specifier.type !== 'ImportSpecifier') continue; - if (specifier.importKind === 'type') continue; - const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; - if (!isRealTimeMember(submodule, imported)) continue; - timeSites.push({ node: specifier, callee: `${submodule}.${imported}` }); - } + collectTimeImports(node, source); }, CallExpression(node) { const callee = unwrap(node.callee); - if (callee.type === 'Identifier') { - const name = callee.name; - const resolution = resolve(context, callee, name); - if (resolution === 'import') { - if (timerBindings.has(name)) bindingSites.push({ node: callee, callee: name }); - return; - } - if (resolution === 'global' && name === 'require' && node.arguments[0] !== undefined) { - const source = staticKey(node.arguments[0], true); - if (source !== null && timerModules.has(source)) - importSites.push({ node, callee: source }); - } + collectIdentifierCall(node, callee); return; } - if (callee.type !== 'MemberExpression') return; - let object = unwrap(callee.object); const member = staticKey(callee.property, callee.computed); if (member === null) return; - - // Only a real node:test mock binding may provide virtual native timers. - if ( - object.type === 'MemberExpression' && - staticKey(object.property, object.computed) === 'timers' - ) { - const root = unwrap(object.object); - if ( - root.type === 'Identifier' && - nodeTestMocks.has(root.name) && - resolve(context, root, root.name) === 'import' && - member === 'enable' - ) - hasMockTimers = true; - } - const chain: string[] = []; - while (object.type === 'MemberExpression') { - const key = staticKey(object.property, object.computed); - if (key === null) return; - chain.push(key); - object = unwrap(object.object); - } - - if (object.type !== 'Identifier') return; - const objectName = object.name; - - if (timerBindings.has(objectName) && resolve(context, object, objectName) === 'import') { + if (enablesMockTimers(unwrap(callee.object), member)) hasMockTimers = true; + const { root } = memberRoot(callee.object); + if (root.type !== 'Identifier') return; + if (timerBindings.has(root.name) && resolve(context, root, root.name) === 'import') bindingSites.push({ node: callee, callee: printed(callee) }); - return; - } }, MemberExpression(node) { - let root = unwrap(node.object); - const chain: string[] = []; - while (root.type === 'MemberExpression') { - const key = staticKey(root.property, root.computed); - if (key === null) break; - chain.push(key); - root = unwrap(root.object); - } - if ( - root.type === 'Identifier' && - globalObjects.has(root.name) && - resolve(context, root, root.name) === 'global' && - timerGlobals.has(staticKey(node.property, node.computed) ?? '') && - chain.every((key) => globalObjects.has(key)) - ) - nativeSites.push({ node, callee: printed(node) }); - const object = unwrap(node.object); - if ( - object.type === 'Identifier' && - testingNamespaces.has(object.name) && - staticKey(node.property, node.computed) === 'TestClock' && - resolve(context, object, object.name) === 'import' - ) - hasTestClock = true; + if (isGlobalTimerMember(node)) nativeSites.push({ node, callee: printed(node) }); + if (isTestClockMember(node)) hasTestClock = true; if (!checkEffectTime) return; const resolved = timeMemberOf(node); if (resolved === null) return; diff --git a/app/tools/oxlint/effect-native/rules/no-nested-effect-run.ts b/app/tools/oxlint/effect-native/rules/no-nested-effect-run.ts index 6e8811b31..6abcef41c 100644 --- a/app/tools/oxlint/effect-native/rules/no-nested-effect-run.ts +++ b/app/tools/oxlint/effect-native/rules/no-nested-effect-run.ts @@ -60,10 +60,14 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, SourceCode, Variable } from '@oxlint/plugins'; +import type { Context, ESTree, Variable } from '@oxlint/plugins'; import { bindingsFor, effectMember } from '../shared/effect-imports.ts'; import type { EffectBindings } from '../shared/effect-imports.ts'; +import { asNode as sharedAsNode, keyName as sharedKeyName } from '../shared/ast.ts'; +import { resolveVariable as sharedResolveVariable } from '../shared/bindings.ts'; +import { importedName } from '../shared/imports.ts'; +import { sameNode as sharedSameNode, nodeKey as sharedNodeKey } from '../shared/reporting.ts'; import { matchesAny } from '../shared/paths.ts'; /** Root-fiber entry points. Every one of these starts a fresh runtime with no inherited context. */ @@ -128,20 +132,15 @@ interface AnyNode { } function asNode(value: unknown): AnyNode | null { - if (typeof value !== 'object' || value === null) return null; - const candidate = value as { type?: unknown; start?: unknown }; - if (typeof candidate.type !== 'string' || typeof candidate.start !== 'number') return null; - return value as AnyNode; + return sharedAsNode(value, true) as AnyNode | null; } -/** Lazily materialised AST nodes are not reference-stable; compare by kind + span instead. */ function sameNode(left: AnyNode | null, right: AnyNode | null): boolean { - if (left === null || right === null) return false; - return left.type === right.type && left.start === right.start && left.end === right.end; + return sharedSameNode(left as ESTree.Node | null, right as ESTree.Node | null); } function nodeKey(node: AnyNode): string { - return `${node.type}:${node.start}:${node.end}`; + return sharedNodeKey(node as unknown as ESTree.Node, ':'); } /** Strip parens, `!`, `as`, `satisfies` and optional-chaining wrappers to reach the real expression. */ @@ -170,21 +169,7 @@ function isRunMemberName(name: string): boolean { } function keyName(key: AnyNode | null, computed: boolean): string | null { - if (key === null) return null; - if (computed) { - // `Effect["runPromise"]` and `Effect[`runPromise`]` — static string keys only. - if (key.type === 'Literal') return typeof key.value === 'string' ? key.value : null; - if (key.type === 'TemplateLiteral') { - const expressions = Array.isArray(key.expressions) ? key.expressions : []; - const quasis = Array.isArray(key.quasis) ? key.quasis : []; - if (expressions.length !== 0 || quasis.length !== 1) return null; - const cooked = (asNode(quasis[0])?.value as { cooked?: unknown } | undefined)?.cooked; - return typeof cooked === 'string' ? cooked : null; - } - return null; - } - if (key.type === 'Identifier' && typeof key.name === 'string') return key.name; - return key.type === 'Literal' && typeof key.value === 'string' ? key.value : null; + return sharedKeyName(key, computed, { templates: computed, singleQuasi: true }); } /** Property name of a `MemberExpression`, honouring computed static access. */ @@ -197,14 +182,6 @@ function propertyKeyName(node: AnyNode): string | null { return keyName(asNode(node.key), node.computed === true); } -function importedName(specifier: { - imported: { type: string; name?: string; value?: string }; -}): string | null { - const imported = specifier.imported; - if (imported.type === 'Identifier') return imported.name ?? null; - return typeof imported.value === 'string' ? imported.value : null; -} - interface FileImports { /** Locals bound to `import * as x from "effect"` — the root barrel (`x.Effect.runPromise`). */ readonly barrelLocals: ReadonlySet; @@ -229,6 +206,30 @@ function collectImports(context: Context, modules: readonly string[]): FileImpor const flatRuns = new Map(); let importsEffect = base.importsEffect; + const collectSpecifier = ( + specifier: ESTree.ImportDeclaration['specifiers'][number], + submodule: string, + isExtraModule: boolean, + ): void => { + if (specifier.type === 'ImportNamespaceSpecifier') { + if (submodule === 'effect') barrelLocals.add(specifier.local.name); + return; + } + if (specifier.type !== 'ImportSpecifier') return; + const imported = importedName(specifier); + const local = specifier.local.name; + if (isExtraModule) { + namespaces.set(local, imported); + if (isRunMemberName(imported)) flatRuns.set(local, imported); + return; + } + if (submodule === 'Effect' && isRunMemberName(imported)) { + flatRuns.set(local, imported); + return; + } + if (OWNING_NAMESPACES.has(submodule)) flatOwners.set(local, submodule); + }; + for (const statement of context.sourceCode.ast.body) { if (statement.type !== 'ImportDeclaration') continue; const source = statement.source.value; @@ -237,47 +238,20 @@ function collectImports(context: Context, modules: readonly string[]): FileImpor if (!isEffectPackage && !isExtraModule) continue; importsEffect = true; const submodule = isEffectPackage ? (source.split('/').at(-1) ?? '') : ''; - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportNamespaceSpecifier') { - if (isEffectPackage && submodule === 'effect') barrelLocals.add(specifier.local.name); - continue; - } - if (specifier.type !== 'ImportSpecifier') continue; - const imported = importedName(specifier); - if (imported === null) continue; - const local = specifier.local.name; - if (isExtraModule) { - // The barrel re-exports the namespaces (`Effect`, `Layer`, ...) verbatim. - namespaces.set(local, imported); - if (isRunMemberName(imported)) flatRuns.set(local, imported); - continue; - } - if (submodule === 'Effect' && isRunMemberName(imported)) { - flatRuns.set(local, imported); - continue; - } - if (OWNING_NAMESPACES.has(submodule)) flatOwners.set(local, submodule); - } + for (const specifier of statement.specifiers) + collectSpecifier(specifier, submodule, isExtraModule); } return { barrelLocals, bindings: { importsEffect, namespaces }, flatOwners, flatRuns }; } -function resolveVariable(sourceCode: SourceCode, identifier: AnyNode): Variable | null { - const name = typeof identifier.name === 'string' ? identifier.name : null; - if (name === null) return null; - let scope: Scope | null = null; +function resolveVariable(context: Context, identifier: AnyNode): Variable | null { + if (typeof identifier.name !== 'string') return null; try { - scope = sourceCode.getScope(identifier as unknown as ESTree.Node); + return sharedResolveVariable(context, identifier.name, identifier as unknown as ESTree.Node); } catch { return null; } - while (scope !== null) { - const variable = scope.set.get(name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; } /** Ban new root fibers started from inside Effect-owned code (audit S1 + A1). */ @@ -344,22 +318,29 @@ export const rule = defineRule({ * An unresolvable identifier falls back to the module-level import table. */ const resolvesToEffectImport = (identifier: AnyNode): boolean => { - const variable = resolveVariable(context.sourceCode, identifier); + const variable = resolveVariable(context, identifier); if (variable === null) return true; return variable.defs.some((definition) => definition.type === 'ImportBinding'); }; + const importedNamespace = ( + identifier: AnyNode, + namespaces: ReadonlyMap, + ): string | null => { + if (typeof identifier.name !== 'string') return null; + const namespace = namespaces.get(identifier.name); + if (namespace === undefined) return null; + return resolvesToEffectImport(identifier) ? namespace : null; + }; + /** * Namespace an object expression stands for: `Effect` → `"Effect"`, `effect.Layer` → `"Layer"` * (root-barrel namespace import). `null` when the object is not a confirmed effect namespace. */ const namespaceOfObject = (object: AnyNode | null): string | null => { if (object === null) return null; - if (object.type === 'Identifier' && typeof object.name === 'string') { - const namespace = imports.bindings.namespaces.get(object.name); - if (namespace === undefined) return null; - return resolvesToEffectImport(object) ? namespace : null; - } + if (object.type === 'Identifier') + return importedNamespace(object, imports.bindings.namespaces); if (object.type !== 'MemberExpression') return null; const base = unwrap(object.object); if (base === null || base.type !== 'Identifier' || typeof base.name !== 'string') return null; @@ -387,11 +368,7 @@ export const rule = defineRule({ */ const owningNamespaceOf = (callee: AnyNode | null): string | null => { if (callee === null) return null; - if (callee.type === 'Identifier' && typeof callee.name === 'string') { - const namespace = imports.flatOwners.get(callee.name); - if (namespace === undefined) return null; - return resolvesToEffectImport(callee) ? namespace : null; - } + if (callee.type === 'Identifier') return importedNamespace(callee, imports.flatOwners); if (callee.type !== 'MemberExpression') return null; const member = staticPropertyName(callee); if (member === null || isRunMemberName(member)) return null; @@ -436,7 +413,7 @@ export const rule = defineRule({ */ const referenceStartsFor = (identifier: AnyNode | null): readonly AnyNode[] => { if (identifier === null || identifier.type !== 'Identifier') return []; - const variable = resolveVariable(context.sourceCode, identifier); + const variable = resolveVariable(context, identifier); if (variable === null || variable.defs.length !== 1) return []; const starts: AnyNode[] = []; for (const reference of variable.references) { @@ -449,6 +426,55 @@ export const rule = defineRule({ return starts; }; + const isOwnedArgument = (parent: AnyNode, child: AnyNode): boolean => { + if (parent.type !== 'CallExpression' && parent.type !== 'NewExpression') return false; + return ( + callArguments(parent).some((argument) => sameNode(argument, child)) && + calleeOwner(unwrap(parent.callee)) !== null + ); + }; + + const callbackBinding = (parent: AnyNode, child: AnyNode): AnyNode | null => { + if ( + parent.type === 'VariableDeclarator' && + isFunctionNode(child) && + sameNode(unwrap(parent.init), child) + ) + return asNode(parent.id); + if (parent.type === 'FunctionDeclaration' && sameNode(asNode(parent.body), child)) + return asNode(parent.id); + return null; + }; + + const enqueueCallbackReferences = ( + identifier: AnyNode | null, + queue: AnyNode[], + seen: Set, + ): void => { + for (const next of referenceStartsFor(identifier)) { + const key = nodeKey(next); + if (seen.has(key)) continue; + seen.add(key); + queue.push(next); + } + }; + + const walkOwners = (from: AnyNode, queue: AnyNode[], seen: Set): boolean => { + let child = from; + let parent = asNode(child.parent); + for ( + let guard = 0; + parent !== null && parent.type !== 'Program' && guard < MAX_WALK_STEPS; + guard += 1 + ) { + if (isOwnedArgument(parent, child)) return true; + enqueueCallbackReferences(callbackBinding(parent, child), queue, seen); + child = parent; + parent = asNode(parent.parent); + } + return false; + }; + /** * True when the search from `start` reaches Effect-owned code: the arguments of an Effect-family * call (directly, through an object literal such as `{ try:, catch: }`, or through a curried @@ -464,45 +490,7 @@ export const rule = defineRule({ hops += 1; const from = queue.shift(); if (from === undefined) break; - let child: AnyNode = from; - let parent = asNode(child.parent); - for ( - let guard = 0; - parent !== null && parent.type !== 'Program' && guard < MAX_WALK_STEPS; - guard += 1 - ) { - if (parent.type === 'CallExpression' || parent.type === 'NewExpression') { - const args = callArguments(parent); - if ( - args.some((argument) => sameNode(argument, child)) && - calleeOwner(unwrap(parent.callee)) !== null - ) { - return true; - } - } - if ( - parent.type === 'VariableDeclarator' && - isFunctionNode(child) && - sameNode(unwrap(parent.init), child) - ) { - for (const next of referenceStartsFor(asNode(parent.id))) { - const key = nodeKey(next); - if (seen.has(key)) continue; - seen.add(key); - queue.push(next); - } - } - if (parent.type === 'FunctionDeclaration' && sameNode(asNode(parent.body), child)) { - for (const next of referenceStartsFor(asNode(parent.id))) { - const key = nodeKey(next); - if (seen.has(key)) continue; - seen.add(key); - queue.push(next); - } - } - child = parent; - parent = asNode(parent.parent); - } + if (walkOwners(from, queue, seen)) return true; } return false; }; @@ -533,6 +521,46 @@ export const rule = defineRule({ }); }; + const destructuredMember = (entry: unknown, name: string): string | null => { + const property = asNode(entry); + if (property === null || property.type !== 'Property') return null; + const key = propertyKeyName(property); + if (key === null || !isRunMemberName(key)) return null; + const rawValue = asNode(property.value); + const bound = rawValue?.type === 'AssignmentPattern' ? asNode(rawValue.left) : rawValue; + return bound?.type === 'Identifier' && bound.name === name ? key : null; + }; + + const declaratorRunMember = (input: unknown, name: string): string | null => { + const declarator = asNode(input); + if (declarator === null || declarator.type !== 'VariableDeclarator') return null; + const id = asNode(declarator.id); + if (id === null) return null; + const init = unwrap(declarator.init); + if (id.type === 'Identifier') return runMemberOf(init); + if (id.type !== 'ObjectPattern' || init?.type !== 'Identifier') return null; + if (namespaceOfObject(init) !== 'Effect') return null; + return patternRunMember(id, name); + }; + + const patternRunMember = (id: AnyNode, name: string): string | null => { + const properties = Array.isArray(id.properties) ? id.properties : []; + for (const entry of properties) { + const member = destructuredMember(entry, name); + if (member !== null) return member; + } + return null; + }; + + const definitionRunMember = ( + definition: Variable['defs'][number], + name: string, + ): string | null => { + if (definition.type === 'ImportBinding') return imports.flatRuns.get(name) ?? null; + if (definition.type !== 'Variable') return null; + return declaratorRunMember(definition.node, name); + }; + /** * The run member a variable stands for: a flat named import of the run function, an alias * declarator, an alias assignment, or a destructure of the Effect namespace. `null` for every @@ -540,40 +568,8 @@ export const rule = defineRule({ */ const aliasedRunMember = (variable: Variable): string | null => { for (const definition of variable.defs) { - if (definition.type === 'ImportBinding') { - const flat = imports.flatRuns.get(variable.name); - if (flat !== undefined) return flat; - continue; - } - if (definition.type !== 'Variable') continue; - const declarator = asNode(definition.node); - if (declarator === null || declarator.type !== 'VariableDeclarator') continue; - const id = asNode(declarator.id); - if (id === null) continue; - const init = unwrap(declarator.init); - if (id.type === 'Identifier') { - if (init === null) continue; - const member = runMemberOf(init); - if (member !== null) return member; - continue; - } - if (id.type !== 'ObjectPattern') continue; - if (init === null || init.type !== 'Identifier') continue; - if (namespaceOfObject(init) !== 'Effect') continue; - const properties = Array.isArray(id.properties) ? id.properties : []; - for (const entry of properties) { - const property = asNode(entry); - if (property === null || property.type !== 'Property') continue; - const key = propertyKeyName(property); - if (key === null || !isRunMemberName(key)) continue; - const rawValue = asNode(property.value); - const bound = - rawValue !== null && rawValue.type === 'AssignmentPattern' - ? asNode(rawValue.left) - : rawValue; - if (bound !== null && bound.type === 'Identifier' && bound.name === variable.name) - return key; - } + const member = definitionRunMember(definition, variable.name); + if (member !== null) return member; } // `let run; run = Effect.runPromise;` — the alias is bound by an assignment, not a declarator. for (const reference of variable.references) { @@ -584,6 +580,19 @@ export const rule = defineRule({ return null; }; + const reportAliasReferences = (variable: Variable): void => { + const member = aliasedRunMember(variable); + if (member === null) return; + for (const reference of variable.references) { + if (!reference.isRead()) continue; + const identifier = asNode(reference.identifier); + if (identifier === null) continue; + if (variable.identifiers.some((declared) => sameNode(asNode(declared), identifier))) + continue; + report(identifier, member); + } + }; + return { Program() { imports = collectImports(context, options.effectModules); @@ -598,18 +607,7 @@ export const rule = defineRule({ // Alias references are resolved through the scope manager so declaration order and the // binding position (declarator, assignment, destructure, flat import) do not matter. for (const scope of context.sourceCode.scopeManager.scopes) { - for (const variable of scope.variables) { - const member = aliasedRunMember(variable); - if (member === null) continue; - for (const reference of variable.references) { - if (!reference.isRead()) continue; - const identifier = asNode(reference.identifier); - if (identifier === null) continue; - if (variable.identifiers.some((declared) => sameNode(asNode(declared), identifier))) - continue; - report(identifier, member); - } - } + for (const variable of scope.variables) reportAliasReferences(variable); } }, MemberExpression(node) { diff --git a/app/tools/oxlint/effect-native/rules/no-nullable-schema-field.ts b/app/tools/oxlint/effect-native/rules/no-nullable-schema-field.ts index f7136ab20..1d321d1f1 100644 --- a/app/tools/oxlint/effect-native/rules/no-nullable-schema-field.ts +++ b/app/tools/oxlint/effect-native/rules/no-nullable-schema-field.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * Audit findings: **A2** — "Make Schema the sole authority for contracts and domain models" and * **B5** — "Adopt Effect's ADTs and temporal model consistently" @@ -76,23 +77,21 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree, Variable } from '@oxlint/plugins'; import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; +import { matchesGlobs } from '../shared/paths.ts'; +import { acceptsRuleFile, ruleFilePolicyProperties } from '../shared/rule-file-policy.ts'; +import { keyName, memberName as staticMemberName, unwrapNode } from '../shared/ast.ts'; +import { resolveVariable } from '../shared/bindings.ts'; +import { importDeclarations, importedName } from '../shared/imports.ts'; +import { stringArray } from '../shared/options.ts'; const SCHEMA_NAMESPACE = 'Schema'; const EFFECT_ROOT_MODULE = 'effect'; const SCHEMA_MODULE = 'effect/Schema'; const EFFECT_SOURCE = /^effect(?:\/.*)?$/u; -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the production `include` defaults instead of forcing - * the fixture config to loosen them (`run-on-repo.mts` reuses that config verbatim). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; - const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; const DEFAULT_IGNORE: readonly string[] = []; @@ -164,18 +163,8 @@ interface RuleOptions { readonly reexportModules: readonly string[]; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { ignore: stringArray(record.ignore, DEFAULT_IGNORE), ignoreTests: record.ignoreTests === true, @@ -185,40 +174,17 @@ function readOptions(context: Context): RuleOptions { }; } -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -function importedName(specifier: ESTree.ImportSpecifier): string { - return specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; -} - function unwrap(node: ESTree.Node): ESTree.Node { - let current = node; - for (let guard = 0; guard < 16; guard += 1) { - if (!UNWRAPPABLE.has(current.type)) return current; - const inner = (current as { expression?: ESTree.Node }).expression; - if (inner === undefined) return current; - current = inner; - } - return current; + return unwrapNode(node, { wrappers: UNWRAPPABLE, maxDepth: 16 }); } -/** Non-computed `.NullOr`, computed `["NullOr"]`, and the no-substitution template `` [`NullOr`] ``. */ +/** Static computed keys allow cooked templates and the rule's exact wrapper policy. */ function memberName(node: ESTree.MemberExpression): string | null { if (!node.computed) return node.property.type === 'Identifier' ? node.property.name : null; - const property = unwrap(node.property); - if (property.type === 'Literal' && typeof property.value === 'string') return property.value; - if (property.type === 'TemplateLiteral' && property.expressions.length === 0) { - return property.quasis[0]?.value.cooked ?? null; - } - return null; + return staticMemberName(node, { + templates: true, + unwrap: { wrappers: UNWRAPPABLE, maxDepth: 16 }, + }); } /** A binding's declaration sites, recorded as source offsets of the declaring identifiers. */ @@ -275,42 +241,58 @@ function collectSchemaLocals( reexportModules: readonly string[], ): SchemaLocals { const locals = emptyLocals(); - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - if (statement.importKind === 'type') continue; + const accepts = (source: string): boolean => + EFFECT_SOURCE.test(source) || matchesGlobs(source, reexportModules); + for (const statement of importDeclarations(program, accepts, { valueOnly: true })) { const source = statement.source.value; - const isReexport = matchesGlobs(source, reexportModules); - if (!EFFECT_SOURCE.test(source) && !isReexport) continue; + const isBarrel = matchesGlobs(source, reexportModules) || source === EFFECT_ROOT_MODULE; for (const specifier of statement.specifiers) { - const local = specifier.local; - if (specifier.type === 'ImportNamespaceSpecifier') { - if (isReexport || source === EFFECT_ROOT_MODULE) - addDeclaration(locals.barrel, local.name, local.start); - else if (bindings.namespaces.get(local.name) === SCHEMA_NAMESPACE) { - addDeclaration(locals.schema, local.name, local.start); - } - continue; - } - if (specifier.type !== 'ImportSpecifier') continue; - if (specifier.importKind === 'type') continue; - const imported = importedName(specifier); - if (imported === SCHEMA_NAMESPACE) addDeclaration(locals.schema, local.name, local.start); - else if (source === SCHEMA_MODULE) addDirect(locals, local.name, imported, local.start); + collectSchemaSpecifier(locals, bindings, specifier, source, isBarrel); } } return locals; } -function lookupVariable(context: Context, identifier: ESTree.Node, name: string): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(identifier); - while (scope !== null) { - const variable = scope.set.get(name); - if (variable !== undefined) return variable; - scope = scope.upper; +function collectSchemaSpecifier( + locals: SchemaLocals, + bindings: EffectBindings, + specifier: ESTree.ImportDeclaration['specifiers'][number], + source: string, + isBarrel: boolean, +): void { + const local = specifier.local; + if (specifier.type === 'ImportNamespaceSpecifier') { + if (isBarrel) addDeclaration(locals.barrel, local.name, local.start); + else if (bindings.namespaces.get(local.name) === SCHEMA_NAMESPACE) + addDeclaration(locals.schema, local.name, local.start); + return; } - return null; + if (specifier.type !== 'ImportSpecifier' || specifier.importKind === 'type') return; + const imported = importedName(specifier); + if (imported === SCHEMA_NAMESPACE) addDeclaration(locals.schema, local.name, local.start); + else if (source === SCHEMA_MODULE) addDirect(locals, local.name, imported, local.start); +} + +function soleDefinition(variable: Variable | null): Variable['defs'][number] | undefined { + return variable?.defs.length === 1 ? variable.defs[0] : undefined; } +function isConstDeclaration(node: ESTree.VariableDeclarator): boolean { + return node.parent?.type === 'VariableDeclaration' && node.parent.kind === 'const'; +} + +const NON_VALUE_PARENTS = new Set([ + 'ExportSpecifier', + 'ImportDefaultSpecifier', + 'ImportNamespaceSpecifier', + 'ImportSpecifier', + 'LabeledStatement', + 'MemberExpression', +]); +const OPTION_TARGETS = new Set(['Option', 'OptionFromSelf', ...OPTION_ABSENCE_CONSTRUCTORS]); +const PIPE_PRESERVERS = new Set(['check', 'annotate', 'brand']); +const METHOD_PRESERVERS = new Set(['check', 'annotate', 'annotateKey']); + /** `const S = Schema` / `const { NullOr } = Schema`: resolved after the whole file is known. */ interface AliasCandidate { readonly local: ESTree.Node & { readonly name: string; readonly start: number }; @@ -355,9 +337,7 @@ export const rule = defineRule({ { additionalProperties: false, properties: { - ignore: { items: { type: 'string' }, type: 'array' }, - ignoreTests: { type: 'boolean' }, - include: { items: { type: 'string' }, type: 'array' }, + ...ruleFilePolicyProperties, includeOptionalKeys: { type: 'boolean' }, reexportModules: { items: { type: 'string' }, type: 'array' }, }, @@ -368,10 +348,7 @@ export const rule = defineRule({ }, create(context) { const options = readOptions(context); - const path = scopePath(context.filename); - if (matchesGlobs(path, options.ignore)) return {}; - if (!matchesGlobs(path, options.include)) return {}; - if (options.ignoreTests && isTestFile(path)) return {}; + if (!acceptsRuleFile(context.filename, options)) return {}; let locals: SchemaLocals = emptyLocals(); let tracking = false; @@ -384,7 +361,7 @@ export const rule = defineRule({ name: string, declarations: ReadonlySet, ): boolean => { - const variable = lookupVariable(context, node, name); + const variable = resolveVariable(context, name, node); if (variable === null || variable.defs.length === 0) return true; if (variable.references.some((reference) => reference.isWrite() && !reference.init)) return false; @@ -499,18 +476,12 @@ export const rule = defineRule({ const current = outermost(node); const parent = current.parent; if (parent === null || parent === undefined) return false; - if (parent.type.startsWith('TS') || parent.type.startsWith('JSX')) return false; + if (/^(?:TS|JSX)/u.test(parent.type)) return false; + if (NON_VALUE_PARENTS.has(parent.type)) return false; switch (parent.type) { case 'CallExpression': case 'NewExpression': return parent.callee !== current; - case 'ExportSpecifier': - case 'ImportDefaultSpecifier': - case 'ImportNamespaceSpecifier': - case 'ImportSpecifier': - case 'LabeledStatement': - case 'MemberExpression': - return false; case 'Property': return parent.parent?.type !== 'ObjectPattern'; case 'VariableDeclarator': @@ -534,130 +505,137 @@ export const rule = defineRule({ if (depth > 12) return false; const expression = unwrap(node); const member = calledMember(expression); - if ( - member === 'Option' || - member === 'OptionFromSelf' || - (member !== null && OPTION_ABSENCE_CONSTRUCTORS.has(member)) - ) - return true; + if (member !== null && OPTION_TARGETS.has(member)) return true; if (expression.type !== 'Identifier') return false; - const variable = lookupVariable(context, expression, expression.name); - const definition = variable?.defs.length === 1 ? variable.defs[0] : undefined; + const variable = resolveVariable(context, expression.name, expression); + const definition = soleDefinition(variable); if (definition?.type !== 'Variable' || definition.node.type !== 'VariableDeclarator') return false; const declaration = definition.node; - if ( - declaration.parent?.type !== 'VariableDeclaration' || - declaration.parent.kind !== 'const' || - declaration.init === null - ) - return false; + if (!isConstDeclaration(declaration) || declaration.init === null) return false; return optionTarget(declaration.init, depth + 1); }; - /** Only follow the source schema, never walk through Array/Struct payload boundaries. */ + const decodeTargetIsOption = (call: ESTree.CallExpression): boolean => { + const target = firstArgument(call); + return target !== undefined && optionTarget(target); + }; + + const isImportedPipe = (callee: ESTree.Node): boolean => { + if (callee.type !== 'Identifier') return false; + const definition = soleDefinition(resolveVariable(context, callee.name, callee)); + if (definition?.type !== 'ImportBinding') return false; + if (definition.node.type !== 'ImportSpecifier' || importedName(definition.node) !== 'pipe') + return false; + return ( + definition.parent?.type === 'ImportDeclaration' && + ['effect', 'effect/Function'].includes(definition.parent.source.value) + ); + }; + + /** Undefined means all steps preserve the source, with no destination encountered. */ + const encodedPipeline = ( + steps: readonly ESTree.Node[], + unwrapSteps: boolean, + ): boolean | undefined => { + for (const argument of steps) { + const step = unwrapSteps ? unwrap(argument) : argument; + if (step.type !== 'CallExpression') return false; + const member = combinatorMember(step.callee); + if (member === 'decodeTo') return decodeTargetIsOption(step); + if (member === null || !PIPE_PRESERVERS.has(member)) return false; + } + return undefined; + }; + + const encodedCallArgument = (parent: ESTree.CallExpression, depth: number): boolean => { + const callee = unwrap(parent.callee); + if (callee.type === 'CallExpression' && combinatorMember(callee.callee) === 'decodeTo') + return decodeTargetIsOption(callee); + if (isImportedPipe(callee)) { + const result = encodedPipeline(parent.arguments.slice(1), false); + if (result !== undefined) return result; + } + const wrapper = combinatorMember(parent.callee); + // encodeTo's argument is the encoded side, not the decoded destination. + if (wrapper === 'encodeTo') return true; + return ( + wrapper !== null && OPTIONAL_REPLACEMENTS.has(wrapper) && isEncodedSide(parent, depth + 1) + ); + }; + + const encodedMethodReceiver = (parent: ESTree.MemberExpression, depth: number): boolean => { + const call = parent.parent; + if (call?.type !== 'CallExpression' || call.callee !== parent) return false; + const method = memberName(parent); + if (method === 'pipe') + return encodedPipeline(call.arguments, true) ?? isEncodedSide(call, depth + 1); + return method !== null && METHOD_PRESERVERS.has(method) && isEncodedSide(call, depth + 1); + }; + + const encodedAlias = (declaration: ESTree.VariableDeclarator, depth: number): boolean => { + if (declaration.id.type !== 'Identifier' || !isConstDeclaration(declaration)) return false; + if (declaration.parent?.parent?.type === 'ExportNamedDeclaration') return false; + const variable = resolveVariable(context, declaration.id.name, declaration.id); + const reads = variable?.references.filter((reference) => reference.isRead()) ?? []; + return ( + reads.length > 0 && + reads.every((reference) => isEncodedSide(reference.identifier, depth + 1)) + ); + }; + + /** Follow only source schemas, never Array/Struct payload boundaries. */ const isEncodedSide = (node: ESTree.Node, depth = 0): boolean => { if (depth > 12) return false; const current = outermost(node); const parent = current.parent; - if (parent?.type === 'CallExpression' && parent.arguments[0] === current) { - const callee = unwrap(parent.callee); - if (callee.type === 'CallExpression' && combinatorMember(callee.callee) === 'decodeTo') { - const target = firstArgument(callee); - return target !== undefined && optionTarget(target); - } - if (callee.type === 'Identifier') { - const variable = lookupVariable(context, callee, callee.name); - const definition = variable?.defs.length === 1 ? variable.defs[0] : undefined; - if ( - definition?.type === 'ImportBinding' && - definition.node.type === 'ImportSpecifier' && - importedName(definition.node) === 'pipe' && - definition.parent?.type === 'ImportDeclaration' && - ['effect', 'effect/Function'].includes(definition.parent.source.value) - ) { - const steps = parent.arguments.slice(1); - for (const step of steps) { - if (step.type !== 'CallExpression') return false; - const member = combinatorMember(step.callee); - if (member === 'decodeTo') { - const target = firstArgument(step); - return target !== undefined && optionTarget(target); - } - if (member !== 'check' && member !== 'annotate' && member !== 'brand') return false; - } - } - } - const wrapper = combinatorMember(parent.callee); - // encodeTo's argument is the encoded side, NOT the decoded target (unlike decodeTo). - if (wrapper === 'encodeTo') return true; - if (wrapper !== null && OPTIONAL_REPLACEMENTS.has(wrapper)) - return isEncodedSide(parent, depth + 1); - } - if (parent?.type === 'MemberExpression' && parent.object === current) { - const call = parent.parent; - if (call?.type !== 'CallExpression' || call.callee !== parent) return false; - const method = memberName(parent); - if (method === 'pipe') { - for (const argument of call.arguments) { - const step = unwrap(argument); - if (step.type !== 'CallExpression') return false; - const member = combinatorMember(step.callee); - if (member === 'decodeTo') { - const target = firstArgument(step); - return target !== undefined && optionTarget(target); - } - if (member !== 'check' && member !== 'annotate' && member !== 'brand') return false; - } - return isEncodedSide(call, depth + 1); - } - if (method === 'check' || method === 'annotate' || method === 'annotateKey') - return isEncodedSide(call, depth + 1); - } - // A shared nullable source is safe only when EVERY read is an encoded-side use. - if ( - parent?.type === 'VariableDeclarator' && - parent.init === current && - parent.id.type === 'Identifier' && - parent.parent?.type === 'VariableDeclaration' && - parent.parent.kind === 'const' && - parent.parent.parent?.type !== 'ExportNamedDeclaration' - ) { - const variable = lookupVariable(context, parent.id, parent.id.name); - const reads = variable?.references.filter((reference) => reference.isRead()) ?? []; - return ( - reads.length > 0 && - reads.every((reference) => isEncodedSide(reference.identifier, depth + 1)) - ); - } + if (!parent) return false; + if (parent.type === 'CallExpression' && parent.arguments[0] === current) + return encodedCallArgument(parent, depth); + if (parent.type === 'MemberExpression' && parent.object === current) + return encodedMethodReceiver(parent, depth); + if (parent.type === 'VariableDeclarator' && parent.init === current) + return encodedAlias(parent, depth); return false; }; + const skipOptionalCall = (node: ESTree.CallExpression): boolean => { + if (!options.includeOptionalKeys) return true; + const innerMember = calledMember(firstArgument(node)); + // Presence flags and optional(nullable) report no separate optional diagnostic. + return ( + innerMember === 'Literal' || + (innerMember !== null && NULLABLE_REPLACEMENTS.has(innerMember)) + ); + }; + + const callReplacement = (node: ESTree.CallExpression, member: string): string => { + if (member === 'NullOr') { + const outer = wrappingConstructor(node); + if (outer !== null && OPTIONAL_REPLACEMENTS.has(outer)) return OPTIONAL_NULL_REPLACEMENT; + } + return NULLABLE_REPLACEMENTS.get(member) ?? OPTIONAL_REPLACEMENTS.get(member) ?? ''; + }; + const evaluateCall = (node: ESTree.CallExpression): void => { const member = combinatorMember(node.callee); if (member === null) return; const isOptional = OPTIONAL_REPLACEMENTS.has(member); if (!NULLABLE_REPLACEMENTS.has(member) && !isOptional) return; - if (isOptional && !options.includeOptionalKeys) return; + if (isOptional && skipOptionalCall(node)) return; if (isImmediateArgumentOfAllowedConstructor(node) || isEncodedSide(node)) return; + reportCombinator(node, member, callReplacement(node, member), 'nullableCall'); + }; - const inner = firstArgument(node); - if (isOptional) { - // `Schema.optionalKey(Schema.Literal(true))` is a presence flag, not an absent value. - if (calledMember(inner) === 'Literal') return; - // `Schema.optional(Schema.NullOr(X))` is reported once, on the inner nullable call. - const innerMember = calledMember(inner); - if (innerMember !== null && NULLABLE_REPLACEMENTS.has(innerMember)) return; - } - - let replacement = - NULLABLE_REPLACEMENTS.get(member) ?? OPTIONAL_REPLACEMENTS.get(member) ?? ''; - if (member === 'NullOr') { - const outer = wrappingConstructor(node); - if (outer !== null && OPTIONAL_REPLACEMENTS.has(outer)) - replacement = OPTIONAL_NULL_REPLACEMENT; - } - reportCombinator(node, member, replacement, 'nullableCall'); + const isMemberValuePosition = (node: ESTree.MemberExpression): boolean => { + const current = outermost(node); + const parent = current.parent; + if (parent === null || parent === undefined) return false; + if (parent.type === 'CallExpression' && parent.callee === current) return false; + if (parent.type !== 'MemberExpression') return isPointFreeValuePosition(node); + if (parent.object !== current) return true; + const method = memberName(parent); + return method !== null && FUNCTION_METHODS.has(method); }; const evaluateMember = (node: ESTree.MemberExpression): void => { @@ -665,16 +643,7 @@ export const rule = defineRule({ if (member === null) return; const replacement = NULLABLE_REPLACEMENTS.get(member); if (replacement === undefined) return; - const current = outermost(node); - const parent = current.parent; - if (parent === null || parent === undefined) return; - // `Schema.NullOr(x)` is the CallExpression case; `Schema.NullOr.call(null, x)` is not. - if (parent.type === 'CallExpression' && parent.callee === current) return; - if (parent.type === 'MemberExpression' && parent.object === current) { - const method = memberName(parent); - if (method === null || !FUNCTION_METHODS.has(method)) return; - } - if (parent.type !== 'MemberExpression' && !isPointFreeValuePosition(node)) return; + if (!isMemberValuePosition(node)) return; if (isImmediateArgumentOfAllowedConstructor(node) || isEncodedSide(node)) return; reportCombinator(node, member, replacement, 'nullableReference'); }; @@ -691,32 +660,39 @@ export const rule = defineRule({ reportCombinator(node, binding.member, replacement, 'nullableReference'); }; - /** `const S = Schema` / `const { NullOr } = Schema`, run to a fixpoint so chains resolve. */ + const resolveAlias = (alias: AliasCandidate): boolean => { + const source = unwrap(alias.source); + const { name, start } = alias.local; + if (alias.key === null) { + if (isSchemaNamespace(source)) return addDeclaration(locals.schema, name, start); + if (isBarrelIdentifier(source)) return addDeclaration(locals.barrel, name, start); + return false; + } + if (isSchemaNamespace(source)) return addDirect(locals, name, alias.key, start); + if (alias.key === SCHEMA_NAMESPACE && isBarrelIdentifier(source)) + return addDeclaration(locals.schema, name, start); + return false; + }; + + /** Bounded fixpoint: every alias must be visited even after an earlier one changes. */ const resolveAliases = (): void => { for (let pass = 0; pass < 4; pass += 1) { let changed = false; - for (const alias of aliases) { - const source = unwrap(alias.source); - if (alias.key === null) { - if (isSchemaNamespace(source)) { - changed = - addDeclaration(locals.schema, alias.local.name, alias.local.start) || changed; - } else if (isBarrelIdentifier(source)) { - changed = - addDeclaration(locals.barrel, alias.local.name, alias.local.start) || changed; - } - continue; - } - if (isSchemaNamespace(source)) { - changed = addDirect(locals, alias.local.name, alias.key, alias.local.start) || changed; - } else if (alias.key === SCHEMA_NAMESPACE && isBarrelIdentifier(source)) { - changed = addDeclaration(locals.schema, alias.local.name, alias.local.start) || changed; - } - } + for (const alias of aliases) changed = resolveAlias(alias) || changed; if (!changed) return; } }; + const collectPropertyAlias = ( + property: ESTree.ObjectPattern['properties'][number], + source: ESTree.Node, + ): void => { + if (property.type !== 'Property' || property.computed) return; + if (property.value.type !== 'Identifier') return; + const key = keyName(property.key, false, { templates: false }); + if (key !== null) aliases.push({ key, local: property.value, source }); + }; + return { Program(node) { locals = collectSchemaLocals(node, collectEffectBindings(node), options.reexportModules); @@ -734,25 +710,14 @@ export const rule = defineRule({ }, VariableDeclarator(node) { if (!tracking || node.init === null) return; - if (node.parent?.type !== 'VariableDeclaration' || node.parent.kind !== 'const') return; + if (!isConstDeclaration(node)) return; const source = node.init; if (node.id.type === 'Identifier') { aliases.push({ key: null, local: node.id, source }); return; } if (node.id.type !== 'ObjectPattern') return; - for (const property of node.id.properties) { - if (property.type !== 'Property' || property.computed) continue; - if (property.value.type !== 'Identifier') continue; - const key = - property.key.type === 'Identifier' - ? property.key.name - : property.key.type === 'Literal' && typeof property.key.value === 'string' - ? property.key.value - : null; - if (key === null) continue; - aliases.push({ key, local: property.value, source }); - } + for (const property of node.id.properties) collectPropertyAlias(property, source); }, CallExpression(node) { if (!tracking) return; diff --git a/app/tools/oxlint/effect-native/rules/no-nullable-service-outcome.ts b/app/tools/oxlint/effect-native/rules/no-nullable-service-outcome.ts index 736726993..3b8026ef8 100644 --- a/app/tools/oxlint/effect-native/rules/no-nullable-service-outcome.ts +++ b/app/tools/oxlint/effect-native/rules/no-nullable-service-outcome.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * Audit findings: **A2** — "Make Schema the sole authority for contracts and domain models" * ("Model absence and outcomes with `Option`, `Result`, `Schema.OptionFromNullOr`, or typed @@ -83,16 +84,14 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree, Variable } from '@oxlint/plugins'; import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; - -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production `include` defaults. - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; +import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; +import { typeNameSegments } from '../shared/ast.ts'; +import { resolveVariable } from '../shared/bindings.ts'; +import { collectRootNamespaces } from '../shared/imports.ts'; +import { booleanOption as boolean, positiveInteger, stringArray } from '../shared/options.ts'; const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; const DEFAULT_IGNORE: readonly string[] = []; @@ -103,7 +102,6 @@ const DEFAULT_PROMISE_TYPES = ['Promise', 'PromiseLike']; /** Effect's outcome type, matched only through real `effect` import bindings. */ const EFFECT_NAMESPACE = 'Effect'; const EFFECT_TYPE = 'Effect'; -const EFFECT_ROOT_MODULE = 'effect'; const ABSENCE_TYPES = new Set(['TSNullKeyword', 'TSUndefinedKeyword']); /** Members that carry no value worth wrapping in an `Option`. */ @@ -125,26 +123,8 @@ interface RuleOptions { readonly aliasDepth: number; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - -function boolean(value: unknown, fallback: boolean): boolean { - return typeof value === 'boolean' ? value : fallback; -} - -function integer(value: unknown, fallback: number): number { - return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { include: stringArray(record.include, DEFAULT_INCLUDE), ignore: stringArray(record.ignore, DEFAULT_IGNORE), @@ -153,52 +133,10 @@ function readOptions(context: Context): RuleOptions { promiseTypes: stringArray(record.promiseTypes, DEFAULT_PROMISE_TYPES), checkEffect: boolean(record.checkEffect, true), resolveAliases: boolean(record.resolveAliases, true), - aliasDepth: integer(record.aliasDepth, 3), + aliasDepth: positiveInteger(record.aliasDepth, 3, 0), }; } -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -/** Flatten `Schema.Codec` / `Effect.Effect` / `Effect` into its dotted segments. */ -function typeNameSegments(name: ESTree.TSTypeName): readonly string[] | null { - if (name.type === 'Identifier') return [name.name]; - if (name.type === 'TSQualifiedName') { - const left = typeNameSegments(name.left); - return left === null ? null : [...left, name.right.name]; - } - return null; -} - -/** Locals bound to the whole `effect` root barrel (`import * as E from "effect"` → `E.Effect.Effect`). */ -function collectEffectBarrels(program: ESTree.Program): ReadonlySet { - const barrels = new Set(); - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - if (statement.source.value !== EFFECT_ROOT_MODULE) continue; - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportNamespaceSpecifier') barrels.add(specifier.local.name); - } - } - return barrels; -} - -/** Resolve type bindings lexically; namespace-local aliases must not leak into other scopes. */ -function lookupVariable(context: Context, node: ESTree.Node, name: string): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(node); - while (scope !== null) { - const variable = scope.set.get(name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; -} - interface Absence { /** Printed absence keywords, e.g. `undefined` or `null | undefined`. */ readonly absence: string; @@ -267,7 +205,7 @@ export const rule = defineRule({ const program = context.sourceCode.ast; const bindings: EffectBindings = collectEffectBindings(program); - const barrels = collectEffectBarrels(program); + const barrels = collectRootNamespaces(program); /** Strip type-level parentheses so `(Row | undefined)` behaves like the bare union. */ const unwrapType = (type: ESTree.TSType): ESTree.TSType => { @@ -284,7 +222,7 @@ export const rule = defineRule({ const rootVariable = (reference: ESTree.TSTypeReference): Variable | null => { let root = reference.typeName; while (root.type === 'TSQualifiedName') root = root.left; - return root.type === 'Identifier' ? lookupVariable(context, root, root.name) : null; + return root.type === 'Identifier' ? resolveVariable(context, root.name, root) : null; }; const aliasTarget = (reference: ESTree.TSTypeReference): ESTree.TSType | null => { @@ -300,37 +238,32 @@ export const rule = defineRule({ }; /** - * `Promise` / `PromiseLike` / `Effect.Effect` / `Eff.Effect` / `E.Effect.Effect` / bare `Effect`. - * Returns the printed wrapper name, or `null` when the reference is not an async outcome wrapper. + * Resolve an imported Effect wrapper from its bare, namespace or root-barrel name. */ - const wrapperName = (reference: ESTree.TSTypeReference): string | null => { - const segments = typeNameSegments(reference.typeName); - if (segments === null || segments.length === 0) return null; - const last = segments[segments.length - 1] ?? ''; - const variable = rootVariable(reference); - const global = variable === null || variable.defs.length === 0; - const imported = - variable?.defs.some((definition) => definition.type === 'ImportBinding') === true; + const effectWrapperName = (segments: readonly string[]): string | null => { + const root = segments[0] ?? ''; if (segments.length === 1) { - const name = segments[0] ?? ''; - // A same-file `type Promise = ...` shadow means this is not the global promise. - if (options.promiseTypes.includes(name)) return global ? name : null; - if (!options.checkEffect || !imported) return null; - return bindings.namespaces.get(name) === EFFECT_NAMESPACE ? name : null; + return bindings.namespaces.get(root) === EFFECT_NAMESPACE ? root : null; } - if (!options.checkEffect || !imported || last !== EFFECT_TYPE) return null; + if (segments[segments.length - 1] !== EFFECT_TYPE) return null; if (segments.length === 2) { - const namespace = segments[0] ?? ''; - return bindings.namespaces.get(namespace) === EFFECT_NAMESPACE - ? `${namespace}.${last}` - : null; + return bindings.namespaces.get(root) === EFFECT_NAMESPACE ? segments.join('.') : null; } - if (segments.length === 3) { - const barrel = segments[0] ?? ''; - if (!barrels.has(barrel) || segments[1] !== EFFECT_NAMESPACE) return null; - return `${barrel}.${EFFECT_NAMESPACE}.${last}`; + if (segments.length !== 3) return null; + return barrels.has(root) && segments[1] === EFFECT_NAMESPACE ? segments.join('.') : null; + }; + + const wrapperName = (reference: ESTree.TSTypeReference): string | null => { + const segments = typeNameSegments(reference.typeName); + if (segments === null || segments.length === 0) return null; + const variable = rootVariable(reference); + const name = segments[0] ?? ''; + if (segments.length === 1 && options.promiseTypes.includes(name)) { + return variable === null || variable.defs.length === 0 ? name : null; } - return null; + if (!options.checkEffect) return null; + const imported = variable?.defs.some((definition) => definition.type === 'ImportBinding'); + return imported ? effectWrapperName(segments) : null; }; /** Flatten nested unions/parentheses into their leaf members. */ @@ -370,6 +303,16 @@ export const rule = defineRule({ return { absence: absenceNames.join(' | '), value: values.map(printed).join(' | ') }; }; + const nextAlias = (current: ESTree.TSTypeReference, seen: Set) => { + const segments = typeNameSegments(current.typeName); + if (segments === null || segments.length !== 1) return null; + const name = segments[0] ?? ''; + if (seen.has(name)) return null; + seen.add(name); + const target = aliasTarget(current); + return target === null ? null : { name, target }; + }; + /** Absence carried by the outcome type, following same-file aliases up to `aliasDepth` hops. */ const outcomeAbsence = (type: ESTree.TSType): Absence | null => { const direct = unionAbsence(type); @@ -379,16 +322,11 @@ export const rule = defineRule({ const seen = new Set(); for (let depth = 0; depth < options.aliasDepth; depth += 1) { if (current.type !== 'TSTypeReference') return null; - const segments = typeNameSegments(current.typeName); - if (segments === null || segments.length !== 1) return null; - const name = segments[0] ?? ''; - if (seen.has(name)) return null; - seen.add(name); - const target = aliasTarget(current); - if (target === null) return null; - const resolved = unionAbsence(target); - if (resolved !== null) return { ...resolved, via: name }; - current = unwrapType(target); + const alias = nextAlias(current, seen); + if (alias === null) return null; + const resolved = unionAbsence(alias.target); + if (resolved !== null) return { ...resolved, via: alias.name }; + current = unwrapType(alias.target); } return null; }; @@ -418,15 +356,10 @@ export const rule = defineRule({ return first === undefined ? null : { wrapper, first, via }; } if (!options.resolveAliases) return null; - const segments = typeNameSegments(current.typeName); - if (segments === null || segments.length !== 1) return null; - const name = segments[0] ?? ''; - if (seen.has(name)) return null; - seen.add(name); - const target = aliasTarget(current); - if (target === null) return null; - if (via === null) via = name; - current = unwrapType(target); + const alias = nextAlias(current, seen); + if (alias === null) return null; + if (via === null) via = alias.name; + current = unwrapType(alias.target); } return null; }; @@ -448,23 +381,31 @@ export const rule = defineRule({ ); }; - const hasExternalAdapterType = (annotation: ESTree.TSTypeAnnotation): boolean => { - const fn = annotation.parent; - if (fn?.type !== 'ArrowFunctionExpression' && fn?.type !== 'FunctionExpression') return false; - let current: ESTree.Node = fn; + const adapterContainer = (fn: ESTree.Node): ESTree.Node => { + let current = fn; for (let depth = 0; depth < 8; depth += 1) { - const container: ESTree.Node | null | undefined = current.parent; + const container = current.parent; if (container?.type !== 'Property' && container?.type !== 'ObjectExpression') break; current = container; } - const parent = current.parent; - if (parent?.type === 'TSSatisfiesExpression') return externalType(parent.typeAnnotation); - if (parent?.type !== 'VariableDeclarator' || parent.id.type !== 'Identifier') return false; - return ( - parent.id.typeAnnotation !== null && - parent.id.typeAnnotation !== undefined && - externalType(parent.id.typeAnnotation.typeAnnotation) - ); + return current; + }; + + const hasExternalAdapterType = (annotation: ESTree.TSTypeAnnotation): boolean => { + const fn = annotation.parent; + if (!fn) return false; + if (fn.type !== 'ArrowFunctionExpression' && fn.type !== 'FunctionExpression') return false; + const parent = adapterContainer(fn).parent; + if (!parent) return false; + if (parent.type === 'TSSatisfiesExpression') return externalType(parent.typeAnnotation); + if (parent.type !== 'VariableDeclarator' || parent.id.type !== 'Identifier') return false; + const type = parent.id.typeAnnotation; + return type != null && externalType(type.typeAnnotation); + }; + + const absenceMessage = (outcomeAlias: string | null, absenceAlias: string | null) => { + if (outcomeAlias !== null) return 'nullableOutcomeAlias'; + return absenceAlias === null ? 'nullableOutcome' : 'nullableAlias'; }; const reported = new Set(); @@ -481,12 +422,7 @@ export const rule = defineRule({ if (absence === null) return; if (reported.has(anchor.start)) return; reported.add(anchor.start); - const messageId = - outcome.via !== null - ? 'nullableOutcomeAlias' - : absence.via === null - ? 'nullableOutcome' - : 'nullableAlias'; + const messageId = absenceMessage(outcome.via, absence.via); context.report({ node: anchor, messageId, diff --git a/app/tools/oxlint/effect-native/rules/no-per-operation-http-api-client.ts b/app/tools/oxlint/effect-native/rules/no-per-operation-http-api-client.ts index 586213cd8..d920a0b22 100644 --- a/app/tools/oxlint/effect-native/rules/no-per-operation-http-api-client.ts +++ b/app/tools/oxlint/effect-native/rules/no-per-operation-http-api-client.ts @@ -67,8 +67,13 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree } from '@oxlint/plugins'; +import { parentOf, isFunctionNode, unwrapNode, keyName } from '../shared/ast.ts'; +import { lookupVariable } from '../shared/bindings.ts'; +import { importedName } from '../shared/imports.ts'; +import { sameNode } from '../shared/reporting.ts'; +import { stringArray as readStringArray } from '../shared/options.ts'; import { collectEffectBindings, effectMember } from '../shared/effect-imports.ts'; import type { EffectBindings } from '../shared/effect-imports.ts'; import { isScriptFile, isTestFile, matchesAny, normalisePath } from '../shared/paths.ts'; @@ -107,11 +112,6 @@ const NAME_WRAPPER_MEMBERS: ReadonlySet = new Set([ ]); const EFFECT_MODULE = /^effect(?:\/.*)?$/u; -const FUNCTION_TYPES = new Set([ - 'ArrowFunctionExpression', - 'FunctionDeclaration', - 'FunctionExpression', -]); const EXPRESSION_WRAPPERS = new Set([ 'ChainExpression', 'ParenthesizedExpression', @@ -122,6 +122,11 @@ const EXPRESSION_WRAPPERS = new Set([ ]); type SiteKind = 'accessor' | 'constructor'; +interface PendingReport { + node: ESTree.Node; + messageId: string; + callee: string; +} interface ResolvedOptions { readonly include: readonly string[]; @@ -135,12 +140,6 @@ interface ResolvedOptions { readonly includeTests: boolean; } -function readStringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - function resolveOptions(context: Context): ResolvedOptions { const raw = context.options?.[0]; const option: Record = @@ -169,37 +168,9 @@ function resolveOptions(context: Context): ResolvedOptions { }; } -function parentOf(node: ESTree.Node): ESTree.Node | null { - return (node as unknown as { parent?: ESTree.Node | null }).parent ?? null; -} - -function sameNode( - left: ESTree.Node | null | undefined, - right: ESTree.Node | null | undefined, -): boolean { - if (left === null || left === undefined || right === null || right === undefined) return false; - return left.type === right.type && left.start === right.start && left.end === right.end; -} - /** Strip the wrappers that sit between an expression and its semantic parent. */ function unwrap(node: ESTree.Node): ESTree.Node { - let current = node; - while (EXPRESSION_WRAPPERS.has(current.type)) { - const inner = (current as unknown as { expression?: ESTree.Node }).expression; - if (inner === undefined) return current; - current = inner; - } - return current; -} - -function isFunctionNode(node: ESTree.Node): boolean { - return FUNCTION_TYPES.has(node.type); -} - -function importedName(specifier: ESTree.ImportSpecifier): string { - return specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; + return unwrapNode(node, { wrappers: EXPRESSION_WRAPPERS }); } function lastSegment(module: string): string { @@ -278,13 +249,6 @@ function memberParts(node: ESTree.Node): { object: string; property: string } | } /** Static key of an object-pattern property: `{ make }`, `{ make: alias }`, `{ "make": alias }`. */ -function propertyKeyName(key: ESTree.Node): string | null { - if (key.type === 'Identifier') return (key as unknown as { name: string }).name; - if (key.type !== 'Literal') return null; - const value = (key as unknown as { value: unknown }).value; - return typeof value === 'string' ? value : null; -} - /** `Layer.effect` → `"Layer.effect"` when the object is a tracked Effect namespace binding. */ function namespaceMemberString(node: ESTree.Node, bindings: EffectBindings): string | null { const matched = effectMember(node, bindings); @@ -296,18 +260,6 @@ function namespaceMemberString(node: ESTree.Node, bindings: EffectBindings): str return namespace === undefined ? null : `${namespace}.${parts.property}`; } -function lookupVariable(context: Context, identifier: ESTree.Node): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(identifier); - const name = (identifier as unknown as { name?: string }).name; - if (name === undefined) return null; - while (scope !== null) { - const variable = scope.set.get(name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; -} - export const rule = defineRule({ meta: { type: 'problem', @@ -494,39 +446,26 @@ export const rule = defineRule({ return false; } + function isNameWrapper(parent: ESTree.Node, current: ESTree.Node): boolean { + if (EXPRESSION_WRAPPERS.has(parent.type)) return true; + if (parent.type !== 'CallExpression') return false; + return ( + !sameNode(unwrap(parent.callee), current) && isWrapperCall(parent, NAME_WRAPPER_MEMBERS) + ); + } + /** The binding this function is stored under, when that is a module-level name. */ function moduleFunctionName(fn: ESTree.Node): string | null { - if (fn.type === 'FunctionDeclaration') { - const id = (fn as unknown as { id?: { name?: string } | null }).id; - return id?.name ?? null; - } - let current: ESTree.Node = fn; + if (fn.type === 'FunctionDeclaration') return fn.id?.name ?? null; + let current = fn; let parent = parentOf(current); for (let guard = 0; guard < 16 && parent !== null; guard += 1) { - if (EXPRESSION_WRAPPERS.has(parent.type)) { - current = parent; - parent = parentOf(current); - continue; - } - // `export const op = Effect.fn("op")(function* () { ... })` still binds `op`. - if ( - parent.type === 'CallExpression' && - !sameNode( - unwrap((parent as unknown as ESTree.CallExpression).callee as unknown as ESTree.Node), - current, - ) && - isWrapperCall(parent as unknown as ESTree.CallExpression, NAME_WRAPPER_MEMBERS) - ) { - current = parent; - parent = parentOf(current); - continue; - } - break; + if (!isNameWrapper(parent, current)) break; + current = parent; + parent = parentOf(current); } - if (parent === null || parent.type !== 'VariableDeclarator') return null; - const id = (parent as unknown as { id?: ESTree.Node }).id; - if (id === undefined || id.type !== 'Identifier') return null; - return (id as unknown as { name: string }).name; + if (parent?.type !== 'VariableDeclarator') return null; + return parent.id.type === 'Identifier' ? parent.id.name : null; } /** Name of the outermost enclosing function when it is declared at module level. */ @@ -596,113 +535,178 @@ export const rule = defineRule({ return classifyMember(`${lastSegment(entry.source)}.${entry.imported}`); } - /** `bff.makeEffectHttpApiClient` / `HttpApiClient.make` / `HttpApiClient["make"]`. */ - function classifyMemberCallee(callee: ESTree.Node): { text: string; kind: SiteKind } | null { - const resolved = options; - const moduleImports = imports; - if (resolved === null || moduleImports === null) return null; - const member = memberOf(callee); - if (member !== null) { - const kind = classifyMember(member); - if (kind !== null) return { kind, text: member }; - } + function classifyBarrelCallee(callee: ESTree.Node): { text: string; kind: SiteKind } | null { + if (options === null || imports === null) return null; const parts = memberParts(callee); if (parts === null) return null; - const source = moduleImports.namespaces.get(parts.object); - if (source === undefined || !resolved.effectReexportModules.includes(source)) return null; + const source = imports.namespaces.get(parts.object); + if (source === undefined || !options.effectReexportModules.includes(source)) return null; if (callee.type !== 'MemberExpression' || bindingKind(callee.object) !== 'import') return null; - if (!resolved.constructorNames.has(parts.property)) return null; + if (!options.constructorNames.has(parts.property)) return null; return { kind: 'constructor', text: `${parts.object}.${parts.property}` }; } - /** - * Module-level rebindings of a constructor: `const buildClient = makeEffectHttpApiClient`, - * `const { makeEffectHttpApiClient: build } = bff`, `const { make } = HttpApiClient`. - */ + /** Classify real Effect members before configured barrel constructors. */ + function classifyMemberCallee(callee: ESTree.Node): { text: string; kind: SiteKind } | null { + if (options === null || imports === null) return null; + const member = memberOf(callee); + const kind = member === null ? null : classifyMember(member); + if (kind !== null && member !== null) return { kind, text: member }; + return classifyBarrelCallee(callee); + } + + function identifierAliasKind(init: ESTree.Node): SiteKind | null { + if (init.type !== 'Identifier') return classifyMemberCallee(init)?.kind ?? null; + const named = imports?.named.get(init.name); + return named === undefined ? null : classifyNamedImport(named); + } + + function collectPatternProperty( + property: ESTree.Node, + namespace: string | undefined, + found: Map, + ): void { + if (property.type !== 'Property' || property.computed || property.value.type !== 'Identifier') + return; + const key = keyName(property.key); + if (key === null) return; + const kind = + namespace === undefined + ? options?.constructorNames.has(key) + ? 'constructor' + : null + : classifyMember(`${namespace}.${key}`); + if (kind !== null) found.set(property.value.name, { kind, text: property.value.name }); + } + + function collectDeclaratorAlias( + entry: ESTree.VariableDeclarator, + found: Map, + ): void { + if (entry.init === undefined || entry.init === null) return; + const init = unwrap(entry.init); + if (entry.id.type === 'Identifier') { + const kind = identifierAliasKind(init); + if (kind !== null) found.set(entry.id.name, { kind, text: entry.id.name }); + return; + } + collectPatternAliases(entry.id, init, found); + } + + function collectPatternAliases( + pattern: ESTree.Node, + init: ESTree.Node, + found: Map, + ): void { + if (pattern.type !== 'ObjectPattern' || init.type !== 'Identifier') return; + const namespace = bindings?.namespaces.get(init.name); + const source = imports?.namespaces.get(init.name); + const isImportedNamespace = + source !== undefined && options?.effectReexportModules.includes(source); + if (namespace === undefined && !isImportedNamespace) return; + for (const property of pattern.properties) collectPatternProperty(property, namespace, found); + } + + /** Collect module-level constructor rebindings without propagating operation-local aliases. */ function collectAliases( program: ESTree.Program, ): Map { - const resolved = options; - const effects = bindings; - const moduleImports = imports; const found = new Map(); - if (resolved === null || effects === null || moduleImports === null) return found; + if (options === null || bindings === null || imports === null) return found; for (const statement of program.body) { const declaration = - statement.type === 'ExportNamedDeclaration' - ? ((statement as unknown as { declaration?: ESTree.Node | null }).declaration ?? null) - : statement; - if (declaration === null || declaration.type !== 'VariableDeclaration') continue; - for (const declarator of (declaration as unknown as { declarations: ESTree.Node[] }) - .declarations) { - const entry = declarator as unknown as { id: ESTree.Node; init?: ESTree.Node | null }; - if (entry.init === undefined || entry.init === null) continue; - const init = unwrap(entry.init); - if (entry.id.type === 'Identifier') { - const local = (entry.id as unknown as { name: string }).name; - if (init.type === 'Identifier') { - const named = moduleImports.named.get((init as unknown as { name: string }).name); - const kind = named === undefined ? null : classifyNamedImport(named); - if (kind !== null) found.set(local, { kind, text: local }); - continue; - } - const memberSite = classifyMemberCallee(init); - if (memberSite !== null) found.set(local, { kind: memberSite.kind, text: local }); - continue; - } - if (entry.id.type !== 'ObjectPattern' || init.type !== 'Identifier') continue; - const objectName = (init as unknown as { name: string }).name; - const namespace = effects.namespaces.get(objectName); - const source = moduleImports.namespaces.get(objectName); - const isImportedNamespace = - source !== undefined && resolved.effectReexportModules.includes(source); - if (namespace === undefined && !isImportedNamespace) continue; - for (const property of (entry.id as unknown as { properties: ESTree.Node[] }) - .properties) { - if (property.type !== 'Property') continue; - const pair = property as unknown as { - key: ESTree.Node; - value: ESTree.Node; - computed: boolean; - }; - if (pair.computed || pair.value.type !== 'Identifier') continue; - const key = propertyKeyName(pair.key); - if (key === null) continue; - const local = (pair.value as unknown as { name: string }).name; - if (namespace !== undefined) { - const kind = classifyMember(`${namespace}.${key}`); - if (kind !== null) found.set(local, { kind, text: local }); - continue; - } - if (resolved.constructorNames.has(key)) - found.set(local, { kind: 'constructor', text: local }); - } - } + statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement; + if (declaration?.type !== 'VariableDeclaration') continue; + for (const entry of declaration.declarations) collectDeclaratorAlias(entry, found); } return found; } - /** `makeEffectHttpApiClient(...)` / `bff.makeEffectHttpApiClient(...)` / `HttpApiClient.make(...)`. */ + function identifierConstructorSite( + callee: Extract, + ): { text: string; kind: SiteKind } | null { + const kindOfBinding = bindingKind(callee); + const named = imports?.named.get(callee.name); + if (named !== undefined) { + const kind = classifyNamedImport(named); + if (kind === null) return null; + return kindOfBinding === 'import' || kindOfBinding === 'unresolved' + ? { kind, text: callee.name } + : null; + } + return kindOfBinding === 'module' || kindOfBinding === 'unresolved' + ? (aliases.get(callee.name) ?? null) + : null; + } + + /** Recognize direct constructors and module-level aliases at their use site. */ function constructorSite(callee: ESTree.Node): { text: string; kind: SiteKind } | null { - const moduleImports = imports; - if (options === null || bindings === null || moduleImports === null) return null; - if (callee.type === 'Identifier') { - const local = (callee as unknown as { name: string }).name; - const kindOfBinding = bindingKind(callee); - const named = moduleImports.named.get(local); - if (named !== undefined) { - const kind = classifyNamedImport(named); - if (kind === null) return null; - return kindOfBinding === 'import' || kindOfBinding === 'unresolved' - ? { kind, text: local } - : null; + if (options === null || bindings === null || imports === null) return null; + return callee.type === 'Identifier' + ? identifierConstructorSite(callee) + : classifyMemberCallee(callee); + } + + function collectLayerConsumed(): Set { + const layerConsumed = new Set(); + const blessedArgumentRanges: Array<{ start: number; end: number }> = []; + for (const layerCall of layerCalls) { + if (!resultEscapesToModuleLevel(layerCall.node)) continue; + for (const argument of layerCall.args) { + blessedArgumentRanges.push({ end: argument.end, start: argument.start }); + const value = unwrap(argument); + if (value.type === 'Identifier' && resolvesToModuleFunction(value)) { + layerConsumed.add((value as unknown as { name: string }).name); + } + } + } + for (const call of identifierCalls) { + if (!blessedArgumentRanges.some((range) => withinRange(call.node, range))) continue; + if (!resolvesToModuleFunction(call.identifier)) continue; + layerConsumed.add(call.name); + } + + return layerConsumed; + } + + function collectConstructorReports( + factories: Set, + reports: PendingReport[], + constructorSpans: Set, + layerConsumed: ReadonlySet, + ): void { + for (const site of constructorSites) { + constructorSpans.add(`${site.node.start}:${site.node.end}`); + if (!hasFunctionAncestor(site.node)) continue; + const owner = outermostModuleFunctionName(site.node); + const blessed = + isInsideBlessedLayerConstruction(site.node) || + (owner !== null && layerConsumed.has(owner)); + // A blessed construction still marks its function as a client factory: calling it from an + // operation elsewhere in the file is a fresh client per call. + if (owner !== null) factories.add(owner); + if (blessed) continue; + reports.push({ + callee: site.text, + messageId: site.kind === 'accessor' ? 'accessorPerOperation' : 'clientPerOperation', + node: site.node, + }); + } + } + + function closeFactories(candidates: typeof identifierCalls, factories: Set): void { + let changed = true; + while (changed) { + changed = false; + for (const call of candidates) { + if (!factories.has(call.name)) continue; + const promoted = returnedFactoryName(call.node); + if (promoted === null || promoted === call.name || factories.has(promoted)) continue; + factories.add(promoted); + changed = true; } - const alias = aliases.get(local); - if (alias === undefined) return null; - return kindOfBinding === 'module' || kindOfBinding === 'unresolved' ? alias : null; } - return classifyMemberCallee(callee); } function withinRange(node: ESTree.Node, range: { start: number; end: number }): boolean { @@ -768,47 +772,11 @@ export const rule = defineRule({ 'Program:exit'() { if (options === null || bindings === null) return; const factories = new Set(); - const reports: Array<{ node: ESTree.Node; messageId: string; callee: string }> = []; + const reports: PendingReport[] = []; const constructorSpans = new Set(); - // Module-level effect factories handed to a blessed `Layer.*`/`Effect.cached*` call build the - // client once per Layer build — the audit's target shape, even though the construction is not - // lexically nested inside the Layer call. - const layerConsumed = new Set(); - const blessedArgumentRanges: Array<{ start: number; end: number }> = []; - for (const layerCall of layerCalls) { - if (!resultEscapesToModuleLevel(layerCall.node)) continue; - for (const argument of layerCall.args) { - blessedArgumentRanges.push({ end: argument.end, start: argument.start }); - const value = unwrap(argument); - if (value.type === 'Identifier' && resolvesToModuleFunction(value)) { - layerConsumed.add((value as unknown as { name: string }).name); - } - } - } - for (const call of identifierCalls) { - if (!blessedArgumentRanges.some((range) => withinRange(call.node, range))) continue; - if (!resolvesToModuleFunction(call.identifier)) continue; - layerConsumed.add(call.name); - } - - for (const site of constructorSites) { - constructorSpans.add(`${site.node.start}:${site.node.end}`); - if (!hasFunctionAncestor(site.node)) continue; - const owner = outermostModuleFunctionName(site.node); - const blessed = - isInsideBlessedLayerConstruction(site.node) || - (owner !== null && layerConsumed.has(owner)); - // A blessed construction still marks its function as a client factory: calling it from an - // operation elsewhere in the file is a fresh client per call. - if (owner !== null) factories.add(owner); - if (blessed) continue; - reports.push({ - callee: site.text, - messageId: site.kind === 'accessor' ? 'accessorPerOperation' : 'clientPerOperation', - node: site.node, - }); - } + const layerConsumed = collectLayerConsumed(); + collectConstructorReports(factories, reports, constructorSpans, layerConsumed); const candidates = identifierCalls.filter( (call) => @@ -818,17 +786,7 @@ export const rule = defineRule({ resolvesToModuleFunction(call.identifier), ); - let changed = true; - while (changed) { - changed = false; - for (const call of candidates) { - if (!factories.has(call.name)) continue; - const promoted = returnedFactoryName(call.node); - if (promoted === null || promoted === call.name || factories.has(promoted)) continue; - factories.add(promoted); - changed = true; - } - } + closeFactories(candidates, factories); for (const call of candidates) { if (!factories.has(call.name)) continue; diff --git a/app/tools/oxlint/effect-native/rules/no-per-request-key-material.ts b/app/tools/oxlint/effect-native/rules/no-per-request-key-material.ts index 089178233..0d96f3491 100644 --- a/app/tools/oxlint/effect-native/rules/no-per-request-key-material.ts +++ b/app/tools/oxlint/effect-native/rules/no-per-request-key-material.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * Audit findings: **A1** — "Establish one process-level Layer and ManagedRuntime composition model" * ("Move Bearer/JWK verification and imported key material into long-lived services rather than @@ -84,17 +85,15 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree, Variable } from '@oxlint/plugins'; import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; - -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production defaults instead of forcing the - * fixture config to pass loosened options (which `run-on-repo.mts` reuses verbatim). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; +import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; +import { stringArray } from '../shared/options.ts'; +import { memberName, keyName } from '../shared/ast.ts'; +import { lookupVariable, resolvesToImport } from '../shared/bindings.ts'; +import { importedName, collectNamespaceLocals } from '../shared/imports.ts'; +import { isNonReferencePosition } from '../shared/reference-positions.ts'; const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; const DEFAULT_IGNORE: readonly string[] = []; @@ -145,7 +144,6 @@ const DEFAULT_GENERATOR_FILES = [ '**/templates/**', ]; -const EFFECT_ROOT_MODULE = 'effect'; const LAYER_NAMESPACE = 'Layer'; const EFFECT_NAMESPACE = 'Effect'; @@ -155,11 +153,6 @@ const FUNCTION_TYPES = new Set([ 'ArrowFunctionExpression', ]); -/** Dotted object path of a `…subtle.importKey` chain: the part before `.importKey`. */ -const SUBTLE_ROOT = /(?:^|\.)(?:crypto|webcrypto)\.subtle$/u; -/** Dotted path of a WebCrypto root object (`crypto`, `globalThis.crypto`, `webcrypto`). */ -const CRYPTO_ROOT = /(?:^|\.)(?:crypto|webcrypto)$/u; - /** Lexical markers that prove an emitted snippet already builds the key once. */ const TEMPLATE_WRAPPER_MARKER = /\b(?:Layer\.(?:effect|scoped|sync|unwrap|unwrapScoped|succeed)|Effect\.(?:cached|cachedWithTTL|cachedFunction|cachedInvalidateWithTTL|once))\s*\(/u; @@ -181,18 +174,8 @@ interface RuleOptions { readonly generatorFiles: readonly string[]; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { include: stringArray(record.include, DEFAULT_INCLUDE), ignore: stringArray(record.ignore, DEFAULT_IGNORE), @@ -211,42 +194,9 @@ function readOptions(context: Context): RuleOptions { }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -function importedName(specifier: ESTree.ImportSpecifier): string { - return specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; -} - -/** Non-computed `.member`, or computed `["member"]`. */ -function memberName(node: ESTree.MemberExpression): string | null { - if (node.type !== 'MemberExpression') return null; - if (!node.computed) return node.property.type === 'Identifier' ? node.property.name : null; - const property = node.property; - if (property.type === 'Literal' && typeof property.value === 'string') return property.value; - return null; -} - /** Static key of an object-pattern / object-literal property (`{ subtle }`, `{ "importKey": k }`). */ function propertyKeyName(property: ESTree.Node): string | null { - if (property.type !== 'Property') return null; - if (property.computed) { - return property.key.type === 'Literal' && typeof property.key.value === 'string' - ? property.key.value - : null; - } - if (property.key.type === 'Identifier') return property.key.name; - if (property.key.type === 'Literal' && typeof property.key.value === 'string') - return property.key.value; - return null; + return property.type === 'Property' ? keyName(property.key, property.computed) : null; } /** `globalThis.crypto.subtle` → `"globalThis.crypto.subtle"`; `null` for any dynamic segment. */ @@ -259,33 +209,6 @@ function dottedPath(node: ESTree.Node): string | null { return object === null ? null : `${object}.${property}`; } -function lookupVariable( - context: Context, - identifier: Extract, -): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(identifier); - while (scope !== null) { - const variable = scope.set.get(identifier.name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; -} - -/** - * `true` when the identifier still resolves to an `import` binding, or to nothing at all (a global - * such as `crypto`). Only a local shadow — parameter, `const`, catch clause, class name — rejects. - */ -function resolvesToImport( - context: Context, - identifier: Extract, -): boolean { - const variable = lookupVariable(context, identifier); - if (variable === null) return true; - if (variable.defs.length === 0) return true; - return variable.defs.some((definition) => definition.type === 'ImportBinding'); -} - interface KeyBindings { /** `import { importJWK } from "jose"` / `import { createSecretKey } from "node:crypto"`. */ readonly direct: Map; @@ -293,6 +216,25 @@ interface KeyBindings { readonly namespaces: Map; } +function recordKeyImport( + specifier: ESTree.ImportDeclaration['specifiers'][number], + members: readonly string[], + direct: Map, + namespaces: Map, +): void { + if (specifier.type === 'ImportSpecifier') { + if (specifier.importKind === 'type') return; + const imported = importedName(specifier); + if (members.includes(imported)) direct.set(specifier.local.name, imported); + } else if ( + specifier.type === 'ImportNamespaceSpecifier' || + specifier.type === 'ImportDefaultSpecifier' + ) { + const existing = namespaces.get(specifier.local.name) ?? []; + namespaces.set(specifier.local.name, [...existing, ...members]); + } +} + function collectKeyBindings(program: ESTree.Program, options: RuleOptions): KeyBindings { const direct = new Map(); const namespaces = new Map(); @@ -304,19 +246,8 @@ function collectKeyBindings(program: ESTree.Program, options: RuleOptions): KeyB const isNodeCrypto = matchesGlobs(source, options.nodeCryptoModules); if (!isJose && !isNodeCrypto) continue; const members = isJose ? options.joseMembers : options.nodeCryptoMembers; - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportSpecifier') { - if (specifier.importKind === 'type') continue; - const imported = importedName(specifier); - if (members.includes(imported)) direct.set(specifier.local.name, imported); - } else if ( - specifier.type === 'ImportNamespaceSpecifier' || - specifier.type === 'ImportDefaultSpecifier' - ) { - const existing = namespaces.get(specifier.local.name) ?? []; - namespaces.set(specifier.local.name, [...existing, ...members]); - } - } + for (const specifier of statement.specifiers) + recordKeyImport(specifier, members, direct, namespaces); } return { direct, namespaces }; } @@ -352,26 +283,18 @@ function collectWrapperLocals( bindings: EffectBindings, options: RuleOptions, ): { layer: ReadonlySet; effect: ReadonlySet; barrel: ReadonlySet } { + const { namespaced, barrel } = collectNamespaceLocals( + program, + bindings, + new Set([LAYER_NAMESPACE, EFFECT_NAMESPACE]), + options.reexportModules, + ); const layer = new Set(); const effect = new Set(); - const barrel = new Set(); - for (const [local, namespace] of bindings.namespaces) { + for (const [local, namespace] of namespaced) { if (namespace === LAYER_NAMESPACE) layer.add(local); else if (namespace === EFFECT_NAMESPACE) effect.add(local); } - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - const source = statement.source.value; - if (source !== EFFECT_ROOT_MODULE && !matchesGlobs(source, options.reexportModules)) continue; - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportNamespaceSpecifier') barrel.add(specifier.local.name); - else if (specifier.type === 'ImportSpecifier') { - const imported = importedName(specifier); - if (imported === LAYER_NAMESPACE) layer.add(specifier.local.name); - else if (imported === EFFECT_NAMESPACE) effect.add(specifier.local.name); - } - } - } return { layer, effect, barrel }; } @@ -489,12 +412,21 @@ export const rule = defineRule({ if (member === null) return false; const object = callee.object; - if (object.type === 'Identifier') { - if (wrappers.layer.has(object.name) && layerMembers.includes(member)) return true; - if (wrappers.effect.has(object.name) && options.effectWrappers.includes(member)) - return true; - return false; - } + return isWrapperMember(object, member, layerMembers); + }; + const isDirectWrapper = ( + name: string, + member: string, + layerMembers: readonly string[], + ): boolean => + (wrappers.layer.has(name) && layerMembers.includes(member)) || + (wrappers.effect.has(name) && options.effectWrappers.includes(member)); + const isWrapperMember = ( + object: ESTree.Node, + member: string, + layerMembers: readonly string[], + ): boolean => { + if (object.type === 'Identifier') return isDirectWrapper(object.name, member, layerMembers); // `E.Layer.effect(…)` through `import * as E from "effect"`. if (object.type !== 'MemberExpression') return false; const namespace = memberName(object); @@ -515,21 +447,26 @@ export const rule = defineRule({ * The `crypto` segment of a `<…>.subtle.` chain must be a global or an import; a * parameter, local `const` or DI port named `crypto` is not WebCrypto. */ + const isImportedCryptoIdentifier = ( + node: Extract, + ): boolean => { + const variable = lookupVariable(context, node); + if (variable === null || variable.defs.length === 0) return node.name === 'crypto'; + return variable.defs.some((definition) => { + if (definition.type !== 'ImportBinding') return false; + const specifier = definition.node; + const declaration = definition.parent; + return ( + declaration?.type === 'ImportDeclaration' && + matchesGlobs(declaration.source.value, options.nodeCryptoModules) && + specifier.type === 'ImportSpecifier' && + importedName(specifier) === 'webcrypto' + ); + }); + }; const isCryptoObject = (node: ESTree.Node): boolean => { if (node.type === 'Identifier') { - const variable = lookupVariable(context, node); - if (variable === null || variable.defs.length === 0) return node.name === 'crypto'; - return variable.defs.some((definition) => { - if (definition.type !== 'ImportBinding') return false; - const specifier = definition.node; - const declaration = definition.parent; - return ( - declaration?.type === 'ImportDeclaration' && - matchesGlobs(declaration.source.value, options.nodeCryptoModules) && - specifier.type === 'ImportSpecifier' && - importedName(specifier) === 'webcrypto' - ); - }); + return isImportedCryptoIdentifier(node); } if (node.type !== 'MemberExpression') return false; const member = memberName(node); @@ -589,22 +526,20 @@ export const rule = defineRule({ * `require("jose")`, `await import("jose")`, `import("node:crypto")` — the CommonJS/dynamic * spellings of the same import, which a static `ImportDeclaration` scan would miss entirely. */ - const dynamicKeyModuleMembers = (node: ESTree.Node): readonly string[] | null => { + const literalModuleSource = (node: ESTree.Node | undefined): string | null => + node?.type === 'Literal' && typeof node.value === 'string' ? node.value : null; + const dynamicModuleSource = (node: ESTree.Node): string | null => { let current: ESTree.Node = node; if (current.type === 'AwaitExpression') current = current.argument; - let source: string | null = null; - if (current.type === 'ImportExpression') { - const specifier = current.source; - if (specifier.type === 'Literal' && typeof specifier.value === 'string') - source = specifier.value; - } else if (current.type === 'CallExpression' && current.callee.type === 'Identifier') { - if (current.callee.name !== 'require') return null; - const requireVariable = lookupVariable(context, current.callee); - if (requireVariable !== null && requireVariable.defs.length > 0) return null; - const first = current.arguments[0]; - if (first !== undefined && first.type === 'Literal' && typeof first.value === 'string') - source = first.value; - } + if (current.type === 'ImportExpression') return literalModuleSource(current.source); + if (current.type !== 'CallExpression' || current.callee.type !== 'Identifier') return null; + if (current.callee.name !== 'require') return null; + const variable = lookupVariable(context, current.callee); + if (variable !== null && variable.defs.length > 0) return null; + return literalModuleSource(current.arguments[0]); + }; + const dynamicKeyModuleMembers = (node: ESTree.Node): readonly string[] | null => { + const source = dynamicModuleSource(node); if (source === null) return null; if (matchesGlobs(source, options.joseModules)) return options.joseMembers; if (matchesGlobs(source, options.nodeCryptoModules)) return options.nodeCryptoMembers; @@ -612,17 +547,11 @@ export const rule = defineRule({ }; /** Display name when `node` denotes key-material construction, otherwise `null`. */ - const resolveKeyReference = (node: ESTree.Node): string | null => { - if (node.type === 'Identifier') { - if (keys.direct.has(node.name) && resolvesToImport(context, node)) return node.name; - if (bindsTo(node, aliasVariables)) return node.name; - return null; - } - if (node.type !== 'MemberExpression') return null; - const member = memberName(node); - if (member === null) return null; - const objectPath = dottedPath(node.object); - + const resolveSubtleReference = ( + node: ESTree.MemberExpression, + member: string, + objectPath: string | null, + ): string | null => { if (options.subtleMembers.includes(member)) { // `subtle.importKey` where `subtle` was destructured/aliased off WebCrypto. if (node.object.type === 'Identifier' && bindsTo(node.object, subtleVariables)) { @@ -633,7 +562,28 @@ export const rule = defineRule({ return `${objectPath}.${member}`; } } + return null; + }; + const resolveKeyReference = (node: ESTree.Node): string | null => { + if (node.type === 'Identifier') { + if (keys.direct.has(node.name) && resolvesToImport(context, node)) return node.name; + if (bindsTo(node, aliasVariables)) return node.name; + return null; + } + if (node.type !== 'MemberExpression') return null; + const member = memberName(node); + if (member === null) return null; + const objectPath = dottedPath(node.object); + const subtle = resolveSubtleReference(node, member, objectPath); + if (subtle !== null) return subtle; + return resolveNamespaceReference(node, member, objectPath); + }; + const resolveNamespaceReference = ( + node: ESTree.MemberExpression, + member: string, + objectPath: string | null, + ): string | null => { if (objectPath === null) return null; if (node.object.type !== 'Identifier') return null; const dynamic = dynamicMembers(node.object); @@ -676,6 +626,13 @@ export const rule = defineRule({ const candidates: Candidate[] = []; /** Report unless the site is module scope, lexically inside a wrapper, or one hop from one. */ + const enclosingBindingName = (node: ESTree.Node): string | null => { + if (node.type === 'FunctionDeclaration') return node.id?.name ?? null; + if (node.type === 'VariableDeclarator' && node.id.type === 'Identifier') return node.id.name; + return null; + }; + const isReferencedBuilder = (name: string | null): boolean => + name !== null && moduleNames.has(name) && builderReferenced.has(name); const evaluate = (candidate: Candidate): void => { const callee = resolveKeyReference(candidate.target); if (callee === null) return; @@ -685,26 +642,13 @@ export const rule = defineRule({ while (current !== null && current !== undefined) { if (isEnclosingWrapper(current)) return; if (FUNCTION_TYPES.has(current.type)) sawFunction = true; - if ( - current.type === 'FunctionDeclaration' && - current.id !== null && - current.id !== undefined - ) { - outermostName = current.id.name; - } else if (current.type === 'VariableDeclarator' && current.id.type === 'Identifier') { - outermostName = current.id.name; - } + outermostName = enclosingBindingName(current) ?? outermostName; current = current.parent; } if (!sawFunction) return; // The repo's `Context.Service` idiom: the build effect lives in a named module-level factory // that is handed to `Layer.effect`/`Effect.cached*` by reference. Built once per Layer build. - if ( - outermostName !== null && - moduleNames.has(outermostName) && - builderReferenced.has(outermostName) - ) - return; + if (isReferencedBuilder(outermostName)) return; context.report({ node: candidate.report, messageId: candidate.messageId, data: { callee } }); }; @@ -716,35 +660,94 @@ export const rule = defineRule({ return false; }; - /** Identifier positions that are declarations, keys or types rather than value references. */ - const isNonReferencePosition = ( - node: Extract, - ): boolean => { - const parent = node.parent; - if (parent === null || parent === undefined) return true; - switch (parent.type) { - case 'ImportSpecifier': - case 'ImportDefaultSpecifier': - case 'ImportNamespaceSpecifier': - case 'ExportSpecifier': - case 'TSTypeReference': - case 'TSQualifiedName': - case 'TSTypeQuery': - return true; - case 'MemberExpression': - return parent.property === node && !parent.computed; - case 'Property': - case 'PropertyDefinition': - case 'MethodDefinition': - return parent.key === node && !parent.computed; - default: - return false; + const registerIdentifierBinding = ( + id: ESTree.Node, + init: Extract, + ): void => { + const namespaceMembers = keys.namespaces.get(init.name); + const isTrackedNamespace = namespaceMembers !== undefined && resolvesToImport(context, init); + const isTrackedDirect = + (keys.direct.has(init.name) && resolvesToImport(context, init)) || + bindsTo(init, aliasVariables); + const isTrackedSubtle = bindsTo(init, subtleVariables) || isCryptoObject(init); + + if (isTrackedDirect && id.type === 'Identifier') { + registerAlias(id); + bindingNodes.add(init); + return; + } + if (isTrackedNamespace) { + eachPatternProperty(id, (key, value) => { + if (namespaceMembers.includes(key)) registerAlias(value); + }); + return; + } + if (isTrackedSubtle) { + eachPatternProperty(id, (key, value) => { + if (key === 'subtle' && isCryptoObject(init)) registerSubtle(value); + else if (options.subtleMembers.includes(key)) registerAlias(value); + }); + } + return; + }; + const cryptoBindingRootAllowed = (node: ESTree.Node): boolean => + node.type !== 'Identifier' || resolvesToImport(context, node); + const registerMemberBinding = (id: ESTree.Node, init: ESTree.MemberExpression): void => { + const member = memberName(init); + const initPath = dottedPath(init); + + // `const subtle = crypto.subtle` / `const { importKey } = globalThis.crypto.subtle`. + if (initPath !== null && cryptoRootIsAmbient(init)) { + if (id.type === 'Identifier') registerSubtle(id); + else { + eachPatternProperty(id, (key, value) => { + if (options.subtleMembers.includes(key)) registerAlias(value); + }); + } + return; + } + // `const { subtle } = globalThis.crypto`. + if (isCryptoObject(init) && id.type === 'ObjectPattern') { + const rootOk = cryptoBindingRootAllowed(init.object); + if (rootOk) { + eachPatternProperty(id, (key, value) => { + if (key === 'subtle') registerSubtle(value); + }); + } + return; + } + // `const load = jose.importJWK`. + if (member !== null && id.type === 'Identifier' && resolveKeyReference(init) !== null) { + registerAlias(id); } }; - const templateElements: Array<{ start: number; end: number }> = []; const templateLiterals: Array<{ start: number; end: number }> = []; + const reportTemplateMatch = (match: RegExpExecArray, text: string, seen: Set): void => { + const member = match[1] ?? ''; + const index = match.index + match[0].lastIndexOf(member); + const inTemplate = templateElements.some( + (entry) => index >= entry.start && index < entry.end, + ); + if (inTemplate && !seen.has(index)) { + // Scan back to the start of the *whole* template literal, not a character window, so a + // realistically sized emitted `Layer.effect(…)` body still counts as already fixed. + const literal = templateLiterals + .filter((entry) => index >= entry.start && index < entry.end) + .sort((a, b) => b.start - a.start)[0]; + const from = literal === undefined ? 0 : literal.start; + if (!TEMPLATE_WRAPPER_MARKER.test(text.slice(from, index))) { + seen.add(index); + context.report({ + node: { range: [index, index + member.length] }, + messageId: 'generatedPerRequest', + data: { callee: member }, + }); + } + } + }; + return { /** * One-hop rebindings. Visited before the references they enable (declaration precedes use), @@ -768,68 +771,8 @@ export const rule = defineRule({ return; } - if (init.type === 'Identifier') { - const namespaceMembers = keys.namespaces.get(init.name); - const isTrackedNamespace = - namespaceMembers !== undefined && resolvesToImport(context, init); - const isTrackedDirect = - (keys.direct.has(init.name) && resolvesToImport(context, init)) || - bindsTo(init, aliasVariables); - const isTrackedSubtle = bindsTo(init, subtleVariables) || isCryptoObject(init); - - if (isTrackedDirect && node.id.type === 'Identifier') { - registerAlias(node.id); - bindingNodes.add(init); - return; - } - if (isTrackedNamespace) { - eachPatternProperty(node.id, (key, value) => { - if (namespaceMembers.includes(key)) registerAlias(value); - }); - return; - } - if (isTrackedSubtle) { - eachPatternProperty(node.id, (key, value) => { - if (key === 'subtle' && isCryptoObject(init)) registerSubtle(value); - else if (options.subtleMembers.includes(key)) registerAlias(value); - }); - } - return; - } - - if (init.type !== 'MemberExpression') return; - const member = memberName(init); - const initPath = dottedPath(init); - - // `const subtle = crypto.subtle` / `const { importKey } = globalThis.crypto.subtle`. - if (initPath !== null && cryptoRootIsAmbient(init)) { - if (node.id.type === 'Identifier') registerSubtle(node.id); - else { - eachPatternProperty(node.id, (key, value) => { - if (options.subtleMembers.includes(key)) registerAlias(value); - }); - } - return; - } - // `const { subtle } = globalThis.crypto`. - if (isCryptoObject(init) && node.id.type === 'ObjectPattern') { - const rootOk = - init.object.type !== 'Identifier' || resolvesToImport(context, init.object); - if (rootOk) { - eachPatternProperty(node.id, (key, value) => { - if (key === 'subtle') registerSubtle(value); - }); - } - return; - } - // `const load = jose.importJWK`. - if ( - member !== null && - node.id.type === 'Identifier' && - resolveKeyReference(init) !== null - ) { - registerAlias(node.id); - } + if (init.type === 'Identifier') registerIdentifierBinding(node.id, init); + else if (init.type === 'MemberExpression') registerMemberBinding(node.id, init); }, CallExpression(node) { const callee = node.callee; @@ -858,7 +801,12 @@ export const rule = defineRule({ } } if (isCalleePosition(node)) return; - if (isNonReferencePosition(node)) return; + if ( + isNonReferencePosition(node, { + nonReferenceParents: new Set(['TSTypeReference', 'TSQualifiedName', 'TSTypeQuery']), + }) + ) + return; if (!watched.has(node.name)) return; candidates.push({ report: node, target: node, messageId: 'perRequestReference' }); }, @@ -912,27 +860,7 @@ export const rule = defineRule({ for (const pattern of patterns) { let match = pattern.exec(text); while (match !== null) { - const member = match[1] ?? ''; - const index = match.index + match[0].lastIndexOf(member); - const inTemplate = templateElements.some( - (entry) => index >= entry.start && index < entry.end, - ); - if (inTemplate && !seen.has(index)) { - // Scan back to the start of the *whole* template literal, not a character window, so a - // realistically sized emitted `Layer.effect(…)` body still counts as already fixed. - const literal = templateLiterals - .filter((entry) => index >= entry.start && index < entry.end) - .sort((a, b) => b.start - a.start)[0]; - const from = literal === undefined ? 0 : literal.start; - if (!TEMPLATE_WRAPPER_MARKER.test(text.slice(from, index))) { - seen.add(index); - context.report({ - node: { range: [index, index + member.length] }, - messageId: 'generatedPerRequest', - data: { callee: member }, - }); - } - } + reportTemplateMatch(match, text, seen); match = pattern.exec(text); } } diff --git a/app/tools/oxlint/effect-native/rules/no-process-exit-outside-script-entry.ts b/app/tools/oxlint/effect-native/rules/no-process-exit-outside-script-entry.ts index cc5213f93..f9bf6feb2 100644 --- a/app/tools/oxlint/effect-native/rules/no-process-exit-outside-script-entry.ts +++ b/app/tools/oxlint/effect-native/rules/no-process-exit-outside-script-entry.ts @@ -74,28 +74,21 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; - +import type { Context, ESTree } from '@oxlint/plugins'; import { - globToRegExp, - isScriptFile, - isTestFile, - matchesAny, - normalisePath, -} from '../shared/paths.ts'; - -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets the fixtures exercise the real production defaults instead of forcing - * the fixture config to pass loosened options (which `run-on-repo.mts` reuses verbatim). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; - -/** `import process from "node:process"` / `"process"`. */ -const PROCESS_MODULE = /^(?:node:)?process$/u; - -/** Global objects that expose `process` as a property. */ -const GLOBAL_OBJECT_NAMES = new Set(['globalThis', 'global']); + parentOf, + skipWrappers, + syntax, + propertyText, + unwrapNode as skipTransparent, +} from '../shared/ast.ts'; +import { provenance, valueReference } from '../shared/provenance.ts'; +import { + isEntryPosition as isBasicEntryPosition, + nearestFunction, +} from '../shared/script-entry.ts'; +import { scriptScope, inScriptScope, matchesGlobs } from '../shared/paths.ts'; +import { stringList, positiveInteger, booleanOption } from '../shared/options.ts'; /** Emitter registration methods whose callback argument is a signal/exit handler. */ const LISTENER_METHODS = new Set([ @@ -106,24 +99,6 @@ const LISTENER_METHODS = new Set([ 'prependOnceListener', ]); -/** Wrappers that do not change "is this expression the callee / object / target of its parent". */ -const TRANSPARENT_PARENTS = new Set([ - 'ParenthesizedExpression', - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSInstantiationExpression', - 'TSTypeAssertion', - 'ChainExpression', -]); - -const FUNCTION_LIKE = new Set([ - 'ArrowFunctionExpression', - 'FunctionDeclaration', - 'FunctionExpression', - 'StaticBlock', -]); - type AnyNode = ESTree.Node; interface RuleOptions { @@ -140,214 +115,39 @@ const DEFAULTS: RuleOptions = { function readOptions(raw: unknown): RuleOptions { const given = (raw ?? {}) as Partial>; - const globs = - Array.isArray(given.allowPaths) && given.allowPaths.every((entry) => typeof entry === 'string') - ? (given.allowPaths as readonly string[]) - : DEFAULTS.allowPaths; return { - allowPaths: globs, - maxExitSites: - typeof given.maxExitSites === 'number' && - Number.isInteger(given.maxExitSites) && - given.maxExitSites >= 0 - ? given.maxExitSites - : DEFAULTS.maxExitSites, - includeExitCode: - typeof given.includeExitCode === 'boolean' ? given.includeExitCode : DEFAULTS.includeExitCode, + allowPaths: stringList(given.allowPaths, DEFAULTS.allowPaths), + maxExitSites: positiveInteger(given.maxExitSites, DEFAULTS.maxExitSites, 0), + includeExitCode: booleanOption(given.includeExitCode, DEFAULTS.includeExitCode), }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real script paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function parentOf(node: AnyNode): AnyNode | null { - return (node as { parent?: AnyNode | null }).parent ?? null; -} - -/** Climb through parentheses/type/chain wrappers; returns the outermost equivalent node and its parent. */ -function skipWrappers(node: AnyNode): { readonly node: AnyNode; readonly parent: AnyNode | null } { - let current = node; - let parent = parentOf(current); - while (parent !== null && TRANSPARENT_PARENTS.has(parent.type)) { - current = parent; - parent = parentOf(current); - } - return { node: current, parent }; -} - -function nearestFunction(node: AnyNode): AnyNode | null { - let current = parentOf(node); - while (current !== null) { - if (FUNCTION_LIKE.has(current.type)) return current; - current = parentOf(current); - } - return null; -} - -/** `true` when the node is evaluated during module evaluation, not inside any function body. */ -function isTopLevel(node: AnyNode): boolean { - return nearestFunction(node) === null; -} - -/** A declaration/statement directly in `Program`, optionally behind `export` / `export default`. */ -function isProgramLevelStatement(node: AnyNode): boolean { - const parent = parentOf(node); - if (parent === null) return false; - if (parent.type === 'Program') return true; - if (parent.type !== 'ExportNamedDeclaration' && parent.type !== 'ExportDefaultDeclaration') - return false; - return parentOf(parent)?.type === 'Program'; -} - -/** Name of a Program-level `function main() {}` / `const main = () => {}`, else `null`. */ -function programLevelFunctionName(fn: AnyNode): string | null { - if (fn.type === 'FunctionDeclaration') { - if (!isProgramLevelStatement(fn)) return null; - const id = (fn as ESTree.Function).id; - return id === null || id === undefined ? null : id.name; - } - if (fn.type !== 'FunctionExpression' && fn.type !== 'ArrowFunctionExpression') return null; - const declarator = parentOf(fn); - if (declarator === null || declarator.type !== 'VariableDeclarator') return null; - if ((declarator as ESTree.VariableDeclarator).init !== fn) return null; - const id = (declarator as ESTree.VariableDeclarator).id; - if (id.type !== 'Identifier') return null; - const declaration = parentOf(declarator); - if (declaration === null || declaration.type !== 'VariableDeclaration') return null; - return isProgramLevelStatement(declaration) ? id.name : null; -} - -/** `void (async () => { ... })()` / `(function () { ... })()` evaluated during module evaluation. */ -function isTopLevelImmediatelyInvoked(fn: AnyNode): boolean { - const { node, parent } = skipWrappers(fn); - if (parent === null || parent.type !== 'CallExpression') return false; - if ((parent as ESTree.CallExpression).callee !== node) return false; - return isTopLevel(parent); -} - -function resolveVariable(context: Context, name: string, from: AnyNode): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(from); - while (scope !== null) { - const variable = scope.set.get(name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; -} - -/** Every use of `main` is a call made during module evaluation (top level or `import.meta.url` guard). */ -function isOnlyCalledFromTopLevel(context: Context, fn: AnyNode, name: string): boolean { - const variable = resolveVariable(context, name, fn); - if (variable === null) return false; - const bindingOffsets = new Set(variable.identifiers.map((identifier) => identifier.start)); - const uses = variable.references.filter( - (reference) => reference.init !== true && !bindingOffsets.has(reference.identifier.start), - ); - if (uses.length === 0) return false; - return uses.every((reference) => { - const { node, parent } = skipWrappers(reference.identifier as unknown as AnyNode); - if (parent === null || parent.type !== 'CallExpression') return false; - if ((parent as ESTree.CallExpression).callee !== node) return false; - return isTopLevel(parent); - }); +/** Effect-run continuations retain the executable position of their originating call. */ +function entryContinuation(context: Context, fn: AnyNode): ESTree.CallExpression | null { + const outer = skipWrappers(fn); + if (outer.parent?.type !== 'CallExpression') return null; + const call = outer.parent; + const callee = syntax(call.callee); + if (!call.arguments.includes(outer.node as never)) return null; + if (callee?.type !== 'MemberExpression') return null; + if (!['then', 'catch', 'finally'].includes(propertyText(callee) ?? '')) return null; + return isEffectRunChain(context, callee.object) ? call : null; } -/** - * The executable edge of a script: module-evaluation code, a top-level IIFE, or a Program-level - * `main` that is only ever invoked from module-evaluation code. - */ function isEntryPosition(context: Context, site: AnyNode): boolean { const fn = nearestFunction(site); - if (fn === null) return true; - const outer = skipWrappers(fn); - if (outer.parent?.type === 'CallExpression') { - const call = outer.parent as ESTree.CallExpression; - const callee = syntax(call.callee); - if ( - call.arguments.includes(outer.node as never) && - callee?.type === 'MemberExpression' && - ['then', 'catch', 'finally'].includes(propertyText(callee) ?? '') && - isEffectRunChain(context, callee.object) - ) - return isEntryPosition(context, call); - } - if (nearestFunction(fn) !== null) return false; - if (isTopLevelImmediatelyInvoked(fn)) return true; - const name = programLevelFunctionName(fn); - if (name === null) return false; - return isOnlyCalledFromTopLevel(context, fn, name); -} - -function staticPropertyName(node: ESTree.MemberExpression): string | null { - const property = syntax(node.property) as AnyNode; - if (!node.computed) - return property.type === 'Identifier' ? (property as ESTree.IdentifierName).name : null; - if (property.type === 'TemplateLiteral') return literalText(property); - if (property.type !== 'Literal') return null; - const value = (property as { value?: unknown }).value; - return typeof value === 'string' ? value : null; -} - -/** `true` when the identifier resolves to a global (unresolved, or only implicitly declared). */ -function isGlobalIdentifier(context: Context, node: AnyNode, name: string): boolean { - const variable = resolveVariable(context, name, node); - if (variable === null) return true; - return ( - variable.defs.length === 0 || - variable.defs.every((definition) => definition.type === 'ImplicitGlobalVariable') - ); + const continuation = fn === null ? null : entryContinuation(context, fn); + return continuation === null + ? isBasicEntryPosition(context, site) + : isEntryPosition(context, continuation); } -interface ProcessBindings { - /** Locals bound to the whole `node:process` module (`import process from "node:process"`). */ - readonly objectLocals: ReadonlySet; - /** Locals bound to the named export `exit`. */ - readonly exitLocals: ReadonlySet; - /** Locals bound to the named export `exitCode`. */ - readonly exitCodeLocals: ReadonlySet; -} - -function collectProcessBindings(program: ESTree.Program): ProcessBindings { - const objectLocals = new Set(); - const exitLocals = new Set(); - const exitCodeLocals = new Set(); - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - if (!PROCESS_MODULE.test(statement.source.value)) continue; - for (const specifier of statement.specifiers) { - if ( - specifier.type === 'ImportDefaultSpecifier' || - specifier.type === 'ImportNamespaceSpecifier' - ) { - objectLocals.add(specifier.local.name); - continue; - } - if (specifier.type !== 'ImportSpecifier') continue; - const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : String(specifier.imported.value); - if (imported === 'default') objectLocals.add(specifier.local.name); - else if (imported === 'exit') exitLocals.add(specifier.local.name); - else if (imported === 'exitCode') exitCodeLocals.add(specifier.local.name); - } - } - return { objectLocals, exitLocals, exitCodeLocals }; -} - -/** `process`, `globalThis.process`, `global.process`, or a `node:process` default/namespace import. */ -function processObjectText( - context: Context, - node: AnyNode, - _bindings: ProcessBindings, -): string | null { +function processObjectText(context: Context, node: AnyNode): string | null { return provenance(context, node) === 'process' ? 'process' : null; } +const EXIT_PROPERTIES = new Set(['exit', 'exitCode', 'kill']); type SiteKind = 'exit' | 'exitCode' | 'kill'; - interface ExitSite { readonly node: AnyNode; readonly kind: SiteKind; @@ -356,11 +156,6 @@ interface ExitSite { readonly end: number; } -function spanOf(node: AnyNode): { readonly start: number; readonly end: number } { - const span = node as unknown as ESTree.Span; - return { start: span.start, end: span.end }; -} - /** `process.exitCode = 1`, `process.exitCode ||= 2`, `process.exitCode++` — a write, never a read. */ function writeOperator(node: AnyNode): string | null { const { node: target, parent } = skipWrappers(node); @@ -377,66 +172,36 @@ function writeOperator(node: AnyNode): string | null { } /** `process.kill(process.pid, …)`: the first argument must be this process' own pid. */ -function isSelfKill( - call: ESTree.CallExpression, - context: Context, - bindings: ProcessBindings, -): boolean { +function isSelfKill(call: ESTree.CallExpression, context: Context): boolean { const first = (call.arguments as readonly AnyNode[])[0]; if (first === undefined) return false; const argument = skipTransparent(first); if (argument.type !== 'MemberExpression') return false; const member = argument as ESTree.MemberExpression; - if (staticPropertyName(member) !== 'pid') return false; - return processObjectText(context, skipTransparent(member.object as AnyNode), bindings) !== null; + if (propertyText(member) !== 'pid') return false; + return processObjectText(context, skipTransparent(member.object as AnyNode)) !== null; } -/** Strip `(...)`, `as`, `satisfies`, `!`, `` and optional-chaining wrappers from an expression. */ -function skipTransparent(node: AnyNode): AnyNode { - let current = node; - while (TRANSPARENT_PARENTS.has(current.type)) { - const inner = (current as { expression?: AnyNode }).expression; - if (inner === undefined || inner === null) return current; - current = inner; - } - return current; +function listenerEvent(context: Context, call: ESTree.CallExpression) { + const callee = skipTransparent(call.callee); + if (callee.type !== 'MemberExpression') return null; + const method = propertyText(callee); + if (method === null || !LISTENER_METHODS.has(method)) return null; + if (processObjectText(context, skipTransparent(callee.object)) === null) return null; + const first = call.arguments[0]; + const event = + first?.type === 'Literal' && typeof first.value === 'string' ? first.value : 'signal'; + return { method, event }; } -/** - * `process.on("SIGTERM", …)` / `.once(…)` / `.addListener(…)` containing this site — the signal - * handler seam, where exiting pre-empts the fiber instead of interrupting it. - */ -function signalHandlerEvent( - context: Context, - site: AnyNode, - bindings: ProcessBindings, -): { readonly method: string; readonly event: string } | null { - let child: AnyNode = site; +/** Find the enclosing process listener registration, retaining literal-only event labels. */ +function signalHandlerEvent(context: Context, site: AnyNode) { + let child = site; let parent = parentOf(child); while (parent !== null) { - if (parent.type === 'CallExpression') { - const call = parent as ESTree.CallExpression; - if ((call.arguments as readonly AnyNode[]).includes(child)) { - const callee = skipTransparent(call.callee as AnyNode); - if (callee.type === 'MemberExpression') { - const member = callee as ESTree.MemberExpression; - const method = staticPropertyName(member); - if ( - method !== null && - LISTENER_METHODS.has(method) && - processObjectText(context, skipTransparent(member.object as AnyNode), bindings) !== null - ) { - const first = (call.arguments as readonly AnyNode[])[0]; - const event = - first !== undefined && - first.type === 'Literal' && - typeof (first as { value?: unknown }).value === 'string' - ? String((first as { value?: unknown }).value) - : 'signal'; - return { method, event }; - } - } - } + if (parent.type === 'CallExpression' && parent.arguments.includes(child as never)) { + const event = listenerEvent(context, parent); + if (event !== null) return event; } child = parent; parent = parentOf(child); @@ -494,30 +259,30 @@ export const rule = defineRule({ const options = readOptions(context.options[0]); const path = scriptScope(context.filename); if (!inScriptScope(path)) return {}; - if (options.allowPaths.some((glob) => globToRegExp(glob).test(path))) return {}; + if (matchesGlobs(path, options.allowPaths)) return {}; - let bindings: ProcessBindings = { - objectLocals: new Set(), - exitLocals: new Set(), - exitCodeLocals: new Set(), - }; const sites: ExitSite[] = []; const push = (node: AnyNode, kind: SiteKind, site: string): void => { - sites.push({ node, kind, site, ...spanOf(node) }); + sites.push({ node, kind, site, start: node.start, end: node.end }); }; - /** `import { exit, exitCode } from "node:process"`: the local binding really is that import. */ - const isProcessImportBinding = (node: AnyNode, name: string): boolean => { - const variable = resolveVariable(context, name, node); - if (variable === null) return false; - return variable.defs.some((definition) => definition.type === 'ImportBinding'); + const collectSelfKill = (call: ESTree.CallExpression, objectText: string): void => { + if (isSelfKill(call, context)) push(call, 'kill', `${objectText}.kill(${objectText}.pid, …)`); + }; + const collectExitCode = (self: AnyNode, objectText: string): void => { + if (!options.includeExitCode) return; + const operator = writeOperator(self); + if (operator === null) return; + const written = skipWrappers(self).parent as AnyNode; + const text = + operator === '++' || operator === '--' + ? `${objectText}.exitCode${operator}` + : `${objectText}.exitCode ${operator} …`; + push(written, 'exitCode', text); }; return { - Program(node) { - bindings = collectProcessBindings(node); - }, Identifier(node) { const self = node as AnyNode; if (!valueReference(context, node)) return; @@ -534,13 +299,9 @@ export const rule = defineRule({ }, MemberExpression(node) { const member = node as ESTree.MemberExpression; - const property = staticPropertyName(member); - if (property !== 'exit' && property !== 'exitCode' && property !== 'kill') return; - const objectText = processObjectText( - context, - skipTransparent(member.object as AnyNode), - bindings, - ); + const property = propertyText(member); + if (!EXIT_PROPERTIES.has(property)) return; + const objectText = processObjectText(context, skipTransparent(member.object as AnyNode)); if (objectText === null) return; const self = node as unknown as AnyNode; const { node: reference, parent } = skipWrappers(self); @@ -550,31 +311,16 @@ export const rule = defineRule({ (parent as ESTree.CallExpression).callee === reference; if (property === 'exit') { - // A point-free reference (`process.on("exit", process.exit)`) is still an exit site. push( - isCallee ? (parent as AnyNode) : self, + isCallee ? parent : self, 'exit', isCallee ? `${objectText}.exit(…)` : `${objectText}.exit`, ); - return; - } - if (property === 'kill') { - if (!isCallee) return; - if (!isSelfKill(parent as ESTree.CallExpression, context, bindings)) return; - push(parent as AnyNode, 'kill', `${objectText}.kill(${objectText}.pid, …)`); - return; + } else if (property === 'kill') { + if (isCallee) collectSelfKill(parent as ESTree.CallExpression, objectText); + } else { + collectExitCode(self, objectText); } - if (!options.includeExitCode) return; - const operator = writeOperator(self); - if (operator === null) return; - const written = skipWrappers(self).parent as AnyNode; - push( - written, - 'exitCode', - operator === '++' || operator === '--' - ? `${objectText}.exitCode${operator}` - : `${objectText}.exitCode ${operator} …`, - ); }, 'Program:exit'() { if (sites.length === 0) return; @@ -589,7 +335,7 @@ export const rule = defineRule({ ); let allowance = options.maxExitSites; for (const site of outer) { - const handler = signalHandlerEvent(context, site.node, bindings); + const handler = signalHandlerEvent(context, site.node); if (handler !== null) { context.report({ node: site.node, @@ -621,216 +367,6 @@ export const rule = defineRule({ }, }); -/** Bounded, lexical provenance only; no type checker or interprocedural/data-flow inference. */ -type Syntax = ESTree.Node & Record; -function syntax(node: unknown): Syntax | null { - let n = node as Syntax | null; - while ( - n && - [ - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSTypeAssertion', - 'TSInstantiationExpression', - 'ParenthesizedExpression', - 'ChainExpression', - 'AwaitExpression', - ].includes(n.type) - ) - n = n.expression ?? n.argument; - return n; -} -function lexicalVariable(context: Context, node: Syntax): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(node); - while (scope) { - const v = scope.set.get(node.name); - if (v) return v; - scope = scope.upper; - } - return null; -} -function literalText(node: unknown): string | null { - const n = syntax(node); - if (n?.type === 'Literal' && typeof n.value === 'string') return n.value; - if (n?.type === 'TemplateLiteral' && n.expressions.length === 0) - return n.quasis[0]?.value.cooked ?? null; - return null; -} -function propertyText(node: unknown): string | null { - const n = node as Syntax; - const key = syntax(n.property ?? n.key); - return !n.computed && key?.type === 'Identifier' ? key.name : literalText(key); -} -function moduleIdentity(source: string): string { - if (/^(?:node:)?(?:process|console|util|module)$/.test(source)) - return source.replace(/^node:/, ''); - if (source === 'effect/Effect') return 'Effect'; - if (source === 'effect/ManagedRuntime') return 'ManagedRuntime'; - return source; -} -function bindingPath(pattern: Syntax, name: string): string[] | null { - if (pattern.type === 'Identifier') return pattern.name === name ? [] : null; - if (pattern.type === 'AssignmentPattern') return bindingPath(pattern.left, name); - if (pattern.type !== 'ObjectPattern') return null; - for (const p of pattern.properties) { - if (p.type !== 'Property') continue; - const key = propertyText(p), - tail = bindingPath(p.value, name); - if (key !== null && tail !== null) return [key, ...tail]; - } - return null; -} -function provenance(context: Context, node: unknown, seen = new Set()): string | null { - const n = syntax(node); - if (!n) return null; - if (n.type === 'Identifier') { - const v = lexicalVariable(context, n); - if (!v || v.defs.length === 0) - return [ - 'process', - 'console', - 'Bun', - 'globalThis', - 'global', - 'window', - 'self', - 'require', - 'Array', - 'Set', - ].includes(n.name) - ? n.name - : null; - if (seen.has(v) || v.defs.length !== 1) return null; - const next = new Set(seen); - next.add(v); - const def = v.defs[0] as any; - if (def.type === 'ImportBinding') { - const spec = def.node as Syntax; - const decl = (def.parent ?? spec.parent) as Syntax; - if (decl.importKind === 'type' || spec.importKind === 'type') return null; - const source = literalText(decl.source); - if (!source) return null; - const base = moduleIdentity(source); - if (spec.type === 'ImportNamespaceSpecifier' || spec.type === 'ImportDefaultSpecifier') - return base; - const name = spec.imported?.name ?? spec.imported?.value; - if (name === 'default') return base; - if (base === 'effect') return name; - return `${base}.${name}`; - } - if (def.type !== 'Variable' || def.node.type !== 'VariableDeclarator') return null; - // A declaration is not a reaching-definition analysis: reassigned aliases are unknown. - if (v.references.some((r: any) => r.init !== true && r.isWrite())) return null; - const d = def.node as Syntax; - const base = provenance(context, d.init, next), - path = bindingPath(d.id, n.name); - return base !== null && path !== null ? [base, ...path].join('.') : null; - } - if (n.type === 'MemberExpression') { - const base = provenance(context, n.object, seen), - key = propertyText(n); - if (base === null || key === null) return null; - if ( - ['globalThis', 'global', 'window', 'self'].includes(base) && - ['process', 'console', 'Bun'].includes(key) - ) - return key; - if (['process', 'console', 'util', 'module'].includes(base) && key === 'default') return base; - if (base === 'effect') return key; - return `${base}.${key}`; - } - if (n.type === 'ImportExpression') { - const text = literalText(n.source); - return text === null ? null : moduleIdentity(text); - } - if (n.type === 'CallExpression') { - const callee = provenance(context, n.callee, seen); - if (callee === 'require') { - const text = literalText(n.arguments[0]); - return text === null ? null : moduleIdentity(text); - } - if (callee === 'module.createRequire') return 'require'; - if (callee === 'ManagedRuntime.make') return 'Runtime'; - } - return null; -} -/** Only value references, never property names, bindings or TS-only identifiers. */ -function valueReference(context: Context, node: unknown): boolean { - const n = node as Syntax, - p = n.parent as Syntax | undefined; - if (!p) return false; - if (p.type.startsWith('Import') || p.type === 'ExportSpecifier') return false; - if (p.type === 'MemberExpression' && p.property === n && !p.computed) return false; - if ( - [ - 'Property', - 'PropertyDefinition', - 'MethodDefinition', - 'TSPropertySignature', - 'TSMethodSignature', - ].includes(p.type) && - p.key === n && - !p.computed && - !(p.shorthand && p.value === n) - ) - return false; - if (['LabeledStatement', 'BreakStatement', 'ContinueStatement'].includes(p.type)) return false; - let child: Syntax = n; - let parent: Syntax | null = p; - while (parent) { - if ( - parent.type.startsWith('TS') && - !( - [ - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSTypeAssertion', - 'TSInstantiationExpression', - ].includes(parent.type) && parent.expression === child - ) - ) - return false; - if ( - parent.type.endsWith('Statement') || - parent.type.endsWith('Declaration') || - parent.type.includes('Function') - ) - break; - child = parent; - parent = parent.parent as Syntax | null; - } - const v = lexicalVariable(context, n); - return ( - !v || - v.references.some( - (r: any) => - r.identifier === n && - r.isRead() && - (typeof r.isValueReference !== 'function' || r.isValueReference()), - ) - ); -} -/** Strip fixture scaffolding first; do not renormalise a relative script path around inner markers. */ -function scriptScope(filename: string): string { - const unified = filename.replaceAll('\\', '/'); - const fixture = unified.match( - /(?:^|\/)tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\/(.*)$/u, - ); - if (fixture) return fixture[1]; - if (!unified.startsWith('/') && !/^[A-Za-z]:\//u.test(unified)) - return unified.replace(/^\.\//, ''); - const match = unified.match(/(?:^|\/)((?:apps|packages|verticals|scripts|tools)\/.*)$/u); - return match?.[1] ?? unified; -} -function inScriptScope(path: string): boolean { - return ( - /(?:^|\/)scripts\//u.test(path) && - !/(?:^|\/)(?:tests?|__tests__)\/|\.(?:test|spec|test-d|spec-d)\.[cm]?[jt]sx?$/u.test(path) - ); -} - function isEffectRunChain(context: Context, node: unknown): boolean { const n = syntax(node); if (n?.type !== 'CallExpression') return false; diff --git a/app/tools/oxlint/effect-native/rules/no-promise-first-scaffold-templates.ts b/app/tools/oxlint/effect-native/rules/no-promise-first-scaffold-templates.ts index 2106b681c..a222690f5 100644 --- a/app/tools/oxlint/effect-native/rules/no-promise-first-scaffold-templates.ts +++ b/app/tools/oxlint/effect-native/rules/no-promise-first-scaffold-templates.ts @@ -100,17 +100,17 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree } from '@oxlint/plugins'; +import type { ESTree } from '@oxlint/plugins'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; - -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production defaults instead of forcing the - * fixture config to pass loosened options (`run-on-repo.mts` reuses that fixture config verbatim - * against the real repository). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; +import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; +import { booleanOption, compilePatterns, stringArray } from '../shared/options.ts'; +import { snippet } from '../shared/reporting.ts'; +import { + driverText as sharedDriverText, + emittedText, + reportNode, +} from '../shared/scaffold-text.ts'; +import type { StringNode } from '../shared/scaffold-text.ts'; /** Files whose template literals are emitted as source code into someone else's module. */ const DEFAULT_TEMPLATE_PATHS: readonly string[] = [ @@ -162,8 +162,6 @@ const DEFAULT_ROUTE_PARAMS: readonly string[] = [ /** The `routeParams` group only scans templates that actually declare route parameters. */ const ROUTE_PARAMS_GATE = /(?:Route|Search)Params/u; -const INTERPOLATION = '_'; - /** Longest snippet echoed back in a diagnostic message. */ const SNIPPET_LIMIT = 72; @@ -185,16 +183,6 @@ interface Match { readonly group: Group; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - -function boolean(value: unknown, fallback: boolean): boolean { - return typeof value === 'boolean' ? value : fallback; -} - function readOptions(raw: unknown): RuleOptions { const record: Record = typeof raw === 'object' && raw !== null && !Array.isArray(raw) @@ -205,107 +193,42 @@ function readOptions(raw: unknown): RuleOptions { promiseFirstPatterns: stringArray(record.promiseFirstPatterns, DEFAULT_PROMISE_FIRST), perCallClientPatterns: stringArray(record.perCallClientPatterns, DEFAULT_PER_CALL_CLIENT), routeParamPatterns: stringArray(record.routeParamPatterns, DEFAULT_ROUTE_PARAMS), - strictPerCallClient: boolean(record.strictPerCallClient, false), + strictPerCallClient: booleanOption(record.strictPerCallClient, false), exclude: stringArray(record.exclude, DEFAULT_EXCLUDE), }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real scaffold paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -/** Compile option sources once per file; a source that does not compile disables itself, not the rule. */ -function compilePatterns(sources: readonly string[]): readonly RegExp[] { - const compiled: RegExp[] = []; - for (const source of sources) { - try { - compiled.push(new RegExp(source, 'gu')); - } catch { - // A malformed user-supplied pattern must never take the whole lint run down. - } - } - return compiled; -} - -/** Collapse matched template text to one short, readable line for the diagnostic. */ -function snippetOf(text: string): string { - const flat = text.replace(/\s+/gu, ' ').trim(); - return flat.length > SNIPPET_LIMIT ? `${flat.slice(0, SNIPPET_LIMIT - 1)}…` : flat; -} - -type StringNode = Extract; -/** Cooked text is scanned; interpolation expressions remain opaque. */ -function emittedText(node: StringNode): string { - return node.type === 'TemplateLiteral' - ? node.quasis.map((q) => q.value.cooked ?? q.value.raw).join(INTERPOLATION) - : typeof node.value === 'string' - ? node.value - : ''; -} -/** Quasi-level location is intentional: escaped/CRLF text has no 1:1 raw offset mapping. */ -function reportNode(node: StringNode, start: number, end: number): ESTree.Node { - if (node.type !== 'TemplateLiteral') return node; - let offset = 0; - for (const quasi of node.quasis) { - const length = (quasi.value.cooked ?? quasi.value.raw).length; - if (start >= offset && end <= offset + length) return quasi; - offset += length + INTERPOLATION.length; - } - return node; -} -/** This is a lexical scanner, not a generated JS parser. Regex literals and arbitrary dynamic - * fragments are not reconstructed. Comments and quoted emitted data are not executable code. */ -function maskText(text: string, strings = true): string { +/** Keep UTF-16 offsets; the shared masker currently collapses surrogate pairs. */ +function maskText(text: string, strings: boolean): string { return text.replace( /\/\*[\s\S]*?\*\/|\/\/[^\r\n]*|'(?:\\[\s\S]|[^'\\])*'|"(?:\\[\s\S]|[^"\\])*"|`(?:\\[\s\S]|[^`\\])*`/gu, (part) => (strings || part.startsWith('/') ? part.replace(/[^\r\n]/g, ' ') : part), ); } +/** Keep function/class boundaries: shared driver filtering also serves broader scanners. */ function driverText(node: ESTree.Node): boolean { + // Module sources remain driver text even inside a function boundary. + let parent = node.parent; if ( - node.parent !== null && - node.parent !== undefined && + parent && [ 'ImportDeclaration', 'ImportExpression', 'ExportNamedDeclaration', 'ExportAllDeclaration', - ].includes(node.parent.type) + ].includes(parent.type) ) - return true; - let current = node; - while (current.parent !== null && current.parent !== undefined) { - const parent = current.parent; + return sharedDriverText(node); + while (parent) { if (/Function/u.test(parent.type) || parent.type === 'ClassBody') return false; - if (parent.type === 'CallExpression' || parent.type === 'NewExpression') { - const callee = parent.callee; - if ( - callee.type === 'Identifier' && - /^(?:Error|TypeError|exec|execSync|execFile|execFileSync|spawn|spawnSync)$/u.test( - callee.name, - ) - ) - return true; - if ( - callee.type === 'MemberExpression' && - callee.object.type === 'Identifier' && - callee.object.name === 'console' - ) - return true; - return false; - } + if (parent.type === 'CallExpression' || parent.type === 'NewExpression') break; if ( ['VariableDeclarator', 'ReturnStatement', 'TemplateLiteral', 'Program'].includes(parent.type) ) - return false; - current = parent; + break; + parent = parent.parent; } - return false; + return sharedDriverText(node); } interface Span { readonly start: number; @@ -314,14 +237,23 @@ interface Span { /** End of a balanced expression/block. Whitespace (including blank lines) is never a boundary. */ function expressionEnd(text: string, start: number): number { const closes: string[] = []; + const delimiters = new Map([ + ['(', ')'], + ['[', ']'], + ['{', '}'], + ]); + const block = text[start] === '{'; for (let i = start; i < text.length; i += 1) { const char = text[i]!; if (closes.length === 0 && /[;,)\]}]/u.test(char)) return i; - if (char === '(' || char === '[' || char === '{') - closes.push(char === '(' ? ')' : char === '[' ? ']' : '}'); - else if (char === closes.at(-1)) { + const closing = delimiters.get(char); + if (closing !== undefined) { + closes.push(closing); + continue; + } + if (char === closes.at(-1)) { closes.pop(); - if (closes.length === 0 && start === text.indexOf('{', start)) return i + 1; + if (closes.length === 0 && block) return i + 1; } } return text.length; @@ -358,6 +290,23 @@ function operationSpans(text: string): { functions: Span[]; layers: Span[] } { } return { functions, layers }; } +/** Collect named emitted imports without treating arbitrary receivers as Effect. */ +function collectRunnerImports( + entries: string, + source: string, + names: string[], + namespace: string[], +): void { + for (const entry of entries.split(',')) { + const binding = /^\s*([\w$]+)(?:\s+as\s+([\w$]+))?\s*$/u.exec(entry); + if (binding === null) continue; + const imported = binding[1]!; + const local = binding[2] ?? imported; + if (source === 'effect' && imported === 'Effect') namespace.push(local); + if (source === 'effect/Effect' && /^run(?:Promise|Sync|Fork)(?:Exit)?$/u.test(imported)) + names.push(local); + } +} /** Resolve only explicit emitted Effect import aliases, never arbitrary *.runPromise receivers. */ function runnerPatterns(text: string): readonly RegExp[] { const names: string[] = []; @@ -366,14 +315,7 @@ function runnerPatterns(text: string): readonly RegExp[] { /\bimport\s+(?:\*\s+as\s+([\w$]+)|\{([^}]+)\})\s+from\s+['"](effect(?:\/Effect)?)['"]/gu; for (const match of text.matchAll(imports)) { if (match[1] !== undefined && match[3] === 'effect/Effect') namespace.push(match[1]); - for (const entry of (match[2] ?? '').split(',')) { - const binding = /^\s*([\w$]+)(?:\s+as\s+([\w$]+))?\s*$/u.exec(entry); - if (binding === null) continue; - const local = binding[2] ?? binding[1]!; - if (match[3] === 'effect' && binding[1] === 'Effect') namespace.push(local); - if (match[3] === 'effect/Effect' && /^run(?:Promise|Sync|Fork)(?:Exit)?$/u.test(binding[1]!)) - names.push(local); - } + collectRunnerImports(match[2] ?? '', match[3]!, names, namespace); } const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); return [ @@ -515,7 +457,7 @@ export const rule = defineRule({ const text = emittedText(node); // Single-line package-manager commands are generator instructions, not JS source. if (/^\s*(?:pnpm|npm|npx|yarn|bun)\s+[^\r\n]*$/u.test(text)) return; - const syntax = maskText(text); + const syntax = maskText(text, true); const found: Match[] = []; scan(syntax, promiseFirst, 'promiseFirst', found); if ( @@ -546,7 +488,7 @@ export const rule = defineRule({ context.report({ node: reportNode(node, match.start, match.end), messageId: match.group, - data: { snippet: snippetOf(text.slice(match.start, match.end)) }, + data: { snippet: snippet(text.slice(match.start, match.end), SNIPPET_LIMIT) }, }); } } diff --git a/app/tools/oxlint/effect-native/rules/no-promise-shaped-port.ts b/app/tools/oxlint/effect-native/rules/no-promise-shaped-port.ts index d6106ca7d..d582ca39e 100644 --- a/app/tools/oxlint/effect-native/rules/no-promise-shaped-port.ts +++ b/app/tools/oxlint/effect-native/rules/no-promise-shaped-port.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * effect-native/no-promise-shaped-port * @@ -16,11 +17,9 @@ import { defineRule } from '@oxlint/plugins'; import type { Context, ESTree } from '@oxlint/plugins'; -import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; - -/** Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; +import { globToRegExp, isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; +import { stringArray, booleanOption as boolean } from '../shared/options.ts'; +import { parentOf, typeNameSegments } from '../shared/ast.ts'; const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**']; const DEFAULT_IGNORE = [ @@ -52,11 +51,6 @@ const DEFAULT_ALLOW_NAMES = [ const DEFAULT_PROMISE_TYPES = ['Promise', 'PromiseLike']; const DEFAULT_EFFECT_MODULES: readonly string[] = []; -const EFFECT_NAMESPACE = 'Effect'; -const EFFECT_ROOT_MODULE = 'effect'; -/** `Effect.*` members whose argument subtree *is* the blessed Promise↔Effect conversion point. */ -const PROMISE_BOUNDARY_MEMBERS = new Set(['promise', 'tryPromise', 'tryMapPromise']); - const TSX_FILE = /\.[cm]?[jt]sx$/u; type AnyNode = ESTree.Node & { readonly parent?: ESTree.Node | null }; @@ -74,22 +68,8 @@ interface RuleOptions { readonly includeFunctionDeclarations: boolean; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - -function boolean(value: unknown, fallback: boolean): boolean { - return typeof value === 'boolean' ? value : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { include: stringArray(record.include, DEFAULT_INCLUDE), ignore: stringArray(record.ignore, DEFAULT_IGNORE), @@ -104,78 +84,6 @@ function readOptions(context: Context): RuleOptions { }; } -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -function parentOf(node: AnyNode | null): AnyNode | null { - return (node?.parent as AnyNode | null | undefined) ?? null; -} - -/** Same-file `type X = ...` names, so a local `type Promise = ...` shadow disables the match. */ -function collectTypeAliasNames(program: ESTree.Program): ReadonlySet { - const names = new Set(); - const visit = (statements: readonly ESTree.Node[]): void => { - for (const statement of statements) { - if (statement.type === 'TSTypeAliasDeclaration') names.add(statement.id.name); - else if (statement.type === 'ExportNamedDeclaration' && statement.declaration !== null) { - visit([statement.declaration as ESTree.Node]); - } else if ( - statement.type === 'TSModuleDeclaration' && - statement.body?.type === 'TSModuleBlock' - ) { - visit(statement.body.body as readonly ESTree.Node[]); - } - } - }; - visit(program.body as readonly ESTree.Node[]); - return names; -} - -/** Locals bound to the whole `effect` root barrel (`import * as E from "effect"` → `E.Effect.tryPromise`). */ -function collectEffectBarrels(program: ESTree.Program): ReadonlySet { - const barrels = new Set(); - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - if (statement.source.value !== EFFECT_ROOT_MODULE) continue; - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportNamespaceSpecifier') barrels.add(specifier.local.name); - } - } - return barrels; -} - -/** Extra namespaces re-exported by first-party barrels listed in `effectModules`. */ -function collectBarrelBindings( - program: ESTree.Program, - modules: readonly string[], -): ReadonlyMap { - const extra = new Map(); - if (modules.length === 0) return extra; - const patterns = modules.map((module) => globToRegExp(module)); - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - const source = statement.source.value; - if (!patterns.some((pattern) => pattern.test(source))) continue; - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportSpecifier') { - const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; - extra.set(specifier.local.name, imported); - } else if (specifier.type === 'ImportNamespaceSpecifier') { - extra.set(specifier.local.name, specifier.local.name); - } - } - } - return extra; -} - /** Flatten a static member chain (`E.Effect.tryPromise`) into its identifier segments. */ function memberSegments(node: ESTree.Node): readonly string[] | null { let current: ESTree.Node = node; @@ -198,16 +106,6 @@ function memberSegments(node: ESTree.Node): readonly string[] | null { return segments; } -/** Flatten `Promise` / `Effect.Effect` type names into their dotted segments. */ -function typeNameSegments(name: ESTree.TSTypeName): readonly string[] | null { - if (name.type === 'Identifier') return [name.name]; - if (name.type === 'TSQualifiedName') { - const left = typeNameSegments(name.left); - return left === null ? null : [...left, name.right.name]; - } - return null; -} - const FUNCTION_TYPES = new Set([ 'FunctionDeclaration', 'FunctionExpression', @@ -274,11 +172,6 @@ export const rule = defineRule({ if (!options.includeTsx && TSX_FILE.test(path)) return {}; const program = context.sourceCode.ast; - const effect: EffectBindings = collectEffectBindings(program); - const barrelBindings = collectBarrelBindings(program, options.effectModules); - const namespaces = new Map([...effect.namespaces, ...barrelBindings]); - const barrels = collectEffectBarrels(program); - const localTypeAliases = collectTypeAliasNames(program); const driverCallbacks = new Set(options.driverCallbacks); const allowNames = new Set(options.allowNames); @@ -305,43 +198,45 @@ export const rule = defineRule({ } return null; }; + const computedKey = (key: any): unknown => { + if (key.type === 'Literal') return key.value; + if (key.type === 'TemplateLiteral' && !key.expressions.length) + return key.quasis[0]?.value.cooked; + return null; + }; + const importedExportName = (def: any): string | undefined => + def.node.imported?.name ?? def.node.imported?.value; + const importBindingPath = (def: any): string => { + const source = def.parent?.source?.value; + const name = importedExportName(def); + if (source === 'effect' || matchesGlobs(source ?? '', options.effectModules)) + return `effect:${name ?? 'root'}`; + if (source === 'effect/Effect') return `effect:Effect${name ? `.${name}` : ''}`; + return `${source}:${name ?? '*'}`; + }; + const immutableDefinition = (def: any, variable: any): boolean => + def.type === 'Variable' && + def.parent?.kind === 'const' && + !variable.references.some((r: any) => r.isWrite() && !r.init); + const importedIdentifier = (node: any, seen: Set): string | null => { + if (node.type !== 'Identifier') return null; + const variable = variableFor(node, node.name); + for (const def of variable?.defs ?? []) { + if (def.type === 'ImportBinding') return importBindingPath(def); + if (immutableDefinition(def, variable)) return imported(def.node.init, seen); + } + return !variable?.defs.length && node.name === 'globalThis' ? 'globalThis' : null; + }; const imported = (raw: any, seen = new Set()): string | null => { const node = unwrap(raw); if (!node || seen.has(node)) return null; seen.add(node); if (node.type === 'MemberExpression') { const left = imported(node.object, seen); - const key = !node.computed - ? node.property.name - : node.property.type === 'Literal' - ? node.property.value - : node.property.type === 'TemplateLiteral' && !node.property.expressions.length - ? node.property.quasis[0]?.value.cooked - : null; + const key = node.computed ? computedKey(node.property) : node.property.name; return left && typeof key === 'string' ? `${left}.${key}` : null; } - if (node.type !== 'Identifier') return null; - const variable = variableFor(node, node.name); - for (const def of variable?.defs ?? []) { - if (def.type === 'ImportBinding') { - const source = def.parent?.source?.value; - const name = def.node.imported?.name ?? def.node.imported?.value; - if ( - source === 'effect' || - options.effectModules.some((m) => globToRegExp(m).test(source ?? '')) - ) - return `effect:${name ?? 'root'}`; - if (source === 'effect/Effect') return `effect:Effect${name ? `.${name}` : ''}`; - return `${source}:${name ?? '*'}`; - } - if ( - def.type === 'Variable' && - def.parent?.kind === 'const' && - !variable.references.some((r: any) => r.isWrite() && !r.init) - ) - return imported(def.node.init, seen); - } - return !variable?.defs.length && node.name === 'globalThis' ? 'globalThis' : null; + return importedIdentifier(node, seen); }; const walk = (node: any, visit: (node: any) => void): void => { if (!node || typeof node !== 'object') return; @@ -461,6 +356,18 @@ export const rule = defineRule({ }; /** Better Auth owns this exact hook signature, not arbitrary services nested in its options. */ + const isAuthHookPath = (keys: readonly string[]): boolean => + keys.length === 4 && + keys[0] === 'databaseHooks' && + ['user', 'session', 'account', 'verification'].includes(keys[1]!) && + ['create', 'update', 'delete'].includes(keys[2]!) && + ['before', 'after'].includes(keys[3]!); + const isObjectValue = (parent: any, current: any): boolean => + parent.type === 'Property' && + parent.value === current && + parent.parent?.type === 'ObjectExpression'; + const authPropertyKey = (parent: any): unknown => + parent.computed ? computedKey(parent.key) : (parent.key.name ?? parent.key.value); const atAuthHook = (node: any): boolean => { let current = node; const keys: string[] = []; @@ -470,19 +377,8 @@ export const rule = defineRule({ current = parent; continue; } - if ( - parent.type !== 'Property' || - parent.value !== current || - parent.parent?.type !== 'ObjectExpression' - ) - break; - const key = !parent.computed - ? (parent.key.name ?? parent.key.value) - : parent.key.type === 'Literal' - ? parent.key.value - : parent.key.type === 'TemplateLiteral' && !parent.key.expressions.length - ? parent.key.quasis[0]?.value.cooked - : null; + if (!isObjectValue(parent, current)) break; + const key = authPropertyKey(parent); if (typeof key !== 'string') return false; keys.unshift(key); current = parent.parent; @@ -492,11 +388,7 @@ export const rule = defineRule({ call?.type === 'CallExpression' && call.arguments[0] === current && imported(call.callee) === 'better-auth:betterAuth' && - keys.length === 4 && - keys[0] === 'databaseHooks' && - ['user', 'session', 'account', 'verification'].includes(keys[1]!) && - ['create', 'update', 'delete'].includes(keys[2]!) && - ['before', 'after'].includes(keys[3]!) + isAuthHookPath(keys) ); }; @@ -517,6 +409,29 @@ export const rule = defineRule({ annotation: ESTree.TSTypeAnnotation | null | undefined, ): string | null => { if (annotation === null || annotation === undefined) return null; + const isGlobalPromiseReference = ( + raw: any, + names: readonly string[], + name: string, + ): boolean => + names.length === 2 && + names[0] === 'globalThis' && + !variableFor(raw, 'globalThis')?.defs.length && + options.promiseTypes.includes(name); + const resolveReference = (raw: any, seen: Set): string | null => { + const names = typeNameSegments(raw.typeName); + if (!names) return null; + const name = names.at(-1)!; + if (isGlobalPromiseReference(raw, names, name)) return `${name}<…>`; + if (names.length !== 1) return null; + const variable = variableFor(raw.typeName, name); + const alias = variable?.defs.find( + (d: any) => d.node.type === 'TSTypeAliasDeclaration', + )?.node; + if (alias) return resolve(alias.typeAnnotation, seen); + if (variable?.defs.length || !options.promiseTypes.includes(name)) return null; + return `${name}<…>`; + }; const resolve = (raw: any, seen = new Set()): string | null => { if (!raw || seen.has(raw)) return null; seen.add(raw); @@ -530,24 +445,7 @@ export const rule = defineRule({ return null; } if (raw.type !== 'TSTypeReference') return null; - const names = typeNameSegments(raw.typeName); - if (!names) return null; - const name = names.at(-1)!; - if ( - names.length === 2 && - names[0] === 'globalThis' && - !variableFor(raw, 'globalThis')?.defs.length && - options.promiseTypes.includes(name) - ) - return `${name}<…>`; - if (names.length !== 1) return null; - const variable = variableFor(raw.typeName, name); - const alias = variable?.defs.find( - (d: any) => d.node.type === 'TSTypeAliasDeclaration', - )?.node; - if (alias) return resolve(alias.typeAnnotation, seen); - if (variable?.defs.length || !options.promiseTypes.includes(name)) return null; - return `${name}<…>`; + return resolveReference(raw, seen); }; return resolve(annotation); }; @@ -571,16 +469,18 @@ export const rule = defineRule({ let hops = 0; while (current !== null && hops < 6) { if ( - current.type === 'Property' || - current.type === 'MethodDefinition' || - current.type === 'TSAbstractMethodDefinition' || - current.type === 'PropertyDefinition' || - current.type === 'TSAbstractPropertyDefinition' || - current.type === 'TSPropertySignature' || - current.type === 'TSMethodSignature' || - current.type === 'TSTypeAliasDeclaration' || - current.type === 'VariableDeclarator' || - current.type === 'FunctionDeclaration' + [ + 'Property', + 'MethodDefinition', + 'TSAbstractMethodDefinition', + 'PropertyDefinition', + 'TSAbstractPropertyDefinition', + 'TSPropertySignature', + 'TSMethodSignature', + 'TSTypeAliasDeclaration', + 'VariableDeclarator', + 'FunctionDeclaration', + ].includes(current.type) ) { return current; } @@ -633,12 +533,12 @@ export const rule = defineRule({ // `const deleteRecovery: (id: string) => Promise = ...` — a declared binding, not a // callback parameter (whose annotated `Identifier` has a function as its parent). if (owner.type === 'Identifier') return parentOf(owner)?.type === 'VariableDeclarator'; - return ( - owner.type === 'TSPropertySignature' || - owner.type === 'TSIndexSignature' || - owner.type === 'PropertyDefinition' || - owner.type === 'TSAbstractPropertyDefinition' - ); + return [ + 'TSPropertySignature', + 'TSIndexSignature', + 'PropertyDefinition', + 'TSAbstractPropertyDefinition', + ].includes(owner.type); }; // ---------------------------------------------------------------- (B) implementations @@ -656,6 +556,14 @@ export const rule = defineRule({ }; /** An implementation position that *owns* behaviour: a service record, a class, a module binding. */ + const referencedAsObjectValue = (declarator: any): boolean => { + const id = declarator.id; + if (id.type !== 'Identifier') return false; + return variableFor(id, id.name)?.references.some( + (r: any) => + r.isRead() && r.identifier.parent && isObjectValue(r.identifier.parent, r.identifier), + ); + }; const isImplementationPosition = (node: AnyNode): boolean => { let current = node; let parent = parentOf(current); @@ -664,33 +572,14 @@ export const rule = defineRule({ parent = parentOf(current); } if (parent === null) return false; - if (parent.type === 'Property') { - return ( - (parent as unknown as ESTree.ObjectProperty).value === - (current as unknown as ESTree.Expression) && - parentOf(parent)?.type === 'ObjectExpression' - ); - } - if (parent.type === 'MethodDefinition' || parent.type === 'TSAbstractMethodDefinition') - return true; - if (parent.type === 'PropertyDefinition' || parent.type === 'TSAbstractPropertyDefinition') - return true; + if (parent.type === 'Property') return isObjectValue(parent, current); + if (['MethodDefinition', 'TSAbstractMethodDefinition'].includes(parent.type)) return true; + if (['PropertyDefinition', 'TSAbstractPropertyDefinition'].includes(parent.type)) return true; if (parent.type === 'VariableDeclarator') { return ( (parent as unknown as ESTree.VariableDeclarator).init === (current as unknown as ESTree.Expression) && - (isModuleScopeDeclarator(parent) || - (() => { - const id = (parent as any).id; - if (id.type !== 'Identifier') return false; - return variableFor(id, id.name)?.references.some( - (r: any) => - r.isRead() && - r.identifier.parent?.type === 'Property' && - r.identifier.parent.value === r.identifier && - r.identifier.parent.parent?.type === 'ObjectExpression', - ); - })()) + (isModuleScopeDeclarator(parent) || referencedAsObjectValue(parent)) ); } return false; @@ -707,37 +596,49 @@ export const rule = defineRule({ ); }; + const isDirectAdapter = (body: any): boolean => { + if (body?.type === 'ImportExpression') return true; + return ( + body?.type === 'CallExpression' && + imported(body.callee) === '@modern-js/plugin-bff/effect-client:runEffectRequest' + ); + }; + const exportedOwner = (owner: any): boolean => + owner.parent?.type === 'ExportNamedDeclaration' || + owner.parent?.parent?.type === 'ExportNamedDeclaration'; + const exemptReference = (ref: any, seen: Set): boolean => { + const call = ref.identifier.parent; + if (call?.type !== 'CallExpression' || call.callee !== ref.identifier) return false; + if (atDriverEdge(call)) return true; + for (let current = call.parent; current; current = current.parent) + if (FUNCTION_TYPES.has(current.type)) return exemptHelper(current, new Set(seen)); + return false; + }; const exemptHelper = (fn: any, seen = new Set()): boolean => { if (seen.has(fn)) return false; seen.add(fn); const body = functionBody(fn); - if (body?.type === 'ImportExpression') return true; - if ( - body?.type === 'CallExpression' && - imported(body.callee) === '@modern-js/plugin-bff/effect-client:runEffectRequest' - ) - return true; + if (isDirectAdapter(body)) return true; const owner = namedOwner(fn); const id = (owner as any).id; if (!id || id.type !== 'Identifier') return false; - if ( - (owner as any).parent?.type === 'ExportNamedDeclaration' || - (owner as any).parent?.parent?.type === 'ExportNamedDeclaration' - ) - return false; + if (exportedOwner(owner)) return false; const variable = variableFor(id, id.name); const refs = variable?.references.filter((r: any) => r.isRead()) ?? []; if (!refs.length) return false; - return refs.every((ref: any) => { - const call = ref.identifier.parent; - if (call?.type !== 'CallExpression' || call.callee !== ref.identifier) return false; - if (atDriverEdge(call)) return true; - for (let current = call.parent; current; current = current.parent) - if (FUNCTION_TYPES.has(current.type)) return exemptHelper(current, new Set(seen)); - return false; - }); + return refs.every((ref: any) => exemptReference(ref, seen)); }; + const ownsImplementation = (node: AnyNode): boolean => + node.type === 'FunctionDeclaration' + ? options.includeFunctionDeclarations && isModuleScopeFunction(node) + : isImplementationPosition(node); + const allowedRoute = (node: AnyNode, member: string): boolean => + allowNames.has(member) && + /(?:^|\/)src\/routes\//u.test(path) && + ['VariableDeclarator', 'FunctionDeclaration'].includes(namedOwner(node).type); + const atImplementationBoundary = (node: AnyNode): boolean => + atDriverEdge(node) || atAuthHook(node) || exemptHelper(node); const checkImplementation = (node: AnyNode): void => { const fn = node as unknown as { readonly async?: boolean; @@ -746,20 +647,12 @@ export const rule = defineRule({ const wrapper = promiseReference(fn.returnType); const isAsync = fn.async === true; if (!isAsync && wrapper === null) return; - if (node.type === 'FunctionDeclaration') { - if (!options.includeFunctionDeclarations) return; - if (!isModuleScopeFunction(node)) return; - } else if (!isImplementationPosition(node)) return; - if (atDriverEdge(node) || atAuthHook(node) || exemptHelper(node)) return; + if (!ownsImplementation(node)) return; + if (atImplementationBoundary(node)) return; if (insideReportedFunction(node)) return; const member = nameOf(node); // Framework router entrypoints (`loader`, `action`, ...) are forced to return a Promise. - if ( - allowNames.has(member) && - /(?:^|\/)src\/routes\//u.test(path) && - ['VariableDeclarator', 'FunctionDeclaration'].includes(namedOwner(node).type) - ) - return; + if (allowedRoute(node, member)) return; reportedFunctions.add(node.start); report(node as ESTree.Node, isAsync ? 'asyncPort' : 'promiseReturningImplementation', { member, diff --git a/app/tools/oxlint/effect-native/rules/no-raw-effect-adt-tag-check.ts b/app/tools/oxlint/effect-native/rules/no-raw-effect-adt-tag-check.ts index 706c72694..efb7da5ff 100644 --- a/app/tools/oxlint/effect-native/rules/no-raw-effect-adt-tag-check.ts +++ b/app/tools/oxlint/effect-native/rules/no-raw-effect-adt-tag-check.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * effect-native/no-raw-effect-adt-tag-check * @@ -54,17 +55,13 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree, Variable } from '@oxlint/plugins'; import { collectEffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; - -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production `include` defaults instead of - * forcing the fixture config to pass loosened options (which `run-on-repo.mts` reuses verbatim). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; +import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; +import { asNamedMember, staticString, unwrapNode } from '../shared/ast.ts'; +import { resolveVariable } from '../shared/bindings.ts'; +import { stringArray } from '../shared/options.ts'; const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; @@ -95,18 +92,8 @@ interface RuleOptions { readonly reexportModules: readonly string[]; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { include: stringArray(record.include, DEFAULT_INCLUDE), ignore: stringArray(record.ignore, DEFAULT_IGNORE), @@ -117,97 +104,36 @@ function readOptions(context: Context): RuleOptions { }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -/** Strip the wrappers that never change what an expression denotes. */ +/** Keep this rule's original transparent wrappers and bounded traversal. */ function unwrap(node: ESTree.Node): ESTree.Node { - let current: ESTree.Node = node; - for (let depth = 0; depth < MAX_UNWRAP_DEPTH; depth += 1) { - if (current.type === 'ChainExpression') { - current = current.expression; - continue; - } - if (current.type === 'TSNonNullExpression' || current.type === 'ParenthesizedExpression') { - current = current.expression; - continue; - } - if ( - current.type === 'TSAsExpression' || - current.type === 'TSSatisfiesExpression' || - current.type === 'TSTypeAssertion' || - current.type === 'TSInstantiationExpression' - ) { - current = current.expression; - continue; - } - return current; - } - return current; -} - -function templateText(node: ESTree.TemplateLiteral): string | null { - const quasi = node.quasis[0]; - if (node.quasis.length !== 1 || quasi === undefined) return null; - return quasi.value.cooked ?? quasi.value.raw; + return unwrapNode(node, { maxDepth: MAX_UNWRAP_DEPTH }); } -/** The `_tag` member access itself (`x._tag`, `x?._tag`, `x!._tag`, `x["_tag"]`), or null. */ function asTagMember(node: ESTree.Node): ESTree.MemberExpression | null { - const expression = unwrap(node); - if (expression.type !== 'MemberExpression') return null; - const property = expression.property; - if (!expression.computed) { - return property.type === 'Identifier' && property.name === TAG_PROPERTY ? expression : null; - } - const key = unwrap(property); - if (key.type === 'Literal') return key.value === TAG_PROPERTY ? expression : null; - if (key.type === 'TemplateLiteral' && key.expressions.length === 0) { - return templateText(key) === TAG_PROPERTY ? expression : null; - } - return null; + return asNamedMember(node, TAG_PROPERTY, asStringLiteral, { maxDepth: MAX_UNWRAP_DEPTH }); } -/** A statically known string operand (`'Some'`, `"Some"`, `` `Some` ``), or null. */ function asStringLiteral(node: ESTree.Node): string | null { - const expression = unwrap(node); - if (expression.type === 'Literal') - return typeof expression.value === 'string' ? expression.value : null; - if (expression.type === 'TemplateLiteral' && expression.expressions.length === 0) - return templateText(expression); - return null; + return staticString(node, { + unwrap: { maxDepth: MAX_UNWRAP_DEPTH }, + templates: true, + rawTemplates: true, + singleQuasi: true, + }); } /** Best-effort human name for an expression, used only to pick the message's ADT vocabulary. */ +function directExpressionName(node: ESTree.Node): string | null { + if (node.type === 'Identifier') return node.name; + if (node.type !== 'MemberExpression' || node.computed) return null; + return node.property.type === 'Identifier' ? node.property.name : null; +} + function expressionName(node: ESTree.Node): string | null { const object = unwrap(node); - if (object.type === 'Identifier') return object.name; - if ( - object.type === 'MemberExpression' && - !object.computed && - object.property.type === 'Identifier' - ) { - return object.property.name; - } - if (object.type === 'CallExpression') { - const callee = unwrap(object.callee); - if ( - callee.type === 'MemberExpression' && - !callee.computed && - callee.property.type === 'Identifier' - ) { - return callee.property.name; - } - if (callee.type === 'Identifier') return callee.name; - } + if (object.type === 'CallExpression') return directExpressionName(unwrap(object.callee)); if (object.type === 'AwaitExpression') return expressionName(object.argument); - return null; + return directExpressionName(object); } /** Best-effort receiver name (`cleanupExit._tag` → `cleanupExit`, `a.b.failure._tag` → `failure`). */ @@ -234,26 +160,24 @@ function patternBindsTag(pattern: ESTree.Node, name: string, depth: number): boo if (depth > MAX_PATTERN_DEPTH) return false; if (pattern.type === 'AssignmentPattern') return patternBindsTag(pattern.left, name, depth + 1); if (pattern.type !== 'ObjectPattern') return false; - for (const property of pattern.properties) { - if (property.type !== 'Property') continue; - const key = patternKeyName(property as unknown as { key: ESTree.Node; computed: boolean }); - let value: ESTree.Node = property.value; - while (value.type === 'AssignmentPattern') value = value.left; - if (key === TAG_PROPERTY && value.type === 'Identifier' && value.name === name) return true; - // A nested pattern may still bind `_tag` further down: `const { inner: { _tag } } = x`. - if (value.type === 'ObjectPattern' && patternBindsTag(value, name, depth + 1)) return true; - } - return false; + return pattern.properties.some((property) => propertyBindsTag(property, name, depth)); } -function resolveVariable(context: Context, name: string, from: ESTree.Node): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(from); - while (scope !== null) { - const variable = scope.set.get(name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; +function propertyBindsTag(property: ESTree.Node, name: string, depth: number): boolean { + if (property.type !== 'Property') return false; + const key = patternKeyName(property); + let value: ESTree.Node = property.value; + while (value.type === 'AssignmentPattern') value = value.left; + if (key === TAG_PROPERTY && value.type === 'Identifier' && value.name === name) return true; + return value.type === 'ObjectPattern' && patternBindsTag(value, name, depth + 1); +} + +function aliasDeclarator(variable: Variable | null): ESTree.VariableDeclarator | null { + if (variable === null || variable.defs.length !== 1) return null; + if (variable.references.some((reference) => reference.isWrite() && !reference.init)) return null; + const definition = variable.defs[0]; + if (definition === undefined || definition.type !== 'Variable') return null; + return definition.node.type === 'VariableDeclarator' ? definition.node : null; } /** @@ -265,17 +189,10 @@ function resolveVariable(context: Context, name: string, from: ESTree.Node): Var function aliasedTagRead(context: Context, node: ESTree.Node): { receiver: string | null } | null { const expression = unwrap(node); if (expression.type !== 'Identifier') return null; - const variable = resolveVariable(context, expression.name, expression); - // Exactly one `const`/`let`/`var` definition: a redeclared or imported name is not a tag alias. - if (variable === null || variable.defs.length !== 1) return null; - // A declaration is not proof of the value after reassignment (including destructured locals). - if (variable.references.some((reference) => reference.isWrite() && !reference.init)) return null; - const definition = variable.defs[0]; - if (definition === undefined || definition.type !== 'Variable') return null; - const declarator = definition.node; - if (declarator.type !== 'VariableDeclarator') return null; + const declarator = aliasDeclarator(resolveVariable(context, expression.name, expression)); + if (declarator === null) return null; const initialiser = declarator.init; - if (initialiser === null || initialiser === undefined) return null; + if (initialiser == null) return null; if (declarator.id.type === 'Identifier') { if (declarator.id.name !== expression.name) return null; @@ -321,22 +238,19 @@ function hasStaticEffectLinkage( reexportModules: readonly string[], ): boolean { if (collectEffectBindings(program).importsEffect) return true; - for (const statement of program.body) { - if (statement.type === 'ImportDeclaration') { - if (reexportModules.includes(statement.source.value)) return true; - continue; - } - if (statement.type === 'ExportAllDeclaration') { - if (isEffectSource(statement.source.value, reexportModules)) return true; - continue; - } - if (statement.type === 'ExportNamedDeclaration') { - const source = statement.source; - if (source !== null && source !== undefined && isEffectSource(source.value, reexportModules)) - return true; - } + return program.body.some((statement) => statementLinksEffect(statement, reexportModules)); +} + +function statementLinksEffect(statement: ESTree.Node, reexportModules: readonly string[]): boolean { + if (statement.type === 'ImportDeclaration') { + return reexportModules.includes(statement.source.value); + } + if (statement.type === 'ExportAllDeclaration') { + return isEffectSource(statement.source.value, reexportModules); } - return false; + if (statement.type !== 'ExportNamedDeclaration') return false; + const source = statement.source; + return source != null && isEffectSource(source.value, reexportModules); } interface PendingReport { diff --git a/app/tools/oxlint/effect-native/rules/no-refinement-outside-schema.ts b/app/tools/oxlint/effect-native/rules/no-refinement-outside-schema.ts index 10ef7b511..139e35bc4 100644 --- a/app/tools/oxlint/effect-native/rules/no-refinement-outside-schema.ts +++ b/app/tools/oxlint/effect-native/rules/no-refinement-outside-schema.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * effect-native/no-refinement-outside-schema * @@ -89,14 +90,9 @@ import type { Context, ESTree } from '@oxlint/plugins'; import { collectEffectBindings } from '../shared/effect-imports.ts'; import type { EffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; - -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production `include` defaults instead of - * forcing the fixture config to pass loosened options (which `run-on-repo.mts` reuses verbatim). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; +import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; +import { stringArray } from '../shared/options.ts'; +import { parentOf, unwrapNode } from '../shared/ast.ts'; const DEFAULT_INCLUDE: readonly string[] = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; @@ -157,18 +153,8 @@ interface RuleOptions { readonly ignoreTests: boolean; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { include: stringArray(record.include, DEFAULT_INCLUDE), ignore: stringArray(record.ignore, DEFAULT_IGNORE), @@ -181,36 +167,17 @@ function readOptions(context: Context): RuleOptions { }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} +const REFINEMENT_WRAPPERS = new Set([ + 'ChainExpression', + 'ParenthesizedExpression', + 'TSNonNullExpression', + 'TSAsExpression', + 'TSSatisfiesExpression', + 'TSInstantiationExpression', +]); -/** Strip the wrappers that never change what an expression denotes. */ function unwrap(node: ESTree.Node): ESTree.Node { - let current: ESTree.Node = node; - for (;;) { - if ( - current.type === 'ChainExpression' || - current.type === 'ParenthesizedExpression' || - current.type === 'TSNonNullExpression' || - current.type === 'TSAsExpression' || - current.type === 'TSSatisfiesExpression' || - current.type === 'TSInstantiationExpression' - ) { - current = current.expression; - continue; - } - return current; - } -} - -function parentOf(node: ESTree.Node): ESTree.Node | null { - return (node as { parent?: ESTree.Node | null }).parent ?? null; + return unwrapNode(node, { wrappers: REFINEMENT_WRAPPERS }); } interface ImportBinding { @@ -232,24 +199,32 @@ function collectValueImports(program: ESTree.Program): ReadonlyMap, +): void { + for (const specifier of statement.specifiers) { + if ((specifier as { importKind?: string }).importKind === 'type') continue; + if (specifier.type === 'ImportSpecifier') { + const imported = + specifier.imported.type === 'Identifier' + ? specifier.imported.name + : String(specifier.imported.value); + bindings.set(specifier.local.name, { module, imported, namespace: false }); + } else if (specifier.type === 'ImportDefaultSpecifier') { + bindings.set(specifier.local.name, { module, imported: 'default', namespace: false }); + } else if (specifier.type === 'ImportNamespaceSpecifier') { + bindings.set(specifier.local.name, { module, imported: '*', namespace: true }); + } + } +} + function localsFrom( imports: ReadonlyMap, accept: (binding: ImportBinding) => boolean, @@ -472,7 +447,14 @@ function delegatesToAuthority( const first = node.arguments[0]; if (first === undefined || first.type === 'SpreadElement') return false; if (!isGuardedValue(first, parameterName)) return false; - const callee = unwrap(node.callee); + return isAuthorityCallee(unwrap(node.callee), authorities, options); +} + +function isAuthorityCallee( + callee: ESTree.Node, + authorities: Authorities, + options: RuleOptions, +): boolean { if (isNativeArray(callee, authorities)) return true; if (isPredicateAuthority(callee, authorities)) return true; if (isSchemaNarrowingApplication(callee, authorities)) return true; @@ -526,10 +508,8 @@ function annotatedInitialiser(owner: ESTree.Node): ESTree.Node | null { if (owner.type !== 'TSFunctionType') return null; let current: ESTree.Node | null = parentOf(owner); for (let depth = 0; current !== null && depth < 6; depth += 1) { - if (current.type === 'TSAsExpression' || current.type === 'TSSatisfiesExpression') - return current.expression; - if (current.type === 'VariableDeclarator') return current.init ?? null; - if (current.type === 'PropertyDefinition') return current.value ?? null; + const initialiser = initialiserAt(current); + if (initialiser !== undefined) return initialiser; if ( current.type === 'Identifier' || current.type === 'TSTypeAnnotation' || @@ -543,6 +523,14 @@ function annotatedInitialiser(owner: ESTree.Node): ESTree.Node | null { return null; } +function initialiserAt(node: ESTree.Node): ESTree.Node | null | undefined { + if (node.type === 'TSAsExpression' || node.type === 'TSSatisfiesExpression') + return node.expression; + if (node.type === 'VariableDeclarator') return node.init ?? null; + if (node.type === 'PropertyDefinition') return node.value ?? null; + return undefined; +} + /** * Is this function the direct callback argument of a collection operation? Member callees * (`rows.filter(...)`, `Arr.findFirst(...)`) are honoured by name; a *bare* identifier callee must @@ -575,26 +563,42 @@ function isCallableCapabilityProbe( if (parameterName === null) return false; const pair = unwrap(expression); if (pair.type !== 'LogicalExpression' || pair.operator !== '&&') return false; - const presence = unwrap(pair.left); - const check = unwrap(pair.right); - if (presence.type !== 'BinaryExpression' || presence.operator !== 'in') return false; - if (!isGuardedValue(presence.right, parameterName)) return false; + const key = capabilityPresenceKey(pair.left, parameterName); + return key !== null && checksCallableProjection(pair.right, parameterName, key, authorities); +} + +function capabilityPresenceKey(expression: ESTree.Node, parameterName: string): string | null { + const presence = unwrap(expression); + if (presence.type !== 'BinaryExpression' || presence.operator !== 'in') return null; + if (!isGuardedValue(presence.right, parameterName)) return null; const key = unwrap(presence.left); - if (key.type !== 'Literal' || typeof key.value !== 'string') return false; - if (check.type !== 'CallExpression' || check.arguments.length !== 1) return false; - const callee = unwrap(check.callee); + return key.type === 'Literal' && typeof key.value === 'string' ? key.value : null; +} + +function isFunctionAuthority(callee: ESTree.Node, authorities: Authorities): boolean { if (!isPredicateAuthority(callee, authorities)) return false; const member = callee.type === 'Identifier' ? authorities.imports.get(callee.name)?.imported : resolveNamespaceMember(callee, authorities.bindings, authorities.barrelLocals)?.member; - if (member !== 'isFunction') return false; + return member === 'isFunction'; +} + +function checksCallableProjection( + expression: ESTree.Node, + parameterName: string, + key: string, + authorities: Authorities, +): boolean { + const check = unwrap(expression); + if (check.type !== 'CallExpression' || check.arguments.length !== 1) return false; + if (!isFunctionAuthority(unwrap(check.callee), authorities)) return false; const argument = check.arguments[0]; if (argument === undefined) return false; const projected = unwrap(argument); return ( projected.type === 'MemberExpression' && - staticPropertyName(projected) === key.value && + staticPropertyName(projected) === key && isGuardedValue(projected.object, parameterName) ); } @@ -630,10 +634,7 @@ function guardedParameterType(owner: ESTree.Node, parameterName: string | null): if (parameterName === null) return null; const params = (owner as { params?: readonly ESTree.Node[] }).params ?? []; for (const param of params) { - let target: ESTree.Node = param; - if (target.type === 'RestElement') target = target.argument; - if (target.type === 'AssignmentPattern') target = target.left; - const identifier = target.type === 'Identifier' ? target : null; + const identifier = parameterIdentifier(param); if (identifier === null || identifier.name !== parameterName) continue; const annotation = (identifier as { typeAnnotation?: ESTree.TSTypeAnnotation | null }).typeAnnotation ?? null; @@ -642,6 +643,15 @@ function guardedParameterType(owner: ESTree.Node, parameterName: string | null): return null; } +function parameterIdentifier( + param: ESTree.Node, +): Extract | null { + let target = param; + if (target.type === 'RestElement') target = target.argument; + if (target.type === 'AssignmentPattern') target = target.left; + return target.type === 'Identifier' ? target : null; +} + const OWNER_TYPES = new Set([ 'ArrowFunctionExpression', 'FunctionDeclaration', @@ -686,47 +696,99 @@ const NAME_TRANSPARENT_PARENTS = new Set([ ]); /** Best-effort declaration name for the diagnostic (`isNonEmptyString`, `#isReady`, `(anonymous)`). */ -function predicateName(owner: ESTree.Node | null): string { - if (owner === null) return '(anonymous)'; - const own = (owner as { id?: ESTree.Node | null }).id ?? null; - const ownName = keyName(own); - if (ownName !== null) return ownName; - if (owner.type === 'TSMethodSignature' || owner.type === 'TSCallSignatureDeclaration') { - const key = keyName((owner as { key?: ESTree.Node | null }).key ?? null); - if (key !== null) return key; - return '(call signature)'; - } - let current: ESTree.Node | null = parentOf(owner); +const NAMED_PROPERTY_TYPES = new Set([ + 'Property', + 'PropertyDefinition', + 'MethodDefinition', + 'TSPropertySignature', +]); + +function assignmentName(left: ESTree.Node): string { + const node = unwrap(left); + if (node.type === 'Identifier') return node.name; + return node.type === 'MemberExpression' + ? (staticPropertyName(node) ?? '(anonymous)') + : '(anonymous)'; +} + +function declarationName(node: ESTree.Node): string | null { + if (node.type === 'VariableDeclarator' || node.type === 'TSTypeAliasDeclaration') + return keyName(node.id) ?? '(anonymous)'; + if (NAMED_PROPERTY_TYPES.has(node.type)) + return keyName((node as { key?: ESTree.Node }).key ?? null) ?? '(anonymous)'; + if (node.type === 'AssignmentExpression') return assignmentName(node.left); + return null; +} + +function parentDeclarationName(owner: ESTree.Node): string { + let current = parentOf(owner); for (let depth = 0; current !== null && depth < 8; depth += 1) { - if (current.type === 'VariableDeclarator') return keyName(current.id) ?? '(anonymous)'; - if ( - current.type === 'Property' || - current.type === 'PropertyDefinition' || - current.type === 'MethodDefinition' - ) { - return keyName((current as { key?: ESTree.Node | null }).key ?? null) ?? '(anonymous)'; - } - if (current.type === 'TSPropertySignature') { - return keyName((current as { key?: ESTree.Node | null }).key ?? null) ?? '(anonymous)'; - } - if (current.type === 'TSTypeAliasDeclaration') return keyName(current.id) ?? '(anonymous)'; - if (current.type === 'AssignmentExpression') { - const left = unwrap(current.left); - if (left.type === 'Identifier') return left.name; - if (left.type === 'MemberExpression') return staticPropertyName(left) ?? '(anonymous)'; - return '(anonymous)'; - } + const name = declarationName(current); + if (name !== null) return name; if (!NAME_TRANSPARENT_PARENTS.has(current.type)) return '(anonymous)'; current = parentOf(current); } return '(anonymous)'; } +/** Best-effort declaration name for the diagnostic. */ +function predicateName(owner: ESTree.Node | null): string { + if (owner === null) return '(anonymous)'; + const ownName = keyName((owner as { id?: ESTree.Node }).id ?? null); + if (ownName !== null) return ownName; + if (owner.type === 'TSMethodSignature' || owner.type === 'TSCallSignatureDeclaration') + return keyName((owner as { key?: ESTree.Node }).key ?? null) ?? '(call signature)'; + return parentDeclarationName(owner); +} + function condense(text: string, limit: number): string { const collapsed = text.replace(/\s+/gu, ' ').trim(); return collapsed.length > limit ? `${collapsed.slice(0, limit - 1)}…` : collapsed; } +function allowsStructuralBody( + owner: ESTree.Node, + body: ESTree.Node, + parameterName: string | null, +): boolean { + const parameterType = guardedParameterType(owner, parameterName); + if ( + parameterType !== null && + OPAQUE_INPUT_TYPES.has(parameterType) && + isStructuralNarrowingOnly(body) + ) + return true; + return isInstanceofAnchored(body, parameterName); +} + +function isAllowedPredicate( + node: ESTree.TSTypePredicate, + owner: ESTree.Node | null, + authorities: Authorities, + options: RuleOptions, +): boolean { + if (owner === null) return false; + if (options.allowInlineCallbacks && isInlineArrayCallback(owner, authorities)) return true; + const body = soleReturnedExpression(owner); + if (body === null) { + const initialiser = annotatedInitialiser(owner); + return initialiser !== null && isAuthorityFunction(initialiser, authorities, options); + } + return isAllowedBody(owner, body, guardedParameterName(node), authorities, options); +} + +function isAllowedBody( + owner: ESTree.Node, + body: ESTree.Node, + parameterName: string | null, + authorities: Authorities, + options: RuleOptions, +): boolean { + if (delegatesToAuthority(body, parameterName, authorities, options)) return true; + if (isCallableCapabilityProbe(body, parameterName, authorities)) return true; + return options.allowInstanceofGuards && allowsStructuralBody(owner, body, parameterName); +} + /** Audit A2: refinements belong to the owning Schema, not to hand-written `x is T` predicates. */ export const rule = defineRule({ meta: { @@ -797,36 +859,7 @@ export const rule = defineRule({ TSTypePredicate(node) { const owner = ownerOf(node); - if ( - options.allowInlineCallbacks && - owner !== null && - isInlineArrayCallback(owner, authorities) - ) - return; - - if (owner !== null) { - const parameterName = guardedParameterName(node); - const body = soleReturnedExpression(owner); - if (body !== null && delegatesToAuthority(body, parameterName, authorities, options)) - return; - if (body !== null && isCallableCapabilityProbe(body, parameterName, authorities)) return; - - const initialiser = body === null ? annotatedInitialiser(owner) : null; - if (initialiser !== null && isAuthorityFunction(initialiser, authorities, options)) - return; - - if (options.allowInstanceofGuards && body !== null) { - const parameterType = guardedParameterType(owner, parameterName); - if ( - parameterType !== null && - OPAQUE_INPUT_TYPES.has(parameterType) && - isStructuralNarrowingOnly(body) - ) { - return; - } - if (isInstanceofAnchored(body, parameterName)) return; - } - } + if (isAllowedPredicate(node, owner, authorities, options)) return; const asserted = node.typeAnnotation; const type = diff --git a/app/tools/oxlint/effect-native/rules/no-route-local-error-classifier.ts b/app/tools/oxlint/effect-native/rules/no-route-local-error-classifier.ts index 8e506091c..8622472f4 100644 --- a/app/tools/oxlint/effect-native/rules/no-route-local-error-classifier.ts +++ b/app/tools/oxlint/effect-native/rules/no-route-local-error-classifier.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * Audit findings: **A9** — "Preserve typed Effects through the frontend" ("ten route-specific error * classifiers", "Exhaustive `Match` against a shared frontend failure vocabulary") and **A4** — @@ -60,16 +61,13 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree, Variable } from '@oxlint/plugins'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; - -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production `routeGlobs` defaults instead of - * forcing the fixture config to pass loosened options (which `run-on-repo.mts` reuses). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; +import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; +import { compile, stringArray } from '../shared/options.ts'; +import { isNode, memberName, type Syntax } from '../shared/ast.ts'; +import { lookupVariable } from '../shared/bindings.ts'; +import { importedName } from '../shared/imports.ts'; const DEFAULT_ROUTE_GLOBS = ['apps/*/src/routes/**', 'verticals/*/src/routes/**']; @@ -110,37 +108,10 @@ interface RuleOptions { readonly allowTestFiles: boolean; } -type AnyNode = Record & { readonly type: string }; - -function isNode(value: unknown): value is AnyNode { - return ( - typeof value === 'object' && - value !== null && - typeof (value as { type?: unknown }).type === 'string' - ); -} - -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - -function compile(value: unknown, fallback: string, flags: string): RegExp { - const source = typeof value === 'string' && value.length > 0 ? value : fallback; - try { - return new RegExp(source, flags); - } catch { - return new RegExp(fallback, flags); - } -} +type AnyNode = Syntax; function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { routeGlobs: stringArray(record.routeGlobs, DEFAULT_ROUTE_GLOBS), namePattern: compile(record.namePattern, DEFAULT_NAME_PATTERN, 'u'), @@ -157,15 +128,6 @@ function readOptions(context: Context): RuleOptions { }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - /** * Depth-first walk over an AST subtree. `parent` back-references are skipped (they would loop), a * visited set guards against any other shared node reference, and `skip` prunes whole subtrees @@ -189,12 +151,15 @@ function forEachNode( seen.add(current); if (skip !== undefined && skip(current)) continue; visit(current); - for (const key of Object.keys(current)) { - if (key === 'parent') continue; - const value = current[key]; - if (value === null || typeof value !== 'object') continue; - stack.push(value); - } + pushChildren(current, stack); + } +} + +function pushChildren(node: AnyNode, stack: unknown[]): void { + for (const key of Object.keys(node)) { + if (key === 'parent') continue; + const value = node[key]; + if (value !== null && typeof value === 'object') stack.push(value); } } @@ -232,84 +197,43 @@ function collectTypeName(typeName: unknown, into: Set): void { collectTypeName(typeName.right, into); } -/** - * `import type { ErrorClassificationInput as X }` → `X` really *is* the projection type, while a - * local alias that merely prints as `ErrorClassificationInput` is not. Maps local → imported name. - */ -/** Identifier names bound to the literal `'_tag'`, so `error[TAG]` is still a discriminant read. */ -/** Non-computed `.x`, computed `["x"]`, or computed `[TAG]` where `const TAG = '_tag'`. */ -function memberPropertyName(node: AnyNode, tagKeyAliases: ReadonlySet): string | null { - const property = node.property; - if (!isNode(property)) return null; - if (node.computed === true) { - if (property.type === 'Literal' && typeof property.value === 'string') return property.value; - if ( - property.type === 'Identifier' && - typeof property.name === 'string' && - tagKeyAliases.has(property.name) - ) { - return TAG_PROPERTY; - } - return null; - } - if (property.type === 'PrivateIdentifier') return null; - return property.type === 'Identifier' && typeof property.name === 'string' ? property.name : null; -} - -/** `error.reason._tag` → `error`; stops at anything that is not a member/assertion wrapper. */ -function rootIdentifierName(node: unknown): string | null { - let current: unknown = node; - while (isNode(current)) { - if (current.type === 'Identifier') - return typeof current.name === 'string' ? current.name : null; - if (current.type === 'MemberExpression') { - current = current.object; - continue; - } - if (TRANSPARENT_EXPRESSIONS.has(current.type)) { - current = current.expression; - continue; - } - return null; +/** Every identifier a binding pattern introduces (`{ error }`, `[first]`, `{ a: { b } }`, rest, default). */ +function unwrapParameter(pattern: unknown): AnyNode | null { + let target = pattern; + const edges: Readonly> = { + TSParameterProperty: 'parameter', + AssignmentPattern: 'left', + RestElement: 'argument', + }; + while (isNode(target)) { + const edge = edges[target.type]; + if (edge === undefined) return target; + target = target[edge]; } return null; } -/** Every identifier a binding pattern introduces (`{ error }`, `[first]`, `{ a: { b } }`, rest, default). */ function patternBindingNames(pattern: unknown, into: Set): void { - let target: unknown = pattern; - while (isNode(target)) { - if (target.type === 'TSParameterProperty') { - target = target.parameter; - continue; - } - if (target.type === 'AssignmentPattern') { - target = target.left; - continue; - } - if (target.type === 'RestElement') { - target = target.argument; - continue; - } - break; - } - if (!isNode(target)) return; + const target = unwrapParameter(pattern); + if (target === null) return; if (target.type === 'Identifier') { if (typeof target.name === 'string') into.add(target.name); return; } if (target.type === 'ObjectPattern') { - const properties = Array.isArray(target.properties) ? target.properties : []; - for (const property of properties) { - if (!isNode(property)) continue; - if (property.type === 'Property') patternBindingNames(property.value, into); - else patternBindingNames(property, into); - } + collectObjectBindings(target.properties, into); return; } - if (target.type === 'ArrayPattern') { - const elements = Array.isArray(target.elements) ? target.elements : []; - for (const element of elements) patternBindingNames(element, into); + if (target.type === 'ArrayPattern' && Array.isArray(target.elements)) { + for (const element of target.elements) patternBindingNames(element, into); + } +} + +function collectObjectBindings(properties: unknown, into: Set): void { + if (!Array.isArray(properties)) return; + for (const property of properties) { + if (!isNode(property)) continue; + patternBindingNames(property.type === 'Property' ? property.value : property, into); } } @@ -339,22 +263,7 @@ interface ParameterShape { } function parameterShape(parameter: unknown): ParameterShape { - let target: unknown = parameter; - while (isNode(target)) { - if (target.type === 'TSParameterProperty') { - target = target.parameter; - continue; - } - if (target.type === 'AssignmentPattern') { - target = target.left; - continue; - } - if (target.type === 'RestElement') { - target = target.argument; - continue; - } - break; - } + const target = unwrapParameter(parameter); if (!isNode(target)) return { name: null, bindings: [], typeNames: new Set(), destructuresTag: false }; const typeNames = referencedTypeNames(target.typeAnnotation); @@ -376,13 +285,41 @@ function parameterShape(parameter: unknown): ParameterShape { */ function variableAt(context: Context, node: AnyNode): Variable | null { if (node.type !== 'Identifier' || typeof node.name !== 'string') return null; - let scope: Scope | null = context.sourceCode.getScope(node as unknown as ESTree.Node); - while (scope !== null) { - const variable = scope.set.get(node.name); - if (variable !== undefined) return variable; - scope = scope.upper; + return lookupVariable(context, node as unknown as ESTree.Node); +} + +function computedTagKey(context: Context, node: AnyNode): boolean { + if (node.computed !== true || !isNode(node.property)) return false; + const variable = variableAt(context, node.property); + if ( + variable === null || + variable.references.some((reference) => reference.isWrite() && !reference.init) + ) + return false; + return variable.defs.some((definition) => { + if (definition.type !== 'Variable' || definition.node.type !== 'VariableDeclarator') + return false; + const init = unwrapExpression(definition.node.init); + return init?.type === 'Literal' && init.value === TAG_PROPERTY; + }); +} + +function readsParameterTag( + context: Context, + node: AnyNode, + fromParameter: (value: unknown) => boolean, +): boolean { + if (node.type === 'MemberExpression') { + const key = memberName(node); + const isTag = key === TAG_PROPERTY || (key === null && computedTagKey(context, node)); + return isTag && fromParameter(node.object); } - return null; + return ( + node.type === 'VariableDeclarator' && + isNode(node.id) && + patternHasTagKey(node.id) && + fromParameter(node.init) + ); } function discriminatesTag( @@ -415,27 +352,7 @@ function discriminatesTag( }; let found = false; forEachNode(body, (node) => { - if (found) return; - if (node.type === 'MemberExpression') { - let key = memberPropertyName(node, new Set()); - if (key === null && node.computed === true && isNode(node.property)) { - const variable = variableAt(context, node.property); - if ( - variable !== null && - !variable.references.some((reference) => reference.isWrite() && !reference.init) - ) { - for (const definition of variable.defs) { - if (definition.type !== 'Variable' || definition.node.type !== 'VariableDeclarator') - continue; - const init = unwrapExpression(definition.node.init); - if (init?.type === 'Literal' && init.value === TAG_PROPERTY) key = TAG_PROPERTY; - } - } - } - if (key === TAG_PROPERTY && fromParameter(node.object)) found = true; - } else if (node.type === 'VariableDeclarator' && isNode(node.id) && patternHasTagKey(node.id)) { - if (fromParameter(node.init)) found = true; - } + if (!found) found = readsParameterTag(context, node, fromParameter); }); return found; } @@ -449,7 +366,11 @@ function isExitEnvelope(context: Context, parameter: unknown): boolean { if (!isNode(target) || !isNode(target.typeAnnotation)) return false; const type = target.typeAnnotation.typeAnnotation; if (!isNode(type) || type.type !== 'TSTypeReference' || !isNode(type.typeName)) return false; - let root = type.typeName; + return exitTypeReference(context, type.typeName); +} + +function exitTypeReference(context: Context, name: AnyNode): boolean { + let root = name; const parts: string[] = []; while (root.type === 'TSQualifiedName' && isNode(root.left) && isNode(root.right)) { if (typeof root.right.name !== 'string') return false; @@ -459,29 +380,23 @@ function isExitEnvelope(context: Context, parameter: unknown): boolean { const variable = variableAt(context, root); if (variable === null) return false; return variable.defs.some((definition) => { - if (definition.type !== 'ImportBinding') return false; - const declaration = definition.parent; - if (declaration?.type !== 'ImportDeclaration') return false; - const source = declaration.source.value; - const specifier = definition.node; - if (specifier.type === 'ImportSpecifier') { - const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; - return ( - (source === 'effect' && imported === 'Exit' && parts.join('.') === 'Exit') || - (source === 'effect/Exit' && imported === 'Exit' && parts.length === 0) - ); - } - return ( - specifier.type === 'ImportNamespaceSpecifier' && - ((source === 'effect/Exit' && parts.join('.') === 'Exit') || - (source === 'effect' && parts.join('.') === 'Exit.Exit')) - ); + if (definition.type !== 'ImportBinding' || definition.parent?.type !== 'ImportDeclaration') + return false; + return isExitImport(definition.node, definition.parent.source.value, parts.join('.')); }); } +function isExitImport(specifier: ESTree.Node, source: unknown, path: string): boolean { + if (specifier.type === 'ImportSpecifier') { + if (importedName(specifier) !== 'Exit') return false; + return (source === 'effect' && path === 'Exit') || (source === 'effect/Exit' && path === ''); + } + if (specifier.type !== 'ImportNamespaceSpecifier') return false; + return ( + (source === 'effect/Exit' && path === 'Exit') || (source === 'effect' && path === 'Exit.Exit') + ); +} + /** Type identity needs an import, not just a matching printed local type name. */ function importsClassifierType(context: Context, parameter: unknown, expected: string): boolean { let found = false; @@ -491,27 +406,29 @@ function importsClassifierType(context: Context, parameter: unknown, expected: s const root = name.type === 'TSQualifiedName' && isNode(name.left) ? name.left : name; const variable = variableAt(context, root); if (variable === null) return; - for (const definition of variable.defs) { - if (definition.type !== 'ImportBinding') continue; - const imported = definition.node; - if (name.type === 'Identifier' && imported.type === 'ImportSpecifier') { - const key = - imported.imported.type === 'Identifier' - ? imported.imported.name - : imported.imported.value; - if (key === expected) found = true; - } else if ( - name.type === 'TSQualifiedName' && - imported.type === 'ImportNamespaceSpecifier' && - isNode(name.right) && - name.right.name === expected + if ( + variable.defs.some( + (definition) => + definition.type === 'ImportBinding' && + matchesClassifierImport(name, definition.node, expected), ) - found = true; - } + ) + found = true; }); return found; } +function matchesClassifierImport(name: AnyNode, imported: ESTree.Node, expected: string): boolean { + if (name.type === 'Identifier' && imported.type === 'ImportSpecifier') + return importedName(imported) === expected; + return ( + name.type === 'TSQualifiedName' && + imported.type === 'ImportNamespaceSpecifier' && + isNode(name.right) && + name.right.name === expected + ); +} + /** * Climb from a function to the expression that is actually bound to a name: through type assertions * (`(… ) satisfies F`, `… as F`) and through call wrappers (`useCallback(fn, [])`, `useMemo`, `memo`, @@ -529,7 +446,7 @@ function bindingAnchor(node: ESTree.Node): AnyNode { if ( parent.type === 'CallExpression' && Array.isArray(parent.arguments) && - parent.arguments.includes(current) + (parent.arguments as readonly unknown[]).includes(current) ) { current = parent; continue; @@ -548,53 +465,86 @@ function keyName(key: unknown): string | null { return null; } -/** Definition name for a function-like node, taken from the declaration site. */ -function definitionName(node: ESTree.Node): { name: string; node: ESTree.Node } | null { +type Definition = { name: string; node: ESTree.Node }; + +function identifierDefinition(value: unknown): Definition | null { + if (!isNode(value) || value.type !== 'Identifier' || typeof value.name !== 'string') return null; + return { name: value.name, node: value }; +} + +function assignmentDefinition(left: unknown): Definition | null { + const identifier = identifierDefinition(left); + if (identifier !== null) return identifier; + if (!isNode(left) || left.type !== 'MemberExpression') return null; + const name = memberName(left); + return name === null ? null : { name, node: left }; +} + +function anchorDefinition(anchor: AnyNode): Definition | null { + const parent = anchor.parent; + if (!isNode(parent)) return null; + if (parent.type === 'VariableDeclarator' && parent.init === anchor) + return identifierDefinition(parent.id); + if (parent.type === 'AssignmentExpression' && parent.right === anchor) + return assignmentDefinition(parent.left); + return propertyDefinition(parent, anchor); +} + +function propertyDefinition(parent: AnyNode, anchor: AnyNode): Definition | null { + if (!['Property', 'PropertyDefinition', 'MethodDefinition'].includes(parent.type)) return null; + if (parent.value !== anchor || parent.computed === true) return null; + const name = keyName(parent.key); + return name === null ? null : { name, node: parent.key as ESTree.Node }; +} + +/** Prefer declaration-site names over internal named function expressions. */ +function definitionName(node: ESTree.Node): Definition | null { const candidate = node as unknown as AnyNode; - if ( - candidate.type === 'FunctionDeclaration' && - isNode(candidate.id) && - typeof candidate.id.name === 'string' - ) { - return { name: candidate.id.name, node: candidate.id as unknown as ESTree.Node }; + if (candidate.type === 'FunctionDeclaration') { + const id = candidate.id; + if (isNode(id) && typeof id.name === 'string') return { name: id.name, node: id }; } - const anchor = bindingAnchor(node); - const parent = (anchor as { parent?: unknown }).parent; - if (isNode(parent)) { - if (parent.type === 'VariableDeclarator' && parent.init === anchor) { - const id = parent.id; - if (isNode(id) && id.type === 'Identifier' && typeof id.name === 'string') { - return { name: id.name, node: id as unknown as ESTree.Node }; - } - } else if ( - (parent.type === 'Property' || - parent.type === 'PropertyDefinition' || - parent.type === 'MethodDefinition') && - parent.value === anchor && - parent.computed !== true - ) { - const name = keyName(parent.key); - if (name !== null) return { name, node: parent.key as unknown as ESTree.Node }; - } else if (parent.type === 'AssignmentExpression' && parent.right === anchor) { - const left = parent.left; - if (isNode(left) && left.type === 'Identifier' && typeof left.name === 'string') { - return { name: left.name, node: left as unknown as ESTree.Node }; - } - if (isNode(left) && left.type === 'MemberExpression') { - const property = memberPropertyName(left, new Set()); - if (property !== null) return { name: property, node: left as unknown as ESTree.Node }; - } - } - } - // `const f = function named() {}`, `export default function () {}`, inline callbacks. - if ( - candidate.type === 'FunctionExpression' && - isNode(candidate.id) && - typeof candidate.id.name === 'string' - ) { - return { name: candidate.id.name, node: candidate.id as unknown as ESTree.Node }; + const definition = anchorDefinition(bindingAnchor(node)); + if (definition !== null) return definition; + if (candidate.type !== 'FunctionExpression') return null; + const id = candidate.id; + return isNode(id) && typeof id.name === 'string' ? { name: id.name, node: id } : null; +} + +function discriminatedParameter( + context: Context, + raw: AnyNode, + parameter: unknown, + options: RuleOptions, +): string | null { + if (isExitEnvelope(context, parameter)) return null; + const shape = parameterShape(parameter); + const typeMatches = [...shape.typeNames].some((type) => options.errorParameterPattern.test(type)); + const errorBindings = shape.bindings.filter((binding) => + options.errorParameterPattern.test(binding), + ); + if (shape.destructuresTag && (typeMatches || errorBindings.length > 0)) + return shape.name ?? '{ _tag }'; + if (!isNode(parameter)) return null; + return ( + (typeMatches ? shape.bindings : errorBindings).find((binding) => + discriminatesTag(context, raw.body, binding, parameter), + ) ?? null + ); +} + +function classifierInput( + context: Context, + parameters: readonly unknown[], + options: RuleOptions, +): string | undefined { + for (const parameter of parameters) { + const matched = options.classifierInputTypes.find((type) => + importsClassifierType(context, parameter, type), + ); + if (matched !== undefined) return matched; } - return null; + return undefined; } export const rule = defineRule({ @@ -672,77 +622,65 @@ export const rule = defineRule({ return false; }; - const inspect = (node: ESTree.Node): void => { - if (reported.has(node)) return; - const raw = node as unknown as AnyNode; - const parameters = Array.isArray(raw.params) ? raw.params : []; - const shapes = parameters.map((parameter) => parameterShape(parameter)); - const definition = definitionName(node); - const name = definition?.name ?? null; + const inspectParameters = (node: ESTree.Node, definition: Definition | null): void => { + const anonymous = definition === null; + if (anonymous && !options.includeInlineHandlers) return; const target = definition?.node ?? node; - if (name !== null && options.allowedNames.includes(name)) return; - const anonymous = name === null; - if (anonymous && insideReportedFunction(node)) return; - - // Axis 1 — the definition is named like a classifier. - if (name !== null && options.namePattern.test(name)) { + const name = definition?.name ?? '(anonymous)'; + const raw = node as AnyNode; + const parameters: readonly unknown[] = Array.isArray(raw.params) ? raw.params : []; + const matched = classifierInput(context, parameters, options); + if (matched !== undefined) { reported.add(node); - context.report({ node: target, messageId: 'namedClassifier', data: { name } }); + context.report({ + node: target, + messageId: 'classifierInput', + data: { name, type: matched }, + }); return; } + inspectDiscrimination(node, parameters, target, name, anonymous); + }; - // Axis 2 — a parameter is annotated with the erased-union projection type. - for (const [index, shape] of shapes.entries()) { - const matched = options.classifierInputTypes.find((type) => - importsClassifierType(context, parameters[index], type), - ); - if (matched === undefined) continue; - if (anonymous && !options.includeInlineHandlers) continue; + const inspectDiscrimination = ( + node: ESTree.Node, + parameters: readonly unknown[], + target: ESTree.Node, + name: string, + anonymous: boolean, + ): void => { + if (!options.detectTagDiscrimination) return; + for (const parameter of parameters) { + const binding = discriminatedParameter(context, node as AnyNode, parameter, options); + if (binding === null) continue; reported.add(node); context.report({ node: target, - messageId: 'classifierInput', - data: { name: name ?? '(anonymous)', type: matched }, + messageId: anonymous ? 'inlineClassifier' : 'tagDiscriminator', + data: { name, parameter: binding }, }); return; } + }; - if (!options.detectTagDiscrimination) return; - if (anonymous && !options.includeInlineHandlers) return; - - // Axis 3 — an error-shaped parameter whose `_tag` this function discriminates. - for (const [index, shape] of shapes.entries()) { - if (isExitEnvelope(context, parameters[index])) continue; - const typeMatches = [...shape.typeNames].some((type) => - options.errorParameterPattern.test(type), - ); - if ( - shape.destructuresTag && - (typeMatches || - shape.bindings.some((binding) => options.errorParameterPattern.test(binding))) - ) { - reported.add(node); - context.report({ - node: target, - messageId: anonymous ? 'inlineClassifier' : 'tagDiscriminator', - data: { name: name ?? '(anonymous)', parameter: shape.name ?? '{ _tag }' }, - }); - return; - } - for (const binding of shape.bindings) { - if (!options.errorParameterPattern.test(binding) && !typeMatches) continue; - const parameter = parameters[index]; - if (!isNode(parameter) || !discriminatesTag(context, raw.body, binding, parameter)) - continue; - reported.add(node); - context.report({ - node: target, - messageId: anonymous ? 'inlineClassifier' : 'tagDiscriminator', - data: { name: name ?? '(anonymous)', parameter: binding }, - }); - return; - } + const inspect = (node: ESTree.Node): void => { + if (reported.has(node)) return; + const definition = definitionName(node); + if (definition === null) { + if (!insideReportedFunction(node)) inspectParameters(node, null); + return; + } + if (options.allowedNames.includes(definition.name)) return; + if (options.namePattern.test(definition.name)) { + reported.add(node); + context.report({ + node: definition.node, + messageId: 'namedClassifier', + data: { name: definition.name }, + }); + return; } + inspectParameters(node, definition); }; return { diff --git a/app/tools/oxlint/effect-native/rules/no-runtime-construction-outside-root.ts b/app/tools/oxlint/effect-native/rules/no-runtime-construction-outside-root.ts index 4493e4d28..00488252e 100644 --- a/app/tools/oxlint/effect-native/rules/no-runtime-construction-outside-root.ts +++ b/app/tools/oxlint/effect-native/rules/no-runtime-construction-outside-root.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * Audit finding: **A1** — "Establish one process-level Layer and ManagedRuntime composition model" * (`docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`). A1 records "four runtime roots, 15+ manually @@ -67,21 +68,23 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree } from '@oxlint/plugins'; import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; +import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; +import { stringArray } from '../shared/options.ts'; +import { lookupVariable, resolvesToImport } from '../shared/bindings.ts'; +import { unwrapNode, keyName, memberName as sharedMemberName } from '../shared/ast.ts'; +import { + collectNamespaceLocals as sharedNamespaceLocals, + collectDirectMemberImports as sharedDirectMembers, +} from '../shared/imports.ts'; +import { isNonReferencePosition as nonReferencePosition } from '../shared/reference-positions.ts'; +import { nodeKey } from '../shared/reporting.ts'; const EFFECT_ROOT_MODULE = 'effect'; const EFFECT_SUBMODULE_PREFIX = `${EFFECT_ROOT_MODULE}/`; -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production `include`/`rootFiles` defaults - * instead of forcing the fixture config to pass loosened options (which `run-on-repo.mts` reuses). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; - const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; const DEFAULT_EXCLUDE: readonly string[] = []; @@ -136,18 +139,8 @@ interface RuleOptions { readonly includeTests: boolean; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); const maxPerRoot = record.maxPerRoot; return { include: stringArray(record.include, DEFAULT_INCLUDE), @@ -163,15 +156,6 @@ function readOptions(context: Context): RuleOptions { }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - /** `["ManagedRuntime.make", "Layer.build"]` → `{ ManagedRuntime: {make}, Layer: {build} }`. */ function parseMembers(members: readonly string[]): ReadonlyMap> { const byNamespace = new Map>(); @@ -194,10 +178,6 @@ function moduleExportName(node: ESTree.Node): string | null { return null; } -function importedName(specifier: ESTree.ImportSpecifier): string | null { - return moduleExportName(specifier.imported as ESTree.Node); -} - /** The last path segment of `effect/ManagedRuntime`, or `null` for anything else. */ function effectSubmodule(source: string): string | null { if (!source.startsWith(EFFECT_SUBMODULE_PREFIX)) return null; @@ -240,29 +220,18 @@ function collectNamespaceLocals( reexportModules: readonly string[], typeOnly: ReadonlySet, ): NamespaceLocals { - const namespaces = new Map(); - const barrels = new Set(); - for (const [local, namespace] of bindings.namespaces) { - if (tracked.has(namespace) && !typeOnly.has(local)) namespaces.set(local, namespace); - } - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - if (statement.importKind === 'type') continue; - const source = statement.source.value; - const isEffectRoot = source === EFFECT_ROOT_MODULE; - const isReexport = matchesGlobs(source, reexportModules); - if (!isEffectRoot && !isReexport) continue; - for (const specifier of statement.specifiers) { - if (typeOnly.has(specifier.local.name)) continue; - if (specifier.type === 'ImportNamespaceSpecifier') barrels.add(specifier.local.name); - else if (specifier.type === 'ImportSpecifier') { - const imported = importedName(specifier); - if (imported !== null && tracked.has(imported)) - namespaces.set(specifier.local.name, imported); - } - } - } - return { namespaces, barrels }; + const { namespaced, barrel } = sharedNamespaceLocals( + program, + bindings, + tracked, + reexportModules, + { + valueOnly: true, + excludedLocals: typeOnly, + }, + ); + for (const local of typeOnly) namespaced.delete(local); + return { namespaces: namespaced, barrels: barrel }; } /** `import { make as boot } from "effect/ManagedRuntime"` → `boot` → `ManagedRuntime.make`. */ @@ -271,23 +240,19 @@ function collectDirectMemberImports( byNamespace: ReadonlyMap>, typeOnly: ReadonlySet, ): ReadonlyMap { - const locals = new Map(); - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - if (statement.importKind === 'type') continue; - const namespace = effectSubmodule(statement.source.value); - if (namespace === null) continue; - const members = byNamespace.get(namespace); - if (members === undefined) continue; - for (const specifier of statement.specifiers) { - if (specifier.type !== 'ImportSpecifier') continue; - if (typeOnly.has(specifier.local.name)) continue; - const imported = importedName(specifier); - if (imported !== null && members.has(imported)) - locals.set(specifier.local.name, `${namespace}.${imported}`); - } - } - return locals; + return new Map( + [ + ...sharedDirectMembers( + program, + byNamespace, + { + valueOnly: true, + excludedLocals: typeOnly, + }, + effectSubmodule, + ), + ].map(([local, { namespace, member }]) => [local, `${namespace}.${member}`]), + ); } interface Finding { @@ -301,137 +266,66 @@ interface Finding { * `export { make as makeRuntime } from "effect/ManagedRuntime"` (re-export with a source) and * `import { make } from "effect/ManagedRuntime"; export { make }` (re-export of a local binding). */ -function collectReexportedMembers( - program: ESTree.Program, +function exportedMember( + local: string, + namespace: string | null, + members: ReadonlySet | undefined, + directMembers: ReadonlyMap, +): string | undefined { + if (namespace !== null && members !== undefined) + return members.has(local) ? `${namespace}.${local}` : undefined; + return directMembers.get(local); +} + +function reexportFindings( + statement: ESTree.ExportNamedDeclaration, byNamespace: ReadonlyMap>, directMembers: ReadonlyMap, -): readonly Finding[] { +): Finding[] { + const namespace = statement.source == null ? null : effectSubmodule(statement.source.value); + const members = namespace === null ? undefined : byNamespace.get(namespace); + if (statement.source != null && members === undefined) return []; const findings: Finding[] = []; - for (const statement of program.body) { - if (statement.type !== 'ExportNamedDeclaration') continue; - if (statement.exportKind === 'type') continue; - const source = statement.source; - const namespace = - source === null || source === undefined ? null : effectSubmodule(source.value); - const members = namespace === null ? undefined : byNamespace.get(namespace); - if (source !== null && source !== undefined && members === undefined) continue; - for (const specifier of statement.specifiers) { - if (specifier.exportKind === 'type') continue; - const local = moduleExportName(specifier.local as ESTree.Node); - if (local === null) continue; - if (namespace !== null && members !== undefined) { - // `export { make } from "effect/ManagedRuntime"`. - if (members.has(local)) - findings.push({ - node: specifier, - member: `${namespace}.${local}`, - start: specifier.start, - }); - continue; - } - // `export { make }` where `make` is a direct member import in this file. - const qualified = directMembers.get(local); - if (qualified !== undefined) - findings.push({ node: specifier, member: qualified, start: specifier.start }); - } + for (const specifier of statement.specifiers) { + if (specifier.exportKind === 'type') continue; + const local = moduleExportName(specifier.local as ESTree.Node); + if (local === null) continue; + const member = exportedMember(local, namespace, members, directMembers); + if (member !== undefined) findings.push({ node: specifier, member, start: specifier.start }); } return findings; } -/** A statically known string key: `"make"` or a no-substitution `` `make` ``. */ -function staticStringOf(node: ESTree.Node): string | null { - if (node.type === 'Literal' && typeof node.value === 'string') return node.value; - if (node.type === 'TemplateLiteral' && node.expressions.length === 0) { - const quasi = node.quasis[0]; - if (quasi === undefined) return null; - const cooked = quasi.value.cooked; - return typeof cooked === 'string' ? cooked : quasi.value.raw; - } - return null; +function collectReexportedMembers( + program: ESTree.Program, + byNamespace: ReadonlyMap>, + directMembers: ReadonlyMap, +): readonly Finding[] { + return program.body.flatMap((statement) => + statement.type === 'ExportNamedDeclaration' && statement.exportKind !== 'type' + ? reexportFindings(statement, byNamespace, directMembers) + : [], + ); } /** Non-computed `.make`, computed `["make"]`, computed `` [`make`] ``. */ function memberName(node: ESTree.MemberExpression): string | null { - if (!node.computed) return node.property.type === 'Identifier' ? node.property.name : null; - return staticStringOf(node.property as ESTree.Node); + return sharedMemberName(node, { templates: true, rawTemplates: true }); } /** `{ make: boot }`, `{ "make": boot }`, `{ ["make"]: boot }`, `` { [`make`]: boot } ``. */ function propertyKeyName(property: Extract): string | null { - const key = property.key as ESTree.Node; - if (!property.computed) return key.type === 'Identifier' ? key.name : staticStringOf(key); - return staticStringOf(key); + return keyName(property.key, property.computed, { templates: true, rawTemplates: true }); } /** Peel `as` / `satisfies` / `!` / `` / parentheses / optional-chain wrappers off an expression. */ function unwrapExpression(node: ESTree.Node): ESTree.Node { - let current: ESTree.Node = node; - for (let depth = 0; depth < 8; depth += 1) { - if (!TRANSPARENT_EXPRESSIONS.has(current.type)) return current; - const inner = (current as { expression?: ESTree.Node }).expression; - if (inner === undefined || inner === null) return current; - current = inner; - } - return current; -} - -function lookupVariable( - context: Context, - identifier: Extract, -): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(identifier); - while (scope !== null) { - const variable = scope.set.get(identifier.name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; -} - -/** - * `true` when the identifier still resolves to an `import` binding. Unresolved names fall back to - * `true` because the module-level import declaration already proved the binding exists; only a local - * shadow (parameter, `const`, catch clause, …) rejects the match. - */ -function resolvesToImport( - context: Context, - identifier: Extract, -): boolean { - const variable = lookupVariable(context, identifier); - if (variable === null) return true; - if (variable.defs.length === 0) return true; - return variable.defs.some((definition) => definition.type === 'ImportBinding'); + return unwrapNode(node, { wrappers: TRANSPARENT_EXPRESSIONS, maxDepth: 8 }); } /** Identifier positions that are declarations, property keys or type references — never runtime uses. */ function isNonReferencePosition(node: Extract): boolean { - const parent = node.parent; - if (parent === null || parent === undefined) return true; - if (TYPE_POSITION_PARENTS.has(parent.type)) return true; - switch (parent.type) { - case 'ImportSpecifier': - case 'ImportDefaultSpecifier': - case 'ImportNamespaceSpecifier': - case 'ExportSpecifier': { - return true; - } - case 'MemberExpression': { - return parent.property === node && !parent.computed; - } - case 'Property': - case 'PropertyDefinition': - case 'MethodDefinition': { - return parent.key === node && !parent.computed; - } - default: { - return false; - } - } -} - -/** Stable key for an AST node: oxlint may hand out fresh wrapper objects for the same node. */ -function nodeKey(node: ESTree.Node): string { - return `${node.start}:${node.end}`; + return nonReferencePosition(node, { nonReferenceParents: TYPE_POSITION_PARENTS }); } type ResolvedBinding = @@ -577,20 +471,72 @@ export const rule = defineRule({ for (const definition of variable.defs) { if (definition.type === 'ImportBinding') return fromImports(identifier.name); if (definition.type !== 'Variable') continue; - const key = nodeKey(definition.node); - const alias = aliasDeclarators.get(key); + const alias = aliasDeclarators.get(nodeKey(definition.node)); if (alias !== undefined) return resolveBinding(alias, depth + 1); - const destructured = destructureDeclarators.get(key); - if (destructured === undefined) continue; - const property = destructured.keys.get(identifier.name); - if (property === undefined || !tracked.has(property)) continue; - const source = resolveBinding(destructured.source, depth + 1); - if (source !== null && source.kind === 'barrel') - return { kind: 'namespace', namespace: property }; + const resolved = resolveDeclarator(definition.node, identifier.name, depth); + if (resolved !== null) return resolved; } return null; }; + function resolveDeclarator(node: ESTree.Node, name: string, depth: number): ResolvedBinding { + const key = nodeKey(node); + const destructured = destructureDeclarators.get(key); + if (destructured === undefined) return null; + const property = destructured.keys.get(name); + if (property === undefined || !tracked.has(property)) return null; + const source = resolveBinding(destructured.source, depth + 1); + if (source !== null && source.kind === 'barrel') + return { kind: 'namespace', namespace: property }; + return null; + } + + function collectMemberFindings(found: Finding[]): void { + for (const candidate of memberCandidates) { + const resolved = resolveBinding(candidate.object, 0); + if (resolved === null) continue; + const namespace = + candidate.viaBarrel === null + ? resolved.kind === 'namespace' + ? resolved.namespace + : null + : resolved.kind === 'barrel' + ? candidate.viaBarrel + : null; + if (namespace === null || byNamespace.get(namespace)?.has(candidate.member) !== true) + continue; + found.push({ + node: candidate.node, + member: `${namespace}.${candidate.member}`, + start: candidate.node.start, + }); + } + } + + function collectDestructureFindings(found: Finding[]): void { + for (const candidate of destructureCandidates) { + const resolved = resolveBinding(candidate.source, 0); + if (resolved === null || resolved.kind !== 'namespace') continue; + if (byNamespace.get(resolved.namespace)?.has(candidate.key) !== true) continue; + found.push({ + node: candidate.node, + member: `${resolved.namespace}.${candidate.key}`, + start: candidate.node.start, + }); + } + } + + function collectPlainFindings(found: Finding[]): void { + for (const candidate of plainCandidates) { + if (!resolvesToImport(context, candidate.node)) continue; + found.push({ + node: candidate.node, + member: candidate.member, + start: candidate.node.start, + }); + } + } + return { MemberExpression(node) { const member = memberName(node); @@ -647,47 +593,9 @@ export const rule = defineRule({ 'Program:exit'() { const found: Finding[] = [...reexports]; - for (const candidate of memberCandidates) { - const resolved = resolveBinding(candidate.object, 0); - if (resolved === null) continue; - if (candidate.viaBarrel === null) { - if (resolved.kind !== 'namespace') continue; - if (byNamespace.get(resolved.namespace)?.has(candidate.member) !== true) continue; - found.push({ - node: candidate.node, - member: `${resolved.namespace}.${candidate.member}`, - start: candidate.node.start, - }); - continue; - } - if (resolved.kind !== 'barrel') continue; - if (byNamespace.get(candidate.viaBarrel)?.has(candidate.member) !== true) continue; - found.push({ - node: candidate.node, - member: `${candidate.viaBarrel}.${candidate.member}`, - start: candidate.node.start, - }); - } - - for (const candidate of destructureCandidates) { - const resolved = resolveBinding(candidate.source, 0); - if (resolved === null || resolved.kind !== 'namespace') continue; - if (byNamespace.get(resolved.namespace)?.has(candidate.key) !== true) continue; - found.push({ - node: candidate.node, - member: `${resolved.namespace}.${candidate.key}`, - start: candidate.node.start, - }); - } - - for (const candidate of plainCandidates) { - if (!resolvesToImport(context, candidate.node)) continue; - found.push({ - node: candidate.node, - member: candidate.member, - start: candidate.node.start, - }); - } + collectMemberFindings(found); + collectDestructureFindings(found); + collectPlainFindings(found); if (found.length === 0) return; found.sort((left, right) => left.start - right.start); diff --git a/app/tools/oxlint/effect-native/rules/no-scattered-browser-effect-run.ts b/app/tools/oxlint/effect-native/rules/no-scattered-browser-effect-run.ts index a44097944..a520b049b 100644 --- a/app/tools/oxlint/effect-native/rules/no-scattered-browser-effect-run.ts +++ b/app/tools/oxlint/effect-native/rules/no-scattered-browser-effect-run.ts @@ -49,6 +49,7 @@ import { defineRule } from '@oxlint/plugins'; import type { Context, ESTree, Scope } from '@oxlint/plugins'; +import { memberName as staticMemberName, unwrapNode } from '../shared/ast.ts'; import { collectEffectBindings } from '../shared/effect-imports.ts'; import type { EffectBindings } from '../shared/effect-imports.ts'; import { globToRegExp, isTestFile, matchesAny } from '../shared/paths.ts'; @@ -138,15 +139,7 @@ function moduleExportName(name: ESTree.Node): string | null { /** `x.member`, `x["member"]` and a substitution-free `x[`member`]` → `"member"`; dynamic → `null`. */ function memberName(node: ESTree.MemberExpression): string | null { - if (!node.computed) return node.property.type === 'Identifier' ? node.property.name : null; - const property = node.property; - if (property.type === 'Literal') - return typeof property.value === 'string' ? property.value : null; - if (property.type === 'TemplateLiteral' && property.expressions.length === 0) { - const quasi = property.quasis[0]; - return quasi === undefined ? null : (quasi.value.cooked ?? quasi.value.raw); - } - return null; + return staticMemberName(node, { templates: true, rawTemplates: true }); } /** True when `name` at `node` still resolves to the module-level import (no shadowing binding). */ @@ -163,6 +156,13 @@ function resolvesToModuleImport(context: Context, node: ESTree.Node, name: strin return true; } +function runnerImportName( + specifier: ESTree.ImportDeclaration['specifiers'][number], +): string | null { + if (specifier.type !== 'ImportSpecifier') return specifier.local.name; + return specifier.importKind === 'type' ? null : moduleExportName(specifier.imported); +} + /** Locals bound to an ad hoc runner by name, default or namespace import; local → imported name. */ function collectRunnerImports( program: ESTree.Program, @@ -172,16 +172,9 @@ function collectRunnerImports( for (const statement of program.body) { if (statement.type !== 'ImportDeclaration' || statement.importKind === 'type') continue; for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportSpecifier') { - if (specifier.importKind === 'type') continue; - const imported = moduleExportName(specifier.imported); - if (imported !== null && runnerNames.includes(imported)) - locals.set(specifier.local.name, imported); - continue; - } - // `import runEffectRequest from "..."` / `import * as runEffectRequest from "..."`. - if (runnerNames.includes(specifier.local.name)) - locals.set(specifier.local.name, specifier.local.name); + const imported = runnerImportName(specifier); + if (imported !== null && runnerNames.includes(imported)) + locals.set(specifier.local.name, imported); } } return locals; @@ -223,13 +216,7 @@ function collectRootNamespaceImports( } function unwrap(node: ESTree.Node): ESTree.Node { - let current = node; - while (VALUE_WRAPPERS.has(current.type)) { - const expression = (current as { expression?: ESTree.Node }).expression; - if (expression === undefined) break; - current = expression; - } - return current; + return unwrapNode(node, { wrappers: VALUE_WRAPPERS }); } function ancestorsOf(node: ESTree.Node): ESTree.Node[] { @@ -239,35 +226,65 @@ function ancestorsOf(node: ESTree.Node): ESTree.Node[] { return ancestors.reverse(); } -/** The boundary key (`queryFn`, `loader`, ...) owning the nearest enclosing function, if any. */ -function boundaryKeyFor( - context: Context, - node: ESTree.Node, +const BOUNDARY_OWNER_TYPES = new Set(['Property', 'VariableDeclarator']); + +/** Undefined continues the ancestor search; null stops without a boundary. */ +function ancestorBoundary( + ancestor: ESTree.Node, + owner: ESTree.Node, boundaryKeys: readonly string[], -): string | null { +): string | null | undefined { + if (ancestor.type === 'Property' && !ancestor.computed) { + const key = moduleExportName(ancestor.key); + return key !== null && boundaryKeys.includes(key) ? key : null; + } + if (ancestor.type === 'VariableDeclarator' && ancestor.id.type === 'Identifier') + return boundaryKeys.includes(ancestor.id.name) ? ancestor.id.name : null; + if (!FUNCTION_TYPES.has(ancestor.type)) return undefined; + return BOUNDARY_OWNER_TYPES.has(owner.type) ? undefined : null; +} + +/** The boundary owning the nearest enclosing function, if any. */ +function boundaryKeyFor(node: ESTree.Node, boundaryKeys: readonly string[]): string | null { const ancestors = ancestorsOf(node); for (let index = ancestors.length - 1; index >= 0; index -= 1) { const ancestor = ancestors[index]; if (ancestor === undefined) continue; - const owner = index > 0 ? ancestors[index - 1] : undefined; + const owner = ancestors[index - 1]; if (owner === undefined) return null; - if (ancestor.type === 'Property' && !ancestor.computed) { - const key = moduleExportName(ancestor.key); - if (key !== null && boundaryKeys.includes(key)) return key; - return null; - } - if (ancestor.type === 'VariableDeclarator' && ancestor.id.type === 'Identifier') { - return boundaryKeys.includes(ancestor.id.name) ? ancestor.id.name : null; - } - if (FUNCTION_TYPES.has(ancestor.type)) { - // Only the function directly owned by a boundary property/binding counts. - if (owner.type === 'Property' || owner.type === 'VariableDeclarator') continue; - return null; - } + const boundary = ancestorBoundary(ancestor, owner, boundaryKeys); + if (boundary !== undefined) return boundary; } return null; } +const DECLARATION_PARENTS = new Set([ + 'ImportSpecifier', + 'ImportDefaultSpecifier', + 'ImportNamespaceSpecifier', + 'ExportSpecifier', +]); +const CLASS_KEY_PARENTS = new Set(['PropertyDefinition', 'MethodDefinition', 'AccessorProperty']); + +function isNonReferenceKey( + node: Extract, + parent: ESTree.Node, +): boolean { + if (parent.type === 'MemberExpression') return !parent.computed && parent.property === node; + if (parent.type === 'Property') + return !parent.computed && parent.key === node && !parent.shorthand; + if (!CLASS_KEY_PARENTS.has(parent.type)) return false; + const property = parent as ESTree.PropertyDefinition; + return property.key === node && !property.computed; +} + +function isRunnerReference(node: Extract): boolean { + const parent = node.parent; + if (parent === null) return false; + if (DECLARATION_PARENTS.has(parent.type) || TYPE_POSITION_PARENTS.has(parent.type)) return false; + return !isNonReferenceKey(node, parent); +} + export const rule = defineRule({ meta: { defaultOptions: [ @@ -327,26 +344,32 @@ export const rule = defineRule({ let rootNamespaces: ReadonlySet = new Set(); const reported: Array<{ readonly start: number; readonly end: number }> = []; - /** `Effect.runPromise` / `Effect["runPromise"]` / `effect.Effect.runPromise` on real imports. */ + const importedNamespace = (node: Extract): boolean => { + const namespace = bindings.namespaces.get(node.name); + return ( + namespace !== undefined && + RUNNER_NAMESPACES.has(namespace) && + resolvesToModuleImport(context, node, node.name) + ); + }; + + const rootRunSeam = (object: ESTree.MemberExpression, member: string): string | null => { + const root = unwrap(object.object); + if (root.type !== 'Identifier' || !rootNamespaces.has(root.name)) return null; + const namespace = memberName(object); + if (namespace === null || !RUNNER_NAMESPACES.has(namespace)) return null; + if (!resolvesToModuleImport(context, root, root.name)) return null; + return `${root.name}.${namespace}.${member}`; + }; + + /** Runners on a directly imported namespace or on the root Effect namespace. */ const runSeam = (node: ESTree.MemberExpression): string | null => { const object = unwrap(node.object); const member = memberName(node); if (member === null || !RUN_MEMBER.test(member)) return null; - if (object.type === 'Identifier') { - const namespace = bindings.namespaces.get(object.name); - if (namespace === undefined || !RUNNER_NAMESPACES.has(namespace)) return null; - if (!resolvesToModuleImport(context, object, object.name)) return null; - return `${object.name}.${member}`; - } - if (object.type === 'MemberExpression') { - const root = unwrap(object.object); - if (root.type !== 'Identifier' || !rootNamespaces.has(root.name)) return null; - const namespace = memberName(object); - if (namespace === null || !RUNNER_NAMESPACES.has(namespace)) return null; - if (!resolvesToModuleImport(context, root, root.name)) return null; - return `${root.name}.${namespace}.${member}`; - } - return null; + if (object.type === 'Identifier') + return importedNamespace(object) ? `${object.name}.${member}` : null; + return object.type === 'MemberExpression' ? rootRunSeam(object, member) : null; }; /** `api.runEffectRequest(...)` where `api` is a namespace import of the runner's module. */ @@ -399,7 +422,7 @@ export const rule = defineRule({ const site = siteOf(node); if (reported.some((range) => site.start >= range.start && site.end <= range.end)) return; reported.push(site); - const key = boundaryKeyFor(context, node, options.boundaryKeys); + const key = boundaryKeyFor(node, options.boundaryKeys); if (key === null) { context.report({ data: { runner }, messageId: 'adHocRun', node }); } else { @@ -407,6 +430,45 @@ export const rule = defineRule({ } }; + const reportDestructuredRunner = ( + property: ESTree.ObjectPattern['properties'][number], + namespace: string, + ): void => { + if (property.type !== 'Property' || property.computed) return; + const key = moduleExportName(property.key); + if (key === null || !RUN_MEMBER.test(key)) return; + context.report({ + data: { runner: `${namespace}.${key}` }, + messageId: 'destructuredRunner', + node: property, + }); + }; + + const isRunnerExport = ( + specifier: ESTree.ExportSpecifier, + source: string | null, + local: string, + ): boolean => { + if (source === null) + return ( + (runnerImports.has(local) || importedRunMember(local)) && + resolvesToModuleImport(context, specifier.local, local) + ); + const exported = moduleExportName(specifier.exported); + return [local, exported].some((name) => name !== null && options.runnerNames.includes(name)); + }; + + const reportRunnerExport = (specifier: ESTree.ExportSpecifier, source: string | null): void => { + if (specifier.exportKind === 'type') return; + const local = moduleExportName(specifier.local); + if (local === null || !isRunnerExport(specifier, source, local)) return; + context.report({ + data: { runner: moduleExportName(specifier.exported) ?? local }, + messageId: 'runnerReexport', + node: specifier, + }); + }; + return { Program(node) { bindings = collectEffectBindings(node); @@ -421,29 +483,7 @@ export const rule = defineRule({ }, Identifier(node) { if (!runnerImports.has(node.name) && !importedRunMember(node.name)) return; - const parent = ancestorsOf(node).at(-1); - if (parent === undefined) return; - // Declaration and re-export sites are handled by their own visitors. - if (parent.type === 'ImportSpecifier' || parent.type === 'ImportDefaultSpecifier') return; - if (parent.type === 'ImportNamespaceSpecifier' || parent.type === 'ExportSpecifier') return; - if (parent.type === 'MemberExpression' && !parent.computed && parent.property === node) - return; - if ( - parent.type === 'Property' && - !parent.computed && - parent.key === node && - !parent.shorthand - ) - return; - if ( - (parent.type === 'PropertyDefinition' || - parent.type === 'MethodDefinition' || - parent.type === 'AccessorProperty') && - parent.key === node && - !parent.computed - ) - return; - if (TYPE_POSITION_PARENTS.has(parent.type)) return; + if (!isRunnerReference(node)) return; if (!resolvesToModuleImport(context, node, node.name)) return; reportSite(node, node.name); }, @@ -451,42 +491,13 @@ export const rule = defineRule({ if (node.id.type !== 'ObjectPattern') return; const init = node.init; if (init === null || init === undefined || init.type !== 'Identifier') return; - const namespace = bindings.namespaces.get(init.name); - if (namespace === undefined || !RUNNER_NAMESPACES.has(namespace)) return; - if (!resolvesToModuleImport(context, init, init.name)) return; - for (const property of node.id.properties) { - if (property.type !== 'Property' || property.computed) continue; - const key = moduleExportName(property.key); - if (key === null || !RUN_MEMBER.test(key)) continue; - context.report({ - data: { runner: `${init.name}.${key}` }, - messageId: 'destructuredRunner', - node: property, - }); - } + if (!importedNamespace(init)) return; + for (const property of node.id.properties) reportDestructuredRunner(property, init.name); }, ExportNamedDeclaration(node) { if (node.exportKind === 'type') return; - const source = node.source?.value ?? null; - // Namespace barrels are not run seams; only known runner exports are governed. - for (const specifier of node.specifiers) { - if (specifier.exportKind === 'type') continue; - const local = moduleExportName(specifier.local); - const exported = moduleExportName(specifier.exported); - if (local === null) continue; - const named = [local, exported].filter((name): name is string => name !== null); - const isRunner = - source === null - ? (runnerImports.has(local) || importedRunMember(local)) && - resolvesToModuleImport(context, specifier.local, local) - : named.some((name) => options.runnerNames.includes(name)); - if (!isRunner) continue; - context.report({ - data: { runner: isRunner ? (exported ?? local) : local }, - messageId: 'runnerReexport', - node: specifier, - }); - } + for (const specifier of node.specifiers) + reportRunnerExport(specifier, node.source?.value ?? null); }, ExportAllDeclaration(node) { if (node.exportKind === 'type') return; diff --git a/app/tools/oxlint/effect-native/rules/no-sequential-independent-yields.ts b/app/tools/oxlint/effect-native/rules/no-sequential-independent-yields.ts index 5485650cc..509ce2286 100644 --- a/app/tools/oxlint/effect-native/rules/no-sequential-independent-yields.ts +++ b/app/tools/oxlint/effect-native/rules/no-sequential-independent-yields.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * effect-native/no-sequential-independent-yields * @@ -76,18 +77,18 @@ import { defineRule } from '@oxlint/plugins'; import type { Context, ESTree } from '@oxlint/plugins'; import { - collectEffectBindings, - effectMember, - type EffectBindings, -} from '../shared/effect-imports.ts'; -import { globToRegExp, isScriptFile, isTestFile, normalisePath } from '../shared/paths.ts'; - -/** Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; - -const EFFECT_NAMESPACE = 'Effect'; -const EFFECT_ROOT_MODULE = 'effect'; -const EFFECT_EFFECT_MODULE = /^effect\/(?:.*\/)?Effect$/u; + asNode as sharedAsNode, + childrenOf, + memberName as sharedMemberName, +} from '../shared/ast.ts'; +import { bindingPath, isGenCallee as sharedIsGenCallee } from '../shared/effect-identity.ts'; +import { + bindingsWithExtraModules, + collectRootNamespaces, + collectNamedImports, +} from '../shared/imports.ts'; +import { booleanOption as boolean, stringArray, safeRegExp } from '../shared/options.ts'; +import { isScriptFile, isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; const DEFAULT_INCLUDE: readonly string[] = ['apps/**', 'verticals/**', 'packages/**']; const DEFAULT_IGNORE: readonly string[] = [ @@ -142,22 +143,8 @@ interface RuleOptions { readonly effectModules: readonly string[]; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - -function boolean(value: unknown, fallback: boolean): boolean { - return typeof value === 'boolean' ? value : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { include: stringArray(record.include, DEFAULT_INCLUDE), ignore: stringArray(record.ignore, DEFAULT_IGNORE), @@ -173,14 +160,6 @@ function readOptions(context: Context): RuleOptions { }; } -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - interface AnyNode { readonly type: string; readonly start: number; @@ -190,121 +169,43 @@ interface AnyNode { } function asNode(value: unknown): AnyNode | null { - if (typeof value !== 'object' || value === null) return null; - const candidate = value as { type?: unknown; start?: unknown }; - if (typeof candidate.type !== 'string' || typeof candidate.start !== 'number') return null; - return value as AnyNode; + return sharedAsNode(value, true) as AnyNode | null; } - function parentOf(node: AnyNode | null): AnyNode | null { - return (node?.parent as AnyNode | null | undefined) ?? null; + return node?.parent ?? null; } - -/** Strip parens, `!`, `as`, `satisfies` and optional-chaining wrappers to reach the real expression. */ function unwrap(value: unknown): AnyNode | null { let current = asNode(value); - for (let guard = 0; current !== null && guard < 16; guard += 1) { - if (!WRAPPER_TYPES.has(current.type)) return current; - const inner = asNode(current.expression); - if (inner === null) return current; - current = inner; + for (let depth = 0; depth < 16 && current !== null; depth += 1) { + if (!WRAPPER_TYPES.has(current.type)) break; + const child = asNode(current.expression); + if (child === null) break; + current = child; } return current; } - -/** Non-computed `.member`, or computed `["member"]`. */ function memberName(node: AnyNode): string | null { const property = asNode(node.property); if (property === null) return null; if (node.computed !== true) return property.type === 'Identifier' ? (property.name as string) : null; - if (property.type === 'TemplateLiteral') return staticString(property as unknown as ESTree.Node); - if ( - (property.type === 'Literal' || property.type === 'StringLiteral') && - typeof property.value === 'string' - ) { - return property.value; - } - return null; -} - -/** Locals bound by `import * as X from "effect"` — `X.Effect.gen` must still be recognised. */ -function collectRootNamespaces(program: ESTree.Program): ReadonlySet { - const locals = new Set(); - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - if (statement.source.value !== EFFECT_ROOT_MODULE) continue; - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportNamespaceSpecifier') locals.add(specifier.local.name); - } - } - return locals; -} - -/** Locals bound by `import { gen, fn } from "effect/Effect"` — bare `gen(function* ())` must be caught. */ -function collectDirectMemberImports( - program: ESTree.Program, - members: readonly string[], -): ReadonlySet { - const locals = new Set(); - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - if (!EFFECT_EFFECT_MODULE.test(statement.source.value)) continue; - for (const specifier of statement.specifiers) { - if (specifier.type !== 'ImportSpecifier') continue; - const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; - if (members.includes(imported)) locals.add(specifier.local.name); - } - } - return locals; -} - -/** Extend `effect` bindings with named `Effect` imports from configured re-export barrels. */ -function bindingsWithExtraModules( - program: ESTree.Program, - modules: readonly string[], -): EffectBindings { - const base = collectEffectBindings(program); - if (modules.length === 0) return base; - const namespaces = new Map(base.namespaces); - let importsEffect = base.importsEffect; - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - if (!modules.includes(statement.source.value)) continue; - for (const specifier of statement.specifiers) { - if (specifier.type !== 'ImportSpecifier') continue; - const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; - if (imported !== EFFECT_NAMESPACE) continue; - namespaces.set(specifier.local.name, EFFECT_NAMESPACE); - importsEffect = true; - } - } - return { importsEffect, namespaces }; + return sharedMemberName(node, { templates: true, babelStrings: true }); } interface GeneratorMatcher { readonly context: Context; readonly effectModules: readonly string[]; - readonly bindings: EffectBindings; - readonly rootNamespaces: ReadonlySet; - readonly directMembers: ReadonlySet; readonly genMembers: readonly string[]; } /** `Effect.gen` / `E.gen` / `X.Effect.gen` / bare `gen` (direct member import), incl. computed + optional. */ -function isGenCallee(callee: AnyNode | null, matcher: GeneratorMatcher, depth = 0): boolean { - if (depth > 8 || callee === null) return false; - const target = identityUnwrap(callee as unknown as ESTree.Node); - if (target.type === 'CallExpression') - return isGenCallee(asNode(target.callee), matcher, depth + 1); - const path = bindingPath(matcher.context, target, matcher.effectModules); - return path?.length === 2 && path[0] === 'Effect' && matcher.genMembers.includes(path[1] ?? ''); +function isGenCallee(callee: AnyNode | null, matcher: GeneratorMatcher): boolean { + return sharedIsGenCallee( + matcher.context, + callee as ESTree.Node | null, + matcher.genMembers, + matcher.effectModules, + ); } /** `true` when `fn` is a generator function handed to `Effect.gen` / `Effect.fn` / `Effect.fnUntraced`. */ @@ -327,72 +228,44 @@ function isEffectGenerator(fn: AnyNode, matcher: GeneratorMatcher): boolean { type Walker = (node: AnyNode) => boolean; -function childrenOf( - node: AnyNode, - visitorKeys: Readonly>, -): AnyNode[] { - const keys = visitorKeys[node.type]; - const names = keys ?? Object.keys(node).filter((key) => key !== 'parent' && key !== 'type'); - const children: AnyNode[] = []; - for (const name of names) { - const value = node[name]; - if (Array.isArray(value)) { - for (const entry of value) { - const child = asNode(entry); - if (child !== null) children.push(child); - } - continue; - } - const child = asNode(value); - if (child !== null) children.push(child); - } - return children; -} - -/** Depth-first walk; `visit` returns `false` to skip the node's children. */ +/** Preserve the generator traversal budget while sharing child enumeration and ordering. */ function walk( node: AnyNode, visitorKeys: Readonly>, visit: Walker, ): void { - const stack: AnyNode[] = [node]; - let guard = 0; - while (stack.length > 0 && guard < 200_000) { - guard += 1; - const current = stack.pop(); - if (current === undefined) break; + const stack = [node]; + for (let visited = 0; stack.length > 0 && visited < 200_000; visited += 1) { + const current = stack.pop()!; if (!visit(current)) continue; - const children = childrenOf(current, visitorKeys); + const children = childrenOf(current as unknown as ESTree.Node, visitorKeys, true); for (let index = children.length - 1; index >= 0; index -= 1) { - const child = children[index]; - if (child !== undefined) stack.push(child); + stack.push(children[index] as AnyNode); } } } -/** Peel `x.pipe(a, b)` and `pipe(x, a, b)` down to the piped subject. */ +/** Peel method and imported pipe calls down to their subject. */ +function pipeSubject( + current: AnyNode, + context: Context, + modules: readonly string[], +): AnyNode | null { + if (current.type !== 'CallExpression') return current; + const callee = unwrap(current.callee); + if (callee === null) return current; + if (MEMBER_TYPES.has(callee.type) && memberName(callee) === 'pipe') return unwrap(callee.object); + const path = bindingPath(context, callee as unknown as ESTree.Node, modules)?.join('.') ?? ''; + if (!['pipe', 'Function.pipe'].includes(path)) return current; + const first = Array.isArray(current.arguments) ? unwrap(current.arguments[0]) : null; + return first ?? current; +} function unwrapPipe(value: unknown, context: Context, modules: readonly string[]): AnyNode | null { let current = unwrap(value); for (let guard = 0; current !== null && guard < 32; guard += 1) { - if (current.type !== 'CallExpression') return current; - const callee = unwrap(current.callee); - if (callee === null) return current; - if (MEMBER_TYPES.has(callee.type) && memberName(callee) === 'pipe') { - current = unwrap(callee.object); - continue; - } - if ( - ['pipe', 'Function.pipe'].includes( - bindingPath(context, callee as unknown as ESTree.Node, modules)?.join('.') ?? '', - ) - ) { - const args = current.arguments; - const first = Array.isArray(args) ? unwrap(args[0]) : null; - if (first === null) return current; - current = first; - continue; - } - return current; + const next = pipeSubject(current, context, modules); + if (next === current) return current; + current = next; } return current; } @@ -449,30 +322,7 @@ function collectReferencedNames( visitorKeys: Readonly>, ): Set { const names = new Set(); - walk(node, visitorKeys, (current) => { - if (current.type === 'Identifier') { - names.add(current.name as string); - return false; - } - if (MEMBER_TYPES.has(current.type) && current.computed !== true) { - const object = asNode(current.object); - if (object !== null) - walk(object, visitorKeys, (inner) => collectInto(inner, names, visitorKeys)); - return false; - } - if ( - (current.type === 'Property' || current.type === 'ObjectProperty') && - current.computed !== true - ) { - // `{ tenantId: value }` — the key is a label, the value is a read. Shorthand shares the node. - if (current.shorthand === true) return true; - const value = asNode(current.value); - if (value !== null) - walk(value, visitorKeys, (inner) => collectInto(inner, names, visitorKeys)); - return false; - } - return true; - }); + walk(node, visitorKeys, (current) => collectInto(current, names, visitorKeys)); return names; } @@ -514,118 +364,54 @@ interface Candidate { readonly ordering: boolean; } -function label(context: Context, node: AnyNode): string { - const text = context.sourceCode - .getText(node as unknown as ESTree.Node) - .replace(/\s+/gu, ' ') - .replace(/\s*(\??\.)\s*/gu, '$1') - .trim(); - return text.length > 60 ? `${text.slice(0, 57)}...` : text; +function inScope(filename: string, options: RuleOptions): boolean { + const path = scopePath(filename); + if (matchesGlobs(path, options.ignore)) return false; + const script = isScriptFile(path) || matchesGlobs(path, DEFAULT_SCRIPT_GLOBS); + if (script && !options.includeScripts) return false; + if (!matchesGlobs(path, options.include) && !(options.includeScripts && script)) return false; + return options.includeTests || !isTestFile(path); } - -/** Report-only rule: adjacent independent `yield*` reads inside `Effect.gen` (audit B1). */ -// Resolve lexical value bindings, not identifier spellings. Only immutable local aliases are -// followed; arbitrary object mutation, re-export contents and dynamic keys need type/data-flow analysis. -function lexicalVariable(context: Context, node: Extract) { - let scope: import('@oxlint/plugins').Scope | null = context.sourceCode.getScope(node); - while (scope !== null) { - const variable = scope.set.get(node.name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; +function singleDeclarator(statement: AnyNode): AnyNode | null { + if (statement.type !== 'VariableDeclaration') return null; + const declarations = statement.declarations; + return Array.isArray(declarations) && declarations.length === 1 ? asNode(declarations[0]) : null; } -function staticString(node: ESTree.Node): string | null { - if (node.type === 'Literal' && typeof node.value === 'string') return node.value; - if (node.type === 'TemplateLiteral' && node.expressions.length === 0) - return node.quasis[0]?.value.cooked ?? null; - return null; +const TRANSPARENT_MEMBERS = new Set([ + 'withSpan', + 'annotateLogs', + 'timeout', + 'timeoutOption', + 'retry', +]); +function transparentArguments( + subject: AnyNode, + context: Context, + modules: readonly string[], +): unknown[] | null { + const path = bindingPath(context, subject.callee as unknown as ESTree.Node, modules); + if (path?.length !== 2 || path[0] !== 'Effect' || !TRANSPARENT_MEMBERS.has(path[1] ?? '')) + return null; + return Array.isArray(subject.arguments) && subject.arguments.length >= 2 + ? subject.arguments + : null; } -function identityUnwrap(node: ESTree.Node): ESTree.Node { - let current = node; - for (;;) { - if (current.type === 'SequenceExpression') { - const last = current.expressions.at(-1); - if (last === undefined) return current; - current = last; - } else if ( - [ - 'ChainExpression', - 'ParenthesizedExpression', - 'TSAsExpression', - 'TSTypeAssertion', - 'TSNonNullExpression', - 'TSSatisfiesExpression', - 'TSInstantiationExpression', - ].includes(current.type) - ) { - current = (current as unknown as { expression: ESTree.Node }).expression; - } else return current; +/** Only known data-first wrappers preserve the effect; constructors and callbacks remain opaque. */ +function readSubject(value: unknown, context: Context, modules: readonly string[]): AnyNode | null { + let subject = unwrapPipe(value, context, modules); + while (subject?.type === 'CallExpression') { + const args = transparentArguments(subject, context, modules); + if (args === null) break; + subject = unwrapPipe(args[0], context, modules); } + return subject; } -function bindingPath( - context: Context, - expression: ESTree.Node, - extraModules: readonly string[] = [], - seen = new Set(), -): readonly string[] | null { - const node = identityUnwrap(expression); - if (node.type === 'MemberExpression') { - const key = - !node.computed && node.property.type === 'Identifier' - ? node.property.name - : staticString(node.property); - const root = bindingPath(context, node.object, extraModules, seen); - return root !== null && key !== null ? [...root, key] : null; - } - if (node.type !== 'Identifier') return null; - const variable = lexicalVariable(context, node); - if (variable === null || seen.has(variable)) return null; - seen.add(variable); - if (variable.defs.length !== 1) return null; - const definition = variable.defs[0]; - if (definition === undefined) return null; - if (definition.type === 'ImportBinding') { - const specifier = definition.node as - | ESTree.ImportSpecifier - | ESTree.ImportNamespaceSpecifier - | ESTree.ImportDefaultSpecifier; - const declaration = definition.parent as ESTree.ImportDeclaration; - if (declaration?.type !== 'ImportDeclaration' || declaration.importKind === 'type') return null; - if (specifier.type === 'ImportSpecifier' && specifier.importKind === 'type') return null; - const source = declaration.source.value; - if (source !== 'effect' && !source.startsWith('effect/') && !extraModules.includes(source)) - return null; - const last = source.split('/').at(-1) ?? ''; - const base = source.startsWith('effect/') && /^[A-Z]/u.test(last) ? [last] : []; - if (specifier.type === 'ImportNamespaceSpecifier') return base; - if (specifier.type !== 'ImportSpecifier') return null; - const imported = - specifier.imported.type === 'Identifier' ? specifier.imported.name : specifier.imported.value; - return [...base, imported]; - } - if (definition.type !== 'Variable') return null; - const declaration = definition.node as ESTree.VariableDeclarator; - const parent = definition.parent as ESTree.VariableDeclaration; - if (parent?.kind !== 'const' || declaration.init === null) return null; - const base = bindingPath(context, declaration.init, extraModules, seen); - if (base === null) return null; - if (declaration.id.type === 'Identifier') return base; - if (declaration.id.type !== 'ObjectPattern') return null; - for (const property of declaration.id.properties) { - if ( - property.type === 'RestElement' || - property.value.type !== 'Identifier' || - property.value.name !== node.name - ) - continue; - const key = - !property.computed && property.key.type === 'Identifier' - ? property.key.name - : staticString(property.key); - return key === null ? null : [...base, key]; - } - return null; +function readCallee(subject: AnyNode | null, includeFunctions: boolean): AnyNode | null { + if (subject?.type !== 'CallExpression') return null; + const callee = unwrap(subject.callee); + if (callee === null) return null; + if (MEMBER_TYPES.has(callee.type)) return callee; + return includeFunctions && callee.type === 'Identifier' ? callee : null; } export const rule = defineRule({ @@ -675,79 +461,36 @@ export const rule = defineRule({ }, create(context) { const options = readOptions(context); - const path = scopePath(context.filename); - if (matchesGlobs(path, options.ignore)) return {}; - if (!options.includeScripts && (isScriptFile(path) || matchesGlobs(path, DEFAULT_SCRIPT_GLOBS))) - return {}; - if ( - !matchesGlobs(path, options.include) && - !(options.includeScripts && (isScriptFile(path) || matchesGlobs(path, DEFAULT_SCRIPT_GLOBS))) - ) - return {}; - if (!options.includeTests && isTestFile(path)) return {}; + if (!inScope(context.filename, options)) return {}; const program = context.sourceCode.ast; const rootNamespaces = collectRootNamespaces(program); - const directMembers = collectDirectMemberImports(program, options.genMembers); + const directMembers = collectNamedImports( + program, + (source) => /^effect\/(?:.*\/)?Effect$/u.test(source), + new Set(options.genMembers), + ); const bindings = bindingsWithExtraModules(program, options.effectModules); if (!bindings.importsEffect && rootNamespaces.size === 0 && directMembers.size === 0) return {}; const matcher: GeneratorMatcher = { context, effectModules: options.effectModules, - bindings, - directMembers, genMembers: options.genMembers, - rootNamespaces, }; const visitorKeys = context.sourceCode.visitorKeys; - let ordering: RegExp; - try { - ordering = new RegExp(options.orderingCalleePattern, 'u'); - } catch { - ordering = new RegExp(DEFAULT_ORDERING_PATTERN, 'u'); - } + const ordering = safeRegExp(options.orderingCalleePattern, DEFAULT_ORDERING_PATTERN); const analysed = new Set(); /** A single-declarator `const x = yield* ` statement, or `null`. */ const candidateOf = (statement: AnyNode): Candidate | null => { - if (statement.type !== 'VariableDeclaration') return null; - const declarations = statement.declarations; - if (!Array.isArray(declarations) || declarations.length !== 1) return null; - const declarator = asNode(declarations[0]); + const declarator = singleDeclarator(statement); if (declarator === null) return null; const init = unwrap(declarator.init); if (init === null || init.type !== 'YieldExpression' || init.delegate !== true) return null; - let subject = unwrapPipe(init.argument, context, options.effectModules); - // Only known data-first wrappers preserve the underlying effect. Never peel arbitrary - // Effect constructors/callbacks: map/sync may intentionally introduce ordered work. - const transparent = new Set([ - 'withSpan', - 'annotateLogs', - 'timeout', - 'timeoutOption', - 'retry', - ]); - while (subject?.type === 'CallExpression') { - const path = bindingPath(context, subject.callee as ESTree.Node, options.effectModules); - if (path?.length !== 2 || path[0] !== 'Effect' || !transparent.has(path[1] ?? '')) break; - const args = subject.arguments; - if (!Array.isArray(args) || args.length < 2) break; - subject = unwrapPipe(args[0], context, options.effectModules); - } - if (subject === null) return null; - - let calleeNode: AnyNode | null = null; - if (subject.type === 'CallExpression') { - const callee = unwrap(subject.callee); - if (callee === null) return null; - if (MEMBER_TYPES.has(callee.type)) calleeNode = callee; - else if (options.includeFunctionCallees && callee.type === 'Identifier') - calleeNode = callee; - else return null; - } else { - return null; - } + const subject = readSubject(init.argument, context, options.effectModules); + const calleeNode = readCallee(subject, options.includeFunctionCallees); + if (calleeNode === null) return null; // `Effect.all(...)`, `Schema.decodeUnknown(...)`, … are the target shape, never the anti-pattern. if ( bindingPath(context, calleeNode as unknown as ESTree.Node, options.effectModules) !== null diff --git a/app/tools/oxlint/effect-native/rules/no-string-timestamp-schema.ts b/app/tools/oxlint/effect-native/rules/no-string-timestamp-schema.ts index e5f790e5a..18dc04e00 100644 --- a/app/tools/oxlint/effect-native/rules/no-string-timestamp-schema.ts +++ b/app/tools/oxlint/effect-native/rules/no-string-timestamp-schema.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * Audit findings: **A2** — "Make Schema the sole authority for contracts and domain models" — and * **B5** — "Adopt Effect's ADTs and temporal model consistently" @@ -79,16 +80,24 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree, Variable } from '@oxlint/plugins'; import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; +import { globToRegExp, isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; + +import { + memberName as staticMemberName, + unwrapNode, + skipWrappers, + keyName, +} from '../shared/ast.ts'; +import { resolveVariable } from '../shared/bindings.ts'; +import { importedName } from '../shared/imports.ts'; +import { isSchemaConstructorArgument as isConstructorArgument } from '../shared/schema-constructor.ts'; +import { stringArray, stringOption, safeRegExp } from '../shared/options.ts'; const SCHEMA_NAMESPACE = 'Schema'; -/** Fixture files mirror repo paths under `tests/fixtures//{valid,invalid}/`; strip that prefix. */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; - const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; const DEFAULT_IGNORE: string[] = []; // Generic On/Time/After suffixes also name sort keys, labels and pagination cursors. The audit @@ -227,82 +236,30 @@ interface RuleOptions { readonly schemaModules: readonly string[]; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - -function stringOption(value: unknown, fallback: string): string { - return typeof value === 'string' && value.length > 0 ? value : fallback; -} - -/** Like `stringOption`, but an explicit `""` disables the pattern instead of restoring the default. */ -function patternOption(value: unknown, fallback: string): string { - return typeof value === 'string' ? value : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { ignore: stringArray(record.ignore, DEFAULT_IGNORE), - ignoreKeyPattern: patternOption(record.ignoreKeyPattern, DEFAULT_IGNORE_KEY_PATTERN), + ignoreKeyPattern: stringOption(record.ignoreKeyPattern, DEFAULT_IGNORE_KEY_PATTERN), ignoreTests: record.ignoreTests === true, - ignoreTypePattern: patternOption(record.ignoreTypePattern, DEFAULT_IGNORE_TYPE_PATTERN), + ignoreTypePattern: stringOption(record.ignoreTypePattern, DEFAULT_IGNORE_TYPE_PATTERN), include: stringArray(record.include, DEFAULT_INCLUDE), includeTypeMembers: record.includeTypeMembers !== false, schemaModules: stringArray(record.schemaModules, DEFAULT_SCHEMA_MODULES), - temporalKeyPattern: stringOption(record.temporalKeyPattern, DEFAULT_TEMPORAL_KEY_PATTERN), + temporalKeyPattern: stringOption( + record.temporalKeyPattern, + DEFAULT_TEMPORAL_KEY_PATTERN, + false, + ), }; } -function safeRegExp(source: string, fallback: string): RegExp { - try { - return new RegExp(source, 'u'); - } catch { - return new RegExp(fallback, 'u'); - } -} - -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -function importedName(specifier: ESTree.ImportSpecifier): string { - return specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; -} - -/** Non-computed `.Struct`, or computed `["Struct"]` / `` [`Struct`] ``. */ function memberName(node: ESTree.MemberExpression): string | null { - if (!node.computed) return node.property.type === 'Identifier' ? node.property.name : null; - const property = node.property; - if (property.type === 'Literal' && typeof property.value === 'string') return property.value; - if (property.type === 'TemplateLiteral' && property.expressions.length === 0) { - const quasi = property.quasis[0]; - return quasi === undefined ? null : (quasi.value.cooked ?? quasi.value.raw); - } - return null; + return staticMemberName(node, { templates: true, rawTemplates: true }); } function unwrap(node: ESTree.Node): ESTree.Node { - let current = node; - for (let guard = 0; guard < 16; guard += 1) { - if (!UNWRAPPABLE.has(current.type)) return current; - const inner = (current as { expression?: ESTree.Node }).expression; - if (inner === undefined) return current; - current = inner; - } - return current; + return unwrapNode(node, { wrappers: UNWRAPPABLE, maxDepth: 16 }); } interface SchemaLocals { @@ -334,39 +291,32 @@ function collectSchemaLocals( if (namespace === SCHEMA_NAMESPACE) schema.add(local); if (namespace === 'pipe') pipe.add(local); } + const collectSpecifier = ( + specifier: ESTree.ImportDeclaration['specifiers'][number], + isSchemaSubmodule: boolean, + ): void => { + if (specifier.type === 'ImportNamespaceSpecifier') { + (isSchemaSubmodule ? schema : barrel).add(specifier.local.name); + return; + } + if (specifier.type !== 'ImportSpecifier' || specifier.importKind === 'type') return; + const imported = importedName(specifier); + if (imported === SCHEMA_NAMESPACE) schema.add(specifier.local.name); + else if (imported === 'pipe') pipe.add(specifier.local.name); + else if (isSchemaSubmodule) members.set(specifier.local.name, imported); + }; const modulePatterns = schemaModules.map((glob) => globToRegExp(glob)); for (const statement of program.body) { if (statement.type !== 'ImportDeclaration' || statement.importKind === 'type') continue; - const source = statement.source.value; - if (!modulePatterns.some((pattern) => pattern.test(source))) continue; - const isSchemaSubmodule = SCHEMA_SUBMODULE.test(source); - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportNamespaceSpecifier') { - // `import * as Schema from "effect/Schema"` is the namespace; anything else is a barrel. - if (isSchemaSubmodule) schema.add(specifier.local.name); - else barrel.add(specifier.local.name); - continue; - } - if (specifier.type !== 'ImportSpecifier' || specifier.importKind === 'type') continue; - const imported = importedName(specifier); - if (imported === SCHEMA_NAMESPACE) schema.add(specifier.local.name); - else if (imported === 'pipe') pipe.add(specifier.local.name); - // `import { Struct, String as SchemaString } from "effect/Schema"` is the same API as the - // namespace form; without this the whole rule is one import statement away from silent. - else if (isSchemaSubmodule) members.set(specifier.local.name, imported); - } + if (!modulePatterns.some((pattern) => pattern.test(statement.source.value))) continue; + for (const specifier of statement.specifiers) + collectSpecifier(specifier, SCHEMA_SUBMODULE.test(statement.source.value)); } return { barrel, members, pipe, schema }; } function lookupVariable(context: Context, identifier: ESTree.Node, name: string): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(identifier); - while (scope !== null) { - const variable = scope.set.get(name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; + return resolveVariable(context, name, identifier); } /** `Schema.DateTimeUtc` for a timestamp key, an explicit date-only codec for a calendar key. */ @@ -493,6 +443,10 @@ export const rule = defineRule({ if (!locals.schema.has(object.name)) return null; return resolvesToImport(object, object.name) ? member : null; } + return barrelSchemaMember(object, member); + }; + + const barrelSchemaMember = (object: ESTree.Node, member: string): string | null => { if (object.type !== 'MemberExpression') return null; if (memberName(object) !== SCHEMA_NAMESPACE) return null; const root = unwrap(object.object); @@ -546,19 +500,34 @@ export const rule = defineRule({ return member !== null && STRING_ROOTS.has(member); } - if (expression.type === 'Identifier') { - // `import { String as SchemaString } from "effect/Schema"` — the same leaf, no namespace. - const imported = locals.members.get(expression.name); - if (imported !== undefined && resolvesToImport(expression, expression.name)) { - return STRING_ROOTS.has(imported); - } - const declarator = localDeclarator(expression, expression.name); - if (declarator === null || seen.has(declarator.start)) return false; - seen.add(declarator.start); - if (reportedCodecDeclarators.has(declarator.start)) trace.viaReportedCodec = true; - return isStringRooted(declarator.init, seen, depth + 1, trace); + if (expression.type === 'Identifier') return stringIdentifier(expression, seen, depth, trace); + return stringCall(expression, seen, depth, trace); + }; + + const stringIdentifier = ( + expression: Extract, + seen: Set, + depth: number, + trace: StringRootTrace, + ): boolean => { + // `import { String as SchemaString } from "effect/Schema"` — the same leaf, no namespace. + const imported = locals.members.get(expression.name); + if (imported !== undefined && resolvesToImport(expression, expression.name)) { + return STRING_ROOTS.has(imported); } + const declarator = localDeclarator(expression, expression.name); + if (declarator === null || seen.has(declarator.start)) return false; + seen.add(declarator.start); + if (reportedCodecDeclarators.has(declarator.start)) trace.viaReportedCodec = true; + return isStringRooted(declarator.init, seen, depth + 1, trace); + }; + const stringCall = ( + expression: ESTree.Node, + seen: Set, + depth: number, + trace: StringRootTrace, + ): boolean => { if (expression.type !== 'CallExpression') return false; const callee = unwrap(expression.callee); @@ -571,29 +540,45 @@ export const rule = defineRule({ return isStringRooted(first, seen, depth + 1, trace); } - // `inner.check(...)` / `inner.annotate(...)` / `inner.pipe(...)` / `inner.brand('X')`. - if (callee.type === 'MemberExpression') { - const method = memberName(callee); - if (method === null || !TRANSPARENT_METHODS.has(method)) return false; - if (method === 'pipe') - return stringPipeline(callee.object, expression.arguments, seen, depth, trace); - return isStringRooted(callee.object, seen, depth + 1, trace); - } + if (callee.type === 'MemberExpression') + return stringMethod(callee, expression.arguments, seen, depth, trace); + return importedStringPipeline(callee, expression.arguments, seen, depth, trace); + }; + const importedStringPipeline = ( + callee: ESTree.Node, + args: readonly ESTree.Node[], + seen: Set, + depth: number, + trace: StringRootTrace, + ): boolean => { // `pipe(Schema.String, Schema.brand('X'))`. if ( callee.type === 'Identifier' && locals.pipe.has(callee.name) && resolvesToImport(callee, callee.name) ) { - const first = expression.arguments[0]; + const first = args[0]; if (first === undefined || first.type === 'SpreadElement') return false; - return stringPipeline(first, expression.arguments.slice(1), seen, depth, trace); + return stringPipeline(first, args.slice(1), seen, depth, trace); } return false; }; + const stringMethod = ( + callee: ESTree.MemberExpression, + args: readonly ESTree.Node[], + seen: Set, + depth: number, + trace: StringRootTrace, + ): boolean => { + const method = memberName(callee); + if (method === null || !TRANSPARENT_METHODS.has(method)) return false; + if (method === 'pipe') return stringPipeline(callee.object, args, seen, depth, trace); + return isStringRooted(callee.object, seen, depth + 1, trace); + }; + /** Composition can change decoded types; never assume an arbitrary pipe step preserves strings. */ const stringPipeline = ( source: ESTree.Node, @@ -621,20 +606,8 @@ export const rule = defineRule({ }; /** Is `node` an argument of a `Schema.Struct` / `Schema.TaggedError()('T', ...)` style call? */ - const isSchemaConstructorArgument = (node: ESTree.Node): boolean => { - const parent = node.parent; - if (parent === null || parent === undefined) return false; - if (parent.type !== 'CallExpression') return false; - if (!parent.arguments.some((argument) => argument === node)) return false; - let callee: ESTree.Node = unwrap(parent.callee); - for (let guard = 0; guard < 8; guard += 1) { - const member = schemaRef(callee); - if (member !== null) return FIELD_BAG_CONSTRUCTORS.has(member); - if (callee.type !== 'CallExpression') return false; - callee = unwrap(callee.callee); - } - return false; - }; + const isSchemaConstructorArgument = (node: ESTree.Node): boolean => + isConstructorArgument(node, schemaRef, FIELD_BAG_CONSTRUCTORS, unwrap); /** The object literal an identifier resolves to: `const auditColumns = { ... }`. */ const declaredObject = (node: ESTree.Node): ESTree.ObjectExpression | null => { @@ -662,14 +635,7 @@ export const rule = defineRule({ queue.push(object); }; for (const object of objects) { - let current: ESTree.Node = object; - while ( - current.parent !== null && - current.parent !== undefined && - UNWRAPPABLE.has(current.parent.type) - ) { - current = current.parent; - } + const current = skipWrappers(object, UNWRAPPABLE).node; if (isSchemaConstructorArgument(current)) enqueue(object); } for (const identifier of bagIdentifiers) enqueue(declaredObject(identifier)); @@ -686,19 +652,11 @@ export const rule = defineRule({ return bags; }; - const propertyKey = (property: ESTree.ObjectProperty): string | null => { - const key = property.key; - if (!property.computed && key.type === 'Identifier') return key.name; - if (key.type === 'Literal' && typeof key.value === 'string') return key.value; - return null; - }; + const propertyKey = (property: ESTree.ObjectProperty): string | null => + keyName(property.key, property.computed, { templates: false }); - const signatureKey = (signature: ESTree.TSPropertySignature): string | null => { - const key = signature.key; - if (!signature.computed && key.type === 'Identifier') return key.name; - if (key.type === 'Literal' && typeof key.value === 'string') return key.value; - return null; - }; + const signatureKey = (signature: ESTree.TSPropertySignature): string | null => + keyName(signature.key, signature.computed, { templates: false }); const unwrapType = (node: ESTree.Node): ESTree.Node => { let current = node; @@ -721,47 +679,44 @@ export const rule = defineRule({ const type = unwrapType(node); if (type.type === 'TSStringKeyword') return true; - if (type.type === 'TSTypeReference') { - const name = type.typeName; - if (name.type !== 'Identifier' || seen.has(name.name)) return false; - const variable = lookupVariable(context, name, name.name); - const definition = variable?.defs.length === 1 ? variable.defs[0] : undefined; - const alias = - definition?.node.type === 'TSTypeAliasDeclaration' && - definition.node.typeParameters == null - ? definition.node.typeAnnotation - : undefined; - if (alias === undefined) return false; - seen.add(name.name); - return isPlainStringType(alias, seen, depth + 1); - } + if (type.type === 'TSTypeReference') return stringTypeAlias(type, seen, depth); + if (type.type === 'TSUnionType') + return stringTypeMembers(type.types, seen, depth, NULLISH_KEYWORDS); + if (type.type === 'TSIntersectionType') + return stringTypeMembers(type.types, seen, depth, new Set(['TSTypeLiteral'])); + return false; + }; - if (type.type === 'TSUnionType') { - let sawString = false; - for (const member of type.types) { - if (isPlainStringType(member, seen, depth + 1)) { - sawString = true; - continue; - } - if (!NULLISH_KEYWORDS.has(unwrapType(member).type)) return false; - } - return sawString; - } + const stringTypeAlias = ( + type: ESTree.TSTypeReference, + seen: Set, + depth: number, + ): boolean => { + const name = type.typeName; + if (name.type !== 'Identifier' || seen.has(name.name)) return false; + const variable = lookupVariable(context, name, name.name); + const definition = variable?.defs.length === 1 ? variable.defs[0] : undefined; + if ( + definition?.node.type !== 'TSTypeAliasDeclaration' || + definition.node.typeParameters != null + ) + return false; + seen.add(name.name); + return isPlainStringType(definition.node.typeAnnotation, seen, depth + 1); + }; - if (type.type === 'TSIntersectionType') { - let sawString = false; - for (const member of type.types) { - if (isPlainStringType(member, seen, depth + 1)) { - sawString = true; - continue; - } - // Only an object-shaped brand carrier may accompany the string. - if (unwrapType(member).type !== 'TSTypeLiteral') return false; - } - return sawString; + const stringTypeMembers = ( + members: readonly ESTree.Node[], + seen: Set, + depth: number, + allowed: ReadonlySet, + ): boolean => { + let sawString = false; + for (const member of members) { + if (isPlainStringType(member, seen, depth + 1)) sawString = true; + else if (!allowed.has(unwrapType(member).type)) return false; } - - return false; + return sawString; }; /** The interface / type-alias / class name that owns a type member, for `ignoreTypePattern`. */ @@ -770,16 +725,16 @@ export const rule = defineRule({ for (let guard = 0; guard < 8; guard += 1) { if (current === null || current === undefined) return null; if ( - current.type === 'TSInterfaceDeclaration' || - current.type === 'TSTypeAliasDeclaration' + [ + 'TSInterfaceDeclaration', + 'TSTypeAliasDeclaration', + 'ClassDeclaration', + 'ClassExpression', + 'VariableDeclarator', + ].includes(current.type) ) { - return current.id.type === 'Identifier' ? current.id.name : null; - } - if (current.type === 'ClassDeclaration' || current.type === 'ClassExpression') { - return current.id?.type === 'Identifier' ? current.id.name : null; - } - if (current.type === 'VariableDeclarator') { - return current.id.type === 'Identifier' ? current.id.name : null; + const id = (current as { id?: ESTree.Node | null }).id; + return id?.type === 'Identifier' ? id.name : null; } current = current.parent; } @@ -827,32 +782,20 @@ export const rule = defineRule({ const regexSource = (node: ESTree.Node, depth: number): string | null => { if (depth > 8) return null; const expression = unwrap(node); - if (expression.type === 'Literal') { - const regex = (expression as { regex?: { pattern: string } }).regex; - if (regex !== undefined) return regex.pattern; - return typeof expression.value === 'string' ? expression.value : null; - } + if (expression.type === 'Literal') return literalRegexSource(expression); if (expression.type === 'NewExpression') { const first = expression.arguments[0]; if (first === undefined || first.type === 'SpreadElement') return null; return regexSource(first, depth + 1); } // `` `^${YEAR}-\\d{2}$` `` and `'^\\d{4}' + '-\\d{2}'` are the same regex, spelled out. - if (expression.type === 'TemplateLiteral') { - let text = ''; - for (const [index, quasi] of expression.quasis.entries()) { - text += quasi.value.cooked ?? quasi.value.raw; - const placeholder = expression.expressions[index]; - if (placeholder !== undefined) text += regexSource(placeholder, depth + 1) ?? ''; - } - return text; - } - if (expression.type === 'BinaryExpression' && expression.operator === '+') { - const left = regexSource(expression.left, depth + 1); - const right = regexSource(expression.right, depth + 1); - if (left === null && right === null) return null; - return `${left ?? ''}${right ?? ''}`; - } + if (expression.type === 'TemplateLiteral') return templateRegexSource(expression, depth); + if (expression.type === 'BinaryExpression' && expression.operator === '+') + return concatenatedRegexSource(expression, depth); + return identifierRegexSource(expression, depth); + }; + + const identifierRegexSource = (expression: ESTree.Node, depth: number): string | null => { if (expression.type === 'Identifier') { const declarator = localDeclarator(expression, expression.name); if (declarator === null || declarator.init === null) return null; @@ -861,6 +804,33 @@ export const rule = defineRule({ return null; }; + const literalRegexSource = ( + expression: Extract, + ): string | null => { + const regex = (expression as { regex?: { pattern: string } }).regex; + if (regex !== undefined) return regex.pattern; + return typeof expression.value === 'string' ? expression.value : null; + }; + + const templateRegexSource = (expression: ESTree.TemplateLiteral, depth: number): string => { + let text = ''; + for (const [index, quasi] of expression.quasis.entries()) { + text += quasi.value.cooked ?? quasi.value.raw; + const placeholder = expression.expressions[index]; + if (placeholder !== undefined) text += regexSource(placeholder, depth + 1) ?? ''; + } + return text; + }; + + const concatenatedRegexSource = ( + expression: ESTree.BinaryExpression, + depth: number, + ): string | null => { + const left = regexSource(expression.left, depth + 1); + const right = regexSource(expression.right, depth + 1); + return left === null && right === null ? null : `${left ?? ''}${right ?? ''}`; + }; + /** `Schema.isPattern` / `Schema.pattern` / a directly-imported `isPattern`. */ const isPatternCallee = (node: ESTree.Node): boolean => { const member = schemaRef(node); @@ -879,6 +849,13 @@ export const rule = defineRule({ return null; }; + const isTransparentCall = (call: ESTree.CallExpression): boolean => { + const callee = unwrap(call.callee); + return ( + callee.type === 'MemberExpression' && TRANSPARENT_METHODS.has(memberName(callee) ?? '') + ); + }; + /** Walk out of `Schema.isPattern(...)` to the `.check(...)` / `.pipe(...)` that owns it. */ const enclosingCheck = (node: ESTree.CallExpression): ESTree.Node => { let current: ESTree.Node = node; @@ -886,12 +863,7 @@ export const rule = defineRule({ const parent: ESTree.Node | null | undefined = current.parent; if (parent === null || parent === undefined) return node; if (parent.type === 'CallExpression') { - const callee = unwrap(parent.callee); - if (callee.type === 'MemberExpression') { - const method = memberName(callee); - if (method !== null && TRANSPARENT_METHODS.has(method)) return parent; - } - return node; + return isTransparentCall(parent) ? parent : node; } if (!UNWRAPPABLE.has(parent.type) && parent.type !== 'SpreadElement') return node; current = parent; @@ -899,6 +871,125 @@ export const rule = defineRule({ return node; }; + const reports: Array<{ + readonly node: ESTree.Node; + readonly messageId: + | 'stringTemporalField' + | 'handRolledTemporalCodec' + | 'stringTemporalMember'; + readonly data: Record; + readonly start: number; + }> = []; + /** Spans of hand-rolled temporal codecs already reported — consumers are not re-reported. */ + const codecSpans: Array<{ start: number; end: number }> = []; + + const outerCodecResult = (target: ESTree.Node): ESTree.Node => { + let result = target; + for (let depth = 0; depth < 12; depth += 1) { + const next = outerCodecStep(result); + if (next === null) break; + result = next; + } + return result; + }; + const outerCodecStep = (result: ESTree.Node): ESTree.Node | null => { + const parent = result.parent; + if ( + parent?.type === 'MemberExpression' && + parent.object === result && + parent.parent?.type === 'CallExpression' && + TRANSPARENT_METHODS.has(memberName(parent) ?? '') + ) + return parent.parent; + return enclosingPipe(result, parent); + }; + const enclosingPipe = ( + result: ESTree.Node, + parent: ESTree.Node | null | undefined, + ): ESTree.Node | null => { + if ( + parent?.type === 'CallExpression' && + parent.arguments.some((argument) => argument === result) && + parent.callee.type === 'MemberExpression' && + memberName(parent.callee) === 'pipe' + ) + return parent; + return null; + }; + const reportCodec = (call: ESTree.CallExpression): void => { + const first = call.arguments[0]; + if (first === undefined || first.type === 'SpreadElement') return; + const raw = regexSource(first, 0); + if (raw === null) return; + const source = normaliseRegexSource(raw); + const isCalendarDate = CALENDAR_DATE_SOURCE.test(source); + const isIsoTime = ISO_TIME_SOURCE.test(source); + if (!isCalendarDate && !isIsoTime) return; + const target = enclosingCheck(call); + const result = outerCodecResult(target); + if (result !== target && !isStringRooted(result, new Set(), 0, { viaReportedCodec: false })) + return; + codecSpans.push({ end: target.end, start: target.start }); + const owner = enclosingDeclarator(target); + if (owner !== null) reportedCodecDeclarators.add(owner.start); + reports.push({ + data: { kind: isIsoTime ? 'timestamp' : 'calendar date' }, + messageId: 'handRolledTemporalCodec', + node: target, + start: target.start, + }); + }; + const containsReportedCodec = (node: ESTree.Node): boolean => + codecSpans.some((span) => span.start >= node.start && span.end <= node.end); + const reportField = (property: ESTree.ObjectExpression['properties'][number]): void => { + if (property.type !== 'Property') return; + if (property.kind !== 'init' || property.method) return; + const key = propertyKey(property); + if (key === null || !isTemporalKey(key)) return; + // A value that is, or resolves to, an in-file codec this rule already reports is the + // same defect; fixing the shared codec fixes every field that references it. + if (containsReportedCodec(property)) return; + const trace: StringRootTrace = { viaReportedCodec: false }; + if (!isStringRooted(property.value, new Set(), 0, trace)) return; + if (trace.viaReportedCodec) return; + reports.push({ + data: { key, replacement: replacementFor(key) }, + messageId: 'stringTemporalField', + node: property, + start: property.start, + }); + }; + const isDisplayMember = (owner: ESTree.Node, key: string): boolean => { + const typeName = enclosingTypeName(owner); + if (ignoreType !== null && typeName !== null && ignoreType.test(typeName)) return true; + return hasProjectionSibling(key, siblingKeys(owner)) || isParameterTypeLiteral(owner); + }; + const reportTypeMember = (signature: ESTree.TSPropertySignature): void => { + const owner = signature.parent; + if (owner == null) return; + if (owner.type !== 'TSInterfaceBody' && owner.type !== 'TSTypeLiteral') return; + const key = signatureKey(signature); + if (key === null || !isTemporalKey(key)) return; + // An i18n label bag (`CustomerDetailCopy`) keyed by field name holds translated + // column headings, not values; no temporal codec can model "Created". + if (isDisplayMember(owner, key)) return; + const annotation = signature.typeAnnotation; + if (annotation == null) return; + if (!isPlainStringType(annotation.typeAnnotation, new Set(), 0)) return; + reports.push({ + data: { key, replacement: replacementFor(key) }, + messageId: 'stringTemporalMember', + node: signature, + start: signature.start, + }); + }; + const reportFields = (): void => { + const fieldBags = collectFieldBags(); + for (const object of objects) { + if (fieldBags.has(object.start)) object.properties.forEach(reportField); + } + }; + return { Program(node) { bindings = collectEffectBindings(node); @@ -926,127 +1017,15 @@ export const rule = defineRule({ typeMembers.push(node); }, 'Program:exit'() { - const reports: Array<{ - readonly node: ESTree.Node; - readonly messageId: - | 'stringTemporalField' - | 'handRolledTemporalCodec' - | 'stringTemporalMember'; - readonly data: Record; - readonly start: number; - }> = []; - /** Spans of hand-rolled temporal codecs already reported — consumers are not re-reported. */ - const codecSpans: Array<{ start: number; end: number }> = []; - + reports.length = 0; + codecSpans.length = 0; const hasSchema = locals.schema.size > 0 || locals.barrel.size > 0 || locals.members.size > 0; - - // Lane 2: hand-rolled temporal string codecs (`Schema.String.check(Schema.isPattern(...))`). if (hasSchema) { - for (const call of patternCalls) { - const first = call.arguments[0]; - if (first === undefined || first.type === 'SpreadElement') continue; - const raw = regexSource(first, 0); - if (raw === null) continue; - const source = normaliseRegexSource(raw); - const isCalendarDate = CALENDAR_DATE_SOURCE.test(source); - const isIsoTime = ISO_TIME_SOURCE.test(source); - if (!isCalendarDate && !isIsoTime) continue; - const target = enclosingCheck(call); - let result = target; - for (let depth = 0; depth < 12; depth += 1) { - const parent = result.parent; - if ( - parent?.type === 'MemberExpression' && - parent.object === result && - parent.parent?.type === 'CallExpression' && - TRANSPARENT_METHODS.has(memberName(parent) ?? '') - ) { - result = parent.parent; - } else if ( - parent?.type === 'CallExpression' && - parent.arguments.some((argument) => argument === result) && - parent.callee.type === 'MemberExpression' && - memberName(parent.callee) === 'pipe' - ) { - result = parent; - } else break; - } - if ( - result !== target && - !isStringRooted(result, new Set(), 0, { viaReportedCodec: false }) - ) - continue; - codecSpans.push({ end: target.end, start: target.start }); - const owner = enclosingDeclarator(target); - if (owner !== null) reportedCodecDeclarators.add(owner.start); - reports.push({ - data: { kind: isIsoTime ? 'timestamp' : 'calendar date' }, - messageId: 'handRolledTemporalCodec', - node: target, - start: target.start, - }); - } + patternCalls.forEach(reportCodec); + reportFields(); } - - /** Does `node`'s own subtree contain a hand-rolled codec this rule already reported? */ - const containsReportedCodec = (node: ESTree.Node): boolean => - codecSpans.some((span) => span.start >= node.start && span.end <= node.end); - - // Lane 1: temporal fields inside Schema field bags. - if (hasSchema) { - const fieldBags = collectFieldBags(); - for (const object of objects) { - if (!fieldBags.has(object.start)) continue; - for (const property of object.properties) { - if (property.type !== 'Property') continue; - if (property.kind !== 'init' || property.method) continue; - const key = propertyKey(property); - if (key === null || !isTemporalKey(key)) continue; - // A value that is, or resolves to, an in-file codec this rule already reports is the - // same defect; fixing the shared codec fixes every field that references it. - if (containsReportedCodec(property)) continue; - const trace: StringRootTrace = { viaReportedCodec: false }; - if (!isStringRooted(property.value, new Set(), 0, trace)) continue; - if (trace.viaReportedCodec) continue; - reports.push({ - data: { key, replacement: replacementFor(key) }, - messageId: 'stringTemporalField', - node: property, - start: property.start, - }); - } - } - } - - // Lane 3: `readonly createdAt: string` DTO members (no `effect` import required). - if (options.includeTypeMembers) { - for (const signature of typeMembers) { - const owner = signature.parent; - if (owner === null || owner === undefined) continue; - if (owner.type !== 'TSInterfaceBody' && owner.type !== 'TSTypeLiteral') continue; - const key = signatureKey(signature); - if (key === null || !isTemporalKey(key)) continue; - // An i18n label bag (`CustomerDetailCopy`) keyed by field name holds translated - // column headings, not values; no temporal codec can model "Created". - const typeName = enclosingTypeName(owner); - if (ignoreType !== null && typeName !== null && ignoreType.test(typeName)) continue; - // A rendered projection beside its machine value (`createdAt` + `createdAtIso`). - if (hasProjectionSibling(key, siblingKeys(owner))) continue; - // A parameter annotation consumes a contract declared elsewhere. - if (isParameterTypeLiteral(owner)) continue; - const annotation = signature.typeAnnotation; - if (annotation === null || annotation === undefined) continue; - if (!isPlainStringType(annotation.typeAnnotation, new Set(), 0)) continue; - reports.push({ - data: { key, replacement: replacementFor(key) }, - messageId: 'stringTemporalMember', - node: signature, - start: signature.start, - }); - } - } - + if (options.includeTypeMembers) typeMembers.forEach(reportTypeMember); reports.sort((left, right) => left.start - right.start); for (const report of reports) { context.report({ data: report.data, messageId: report.messageId, node: report.node }); diff --git a/app/tools/oxlint/effect-native/rules/no-structural-document-walking.ts b/app/tools/oxlint/effect-native/rules/no-structural-document-walking.ts index 5813f7dca..35de64963 100644 --- a/app/tools/oxlint/effect-native/rules/no-structural-document-walking.ts +++ b/app/tools/oxlint/effect-native/rules/no-structural-document-walking.ts @@ -72,27 +72,22 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; - -import { collectEffectBindings } from '../shared/effect-imports.ts'; -import type { EffectBindings } from '../shared/effect-imports.ts'; -import { isTestFile, matchesAny, normalisePath } from '../shared/paths.ts'; +import type { Context, ESTree, Variable } from '@oxlint/plugins'; + +import { + parentOf, + unwrapNode as unwrap, + staticString, + nearestFunction, + walk, +} from '../shared/ast.ts'; +import { resolveVariable } from '../shared/bindings.ts'; +import { importedName } from '../shared/imports.ts'; +import { stringList } from '../shared/options.ts'; +import { isTestFile, matchesAny, workspacePath } from '../shared/paths.ts'; type AnyNode = ESTree.Node; -const WORKSPACE_MARKERS: readonly string[] = ['/apps/', '/verticals/', '/packages/', '/scripts/']; - -/** - * Absolute filename → the workspace-relative path the scope globs are written against. The *last* - * marker wins so real sources and the plugin's own fixtures classify identically. - */ -function workspacePath(filename: string): string { - const unified = filename.replaceAll('\\', '/'); - let best = -1; - for (const marker of WORKSPACE_MARKERS) best = Math.max(best, unified.lastIndexOf(marker)); - return best === -1 ? normalisePath(unified) : unified.slice(best + 1); -} - const DEFAULT_INCLUDE_PATHS: readonly string[] = [ 'apps/**', 'verticals/**', @@ -169,12 +164,6 @@ const DEFAULTS: RuleOptions = { allowInKeys: [...DEFAULT_ALLOW_IN_KEYS], }; -function stringList(value: unknown, fallback: readonly string[]): readonly string[] { - return Array.isArray(value) && value.every((entry) => typeof entry === 'string') - ? (value as readonly string[]) - : fallback; -} - function readOptions(raw: unknown): RuleOptions { const given = (raw ?? {}) as Partial>; const includePaths = stringList(given.includePaths, DEFAULTS.includePaths); @@ -199,46 +188,12 @@ function compilePattern(source: string): RegExp { } } -function parentOf(node: AnyNode): AnyNode | null { - return (node as { parent?: AnyNode | null }).parent ?? null; -} - function spanOf(node: AnyNode): { readonly start: number; readonly end: number } { return node as unknown as { readonly start: number; readonly end: number }; } -/** Strip parentheses, chains and type wrappers. */ -function unwrap(node: AnyNode): AnyNode { - let current = node; - for (;;) { - if (current.type === 'ChainExpression') { - current = (current as { expression: AnyNode }).expression; - continue; - } - if (TRANSPARENT_PARENTS.has(current.type)) { - const inner = (current as { expression?: AnyNode }).expression; - if (inner === undefined) return current; - current = inner; - continue; - } - return current; - } -} - -/** A statically known string operand (`'x'`, `"x"`, `` `x` ``), or null. */ function asStringLiteral(node: AnyNode): string | null { - const expression = unwrap(node); - if (expression.type === 'Literal') { - const value = (expression as { value?: unknown }).value; - return typeof value === 'string' ? value : null; - } - if (expression.type === 'TemplateLiteral') { - const template = expression as ESTree.TemplateLiteral; - const quasi = template.quasis[0]; - if (template.expressions.length !== 0 || quasi === undefined) return null; - return quasi.value.cooked ?? quasi.value.raw; - } - return null; + return staticString(node, { unwrap: {}, templates: true, rawTemplates: true }); } /** `x.y` / `x["y"]` → `"y"`; a dynamic key → `null`. */ @@ -249,16 +204,6 @@ function staticPropertyName(node: ESTree.MemberExpression): string | null { return asStringLiteral(property); } -function resolveVariable(context: Context, name: string, from: AnyNode): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(from); - while (scope !== null) { - const variable = scope.set.get(name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; -} - /** `true` when `node` is the unshadowed global `name`. */ function isUnshadowedGlobal(context: Context, node: AnyNode, name: string): boolean { if (node.type !== 'Identifier') return false; @@ -281,11 +226,16 @@ function isUnshadowedGlobal(context: Context, node: AnyNode, name: string): bool ); } -/** `true` when the variable, if any, comes from an import (so `Predicate` reached via a barrel counts). */ -function isImportedOrGlobal(context: Context, node: AnyNode, name: string): boolean { - const variable = resolveVariable(context, name, node); - if (variable === null || variable.defs.length === 0) return true; - return variable.defs.every((definition) => definition.type === 'ImportBinding'); +function typeofComparison( + binary: ESTree.BinaryExpression, +): { argument: AnyNode; other: AnyNode } | null { + const left = unwrap(binary.left); + const right = unwrap(binary.right); + if (left.type === 'UnaryExpression' && left.operator === 'typeof') + return { argument: unwrap(left.argument), other: right }; + if (right.type === 'UnaryExpression' && right.operator === 'typeof') + return { argument: unwrap(right.argument), other: left }; + return null; } /** @@ -365,7 +315,6 @@ export const rule = defineRule({ if (matchesAny(path, options.allowPaths)) return {}; if (options.ignoreTestFiles && isTestFile(path)) return {}; - const bindings: EffectBindings = collectEffectBindings(context.sourceCode.ast); const documentIdentifier = compilePattern(options.documentIdentifiers); const allowedKeys = new Set(options.allowInKeys); /** Spans already reported as a whole object-shape guard; nested field probes stay silent there. */ @@ -417,45 +366,47 @@ export const rule = defineRule({ * `Predicate` is accepted when it is an import (a re-export barrel this rule cannot follow), * never when it is a local object literal. */ + const namedPredicateIdentity = ( + source: string, + imported: string, + submodule: boolean, + ): string | null => { + if (submodule) return imported; + return source === 'effect' && imported === 'Predicate' ? '@predicate' : null; + }; + const importPredicateIdentity = (def: Variable['defs'][number]): string | null => { + if ( + def.type !== 'ImportBinding' || + def.parent?.type !== 'ImportDeclaration' || + def.parent.importKind === 'type' + ) + return null; + const source = def.parent.source.value; + const submodule = /^effect\/(?:.*\/)?Predicate$/u.test(source); + if (def.node.type === 'ImportNamespaceSpecifier') return submodule ? '@predicate' : null; + if (def.node.type !== 'ImportSpecifier' || def.node.importKind === 'type') return null; + return namedPredicateIdentity(source, importedName(def.node), submodule); + }; + const constantInitializer = (def: Variable['defs'][number]): AnyNode | null => { + if (def.type !== 'Variable' || def.node.type !== 'VariableDeclarator') return null; + if (def.node.id.type !== 'Identifier' || def.node.parent?.type !== 'VariableDeclaration') + return null; + return def.node.parent.kind === 'const' ? def.node.init : null; + }; const predicateIdentity = (input: AnyNode, depth = 0): string | null => { if (depth > 12) return null; const node = unwrap(input); - if (node.type === 'MemberExpression') { + if (node.type === 'MemberExpression') return predicateIdentity(node.object, depth + 1) === '@predicate' ? staticPropertyName(node) : null; - } if (node.type !== 'Identifier') return null; const variable = resolveVariable(context, node.name, node); for (const def of variable?.defs ?? []) { - if ( - def.type === 'ImportBinding' && - def.parent?.type === 'ImportDeclaration' && - def.parent.importKind !== 'type' - ) { - const source = def.parent.source.value; - if ( - def.node.type === 'ImportNamespaceSpecifier' && - /^effect\/(?:.*\/)?Predicate$/u.test(source) - ) - return '@predicate'; - if (def.node.type !== 'ImportSpecifier' || def.node.importKind === 'type') continue; - const imported = - def.node.imported.type === 'Identifier' - ? def.node.imported.name - : def.node.imported.value; - if (/^effect\/(?:.*\/)?Predicate$/u.test(source)) return imported; - if (source === 'effect' && imported === 'Predicate') return '@predicate'; - } - if ( - def.type === 'Variable' && - def.node.type === 'VariableDeclarator' && - def.node.init && - def.node.id.type === 'Identifier' && - def.node.parent?.type === 'VariableDeclaration' && - def.node.parent.kind === 'const' - ) - return predicateIdentity(def.node.init, depth + 1); + const imported = importPredicateIdentity(def); + if (imported !== null) return imported; + const initializer = constantInitializer(def); + if (initializer) return predicateIdentity(initializer, depth + 1); } return null; }; @@ -484,19 +435,9 @@ export const rule = defineRule({ if (expression.type === 'BinaryExpression') { const binary = expression as ESTree.BinaryExpression; if (!EQUALITY_OPERATORS.has(binary.operator)) return null; - const left = unwrap(binary.left as AnyNode); - const right = unwrap(binary.right as AnyNode); - const typeofSide = - left.type === 'UnaryExpression' && (left as ESTree.UnaryExpression).operator === 'typeof' - ? left - : right.type === 'UnaryExpression' && - (right as ESTree.UnaryExpression).operator === 'typeof' - ? right - : null; - if (typeofSide === null) return null; - const other = typeofSide === left ? right : left; - if (asStringLiteral(other) !== 'object') return null; - return unwrap((typeofSide as ESTree.UnaryExpression).argument as AnyNode); + const comparison = typeofComparison(binary); + if (!comparison || asStringLiteral(comparison.other) !== 'object') return null; + return comparison.argument; } if (expression.type !== 'CallExpression') return null; const call = expression as ESTree.CallExpression; @@ -585,31 +526,34 @@ export const rule = defineRule({ ); }; const recursiveCache = new WeakMap(); + const functionIdentifier = (fn: AnyNode): ESTree.BindingIdentifier | null => { + if (fn.type === 'FunctionDeclaration' || fn.type === 'FunctionExpression') return fn.id; + const parent = parentOf(fn); + return parent?.type === 'VariableDeclarator' && parent.id.type === 'Identifier' + ? parent.id + : null; + }; const inGenericRecursiveTraversal = (node: AnyNode): boolean => { - let fn: AnyNode | null = parentOf(node); - while ( - fn && - !['ArrowFunctionExpression', 'FunctionDeclaration', 'FunctionExpression'].includes(fn.type) - ) - fn = parentOf(fn); + const fn = nearestFunction(node); if (!fn) return false; const cached = recursiveCache.get(fn); if (cached !== undefined) return cached; - const id = - fn.type === 'FunctionDeclaration' || fn.type === 'FunctionExpression' - ? fn.id - : fn.parent?.type === 'VariableDeclarator' && fn.parent.id.type === 'Identifier' - ? fn.parent.id - : null; + const id = functionIdentifier(fn); if (!id) return false; const binding = resolveVariable(context, id.name, id); let recursive = false, genericKeys = false, array = false; - const visit = (current: AnyNode): void => { - if (current !== fn && ['FunctionDeclaration', 'FunctionExpression'].includes(current.type)) - return; - if (current.type === 'CallExpression') { + walk( + fn, + {}, + (current) => { + if ( + current !== fn && + ['FunctionDeclaration', 'FunctionExpression'].includes(current.type) + ) + return false; + if (current.type !== 'CallExpression') return; const callee = unwrap(current.callee); if ( callee.type === 'Identifier' && @@ -621,17 +565,9 @@ export const rule = defineRule({ ) genericKeys = true; if (isGlobalMethod(callee, 'Array', 'isArray')) array = true; - } - for (const [key, value] of Object.entries(current)) { - if (key === 'parent' || key === 'loc' || key === 'range') continue; - if (Array.isArray(value)) - for (const child of value) { - if (child && typeof child === 'object' && 'type' in child) visit(child as AnyNode); - } - else if (value && typeof value === 'object' && 'type' in value) visit(value as AnyNode); - } - }; - visit(fn); + }, + false, + ); const result = recursive && genericKeys && array; recursiveCache.set(fn, result); return result; @@ -641,6 +577,89 @@ export const rule = defineRule({ return first !== undefined && first.type !== 'SpreadElement' && isDocumentReceiver(first); }; + const isSerializedComparison = (node: ESTree.BinaryExpression): boolean => { + const left = unwrap(node.left), + right = unwrap(node.right); + if (left.type !== 'CallExpression' || right.type !== 'CallExpression') return false; + return ( + isGlobalMethod(left.callee, 'JSON', 'stringify') && + isGlobalMethod(right.callee, 'JSON', 'stringify') && + (serializedDocument(left) || serializedDocument(right)) + ); + }; + const reportMembership = ( + node: AnyNode, + receiver: AnyNode | undefined, + keyNode: AnyNode | undefined, + ): void => { + if (!receiver || receiver.type === 'SpreadElement' || !isDocumentReceiver(receiver)) return; + if (!keyNode || keyNode.type === 'SpreadElement' || keyNode.type === 'PrivateIdentifier') + return; + const key = asStringLiteral(keyNode); + if (key !== null && !allowedKeys.has(key)) report(node, 'documentKeyProbe'); + }; + const fieldCallTarget = (call: ESTree.CallExpression): AnyNode | null => { + const arrayTarget = arrayGuardArgument(call); + if (arrayTarget !== null && isDocumentField(arrayTarget)) return arrayTarget; + const guard = predicateGuardName(call.callee); + if (guard === null || !(FIELD_GUARDS.has(guard) || OBJECT_GUARDS.has(guard))) return null; + const target = soleArgument(call); + return target !== null && isDocumentField(target) ? target : null; + }; + const reportFieldCall = (call: ESTree.CallExpression): boolean => { + if (fieldCallTarget(call) === null) return false; + if (!insideReportedGuard(call)) report(call, 'documentFieldGuard'); + return true; + }; + const spreadKeys = (node: AnyNode): AnyNode => { + if ( + node.type === 'ArrayExpression' && + node.elements.length === 1 && + node.elements[0]?.type === 'SpreadElement' + ) + return unwrap(node.elements[0].argument); + return node; + }; + const reportExactKeyJoin = ( + call: ESTree.CallExpression, + member: ESTree.MemberExpression, + ): void => { + const sorted = unwrap(member.object); + if (sorted.type !== 'CallExpression') return; + const sortCallee = unwrap(sorted.callee); + if (sortCallee.type !== 'MemberExpression') return; + const method = staticPropertyName(sortCallee); + if (method === null || !SORT_METHODS.has(method)) return; + const keys = spreadKeys(unwrap(sortCallee.object)); + if (keys.type !== 'CallExpression' || !isGlobalMethod(keys.callee, 'Object', 'keys')) return; + const receiver = soleArgument(keys); + if (receiver && isDocumentReceiver(receiver)) report(call, 'exactKeyJoin'); + }; + const isStaticMembership = (member: ESTree.MemberExpression, method: string | null): boolean => + (method === 'hasOwn' && isGlobalHost(member.object, 'Object')) || + (method === 'has' && isGlobalHost(member.object, 'Reflect')); + const reportMemberCall = ( + call: ESTree.CallExpression, + member: ESTree.MemberExpression, + ): void => { + const method = staticPropertyName(member); + if (isStaticMembership(member, method)) { + reportMembership(call, call.arguments[0], call.arguments[1]); + return; + } + if (method === 'hasOwnProperty') { + reportMembership(call, member.object, call.arguments[0]); + return; + } + if (method === 'call') { + const host = unwrap(member.object); + if (host.type === 'MemberExpression' && staticPropertyName(host) === 'hasOwnProperty') + reportMembership(call, call.arguments[0], call.arguments[1]); + return; + } + if (method === 'join') reportExactKeyJoin(call, member); + }; + return { LogicalExpression(node) { const expression = node as unknown as AnyNode; @@ -664,153 +683,26 @@ export const rule = defineRule({ }, BinaryExpression(node) { - const expression = node as unknown as AnyNode; - const binary = node as ESTree.BinaryExpression; - - // `'field' in document` — a hand-written key set. - if (binary.operator === 'in') { - const left = binary.left as AnyNode; - if (left.type === 'PrivateIdentifier') return; - const key = asStringLiteral(left); - if (key === null || allowedKeys.has(key) || !isDocumentReceiver(binary.right as AnyNode)) - return; - report(expression, 'documentKeyProbe'); + if (node.operator === 'in') { + reportMembership(node, node.right, node.left); return; } - - if (!EQUALITY_OPERATORS.has(binary.operator)) return; - const left = unwrap(binary.left as AnyNode); - const right = unwrap(binary.right as AnyNode); - - // `JSON.stringify(a) === JSON.stringify(b)` — structural equality by serialized text. - if ( - left.type === 'CallExpression' && - right.type === 'CallExpression' && - isGlobalMethod((left as ESTree.CallExpression).callee as AnyNode, 'JSON', 'stringify') && - isGlobalMethod((right as ESTree.CallExpression).callee as AnyNode, 'JSON', 'stringify') && - (serializedDocument(left) || serializedDocument(right)) - ) { - report(expression, 'serializedComparison'); + if (!EQUALITY_OPERATORS.has(node.operator)) return; + if (isSerializedComparison(node)) { + report(node, 'serializedComparison'); return; } - - // `typeof decoded.kind === 'string'` — a field's type decided here, not in the Schema. - const typeofSide = - left.type === 'UnaryExpression' && (left as ESTree.UnaryExpression).operator === 'typeof' - ? left - : right.type === 'UnaryExpression' && - (right as ESTree.UnaryExpression).operator === 'typeof' - ? right - : null; - if (typeofSide === null) return; - const other = typeofSide === left ? right : left; - if (asStringLiteral(other) === null) return; - const argument = unwrap((typeofSide as ESTree.UnaryExpression).argument as AnyNode); - if (!isDocumentField(argument)) return; - if (insideReportedGuard(expression) || inGenericRecursiveTraversal(expression)) return; - report(expression, 'documentFieldGuard'); + const comparison = typeofComparison(node); + if (!comparison || asStringLiteral(comparison.other) === null) return; + if (!isDocumentField(comparison.argument)) return; + if (insideReportedGuard(node) || inGenericRecursiveTraversal(node)) return; + report(node, 'documentFieldGuard'); }, - CallExpression(node) { - const expression = node as unknown as AnyNode; - const call = node as ESTree.CallExpression; - const callee = unwrap(call.callee as AnyNode); - if (inGenericRecursiveTraversal(expression)) return; - - // `Array.isArray(topology['verticals'])`, `Predicate.isString(decoded.id)`. - const arrayTarget = arrayGuardArgument(expression); - if (arrayTarget !== null && isDocumentField(arrayTarget)) { - if (!insideReportedGuard(expression)) report(expression, 'documentFieldGuard'); - return; - } - const guard = predicateGuardName(call.callee as AnyNode); - if (guard !== null && (FIELD_GUARDS.has(guard) || OBJECT_GUARDS.has(guard))) { - const target = soleArgument(call); - if (target !== null && isDocumentField(target)) { - if (!insideReportedGuard(expression)) report(expression, 'documentFieldGuard'); - return; - } - } - + if (inGenericRecursiveTraversal(node) || reportFieldCall(node)) return; + const callee = unwrap(node.callee); if (callee.type !== 'MemberExpression') return; - const member = callee as ESTree.MemberExpression; - const method = staticPropertyName(member); - if (method === null) return; - - // `Object.hasOwn(document, 'field')`. - if ( - (method === 'hasOwn' && isGlobalHost(member.object as AnyNode, 'Object')) || - (method === 'has' && isGlobalHost(member.object as AnyNode, 'Reflect')) - ) { - const receiver = call.arguments[0]; - if (!receiver || receiver.type === 'SpreadElement' || !isDocumentReceiver(receiver)) - return; - const [, second] = call.arguments; - if ( - second !== undefined && - second.type !== 'SpreadElement' && - asStringLiteral(second as AnyNode) !== null - ) { - const key = asStringLiteral(second as AnyNode); - if (key !== null && !allowedKeys.has(key)) report(expression, 'documentKeyProbe'); - } - return; - } - - // `document.hasOwnProperty('field')`. - if (method === 'hasOwnProperty') { - if (!isDocumentReceiver(member.object)) return; - if (!isDocumentReceiver(member.object)) return; - const [first] = call.arguments; - if (first !== undefined && first.type !== 'SpreadElement') { - const key = asStringLiteral(first as AnyNode); - if (key !== null && !allowedKeys.has(key)) report(expression, 'documentKeyProbe'); - } - return; - } - - // `Object.prototype.hasOwnProperty.call(document, 'field')`. - if (method === 'call') { - const receiver = call.arguments[0]; - if (!receiver || receiver.type === 'SpreadElement' || !isDocumentReceiver(receiver)) - return; - const host = unwrap(member.object as AnyNode); - if ( - host.type === 'MemberExpression' && - staticPropertyName(host as ESTree.MemberExpression) === 'hasOwnProperty' - ) { - const target = call.arguments[0]; - if (!target || target.type === 'SpreadElement' || !isDocumentReceiver(target)) return; - const [, second] = call.arguments; - if (second !== undefined && second.type !== 'SpreadElement') { - const key = asStringLiteral(second as AnyNode); - if (key !== null && !allowedKeys.has(key)) report(expression, 'documentKeyProbe'); - } - } - return; - } - - // `Object.keys(document).toSorted().join('\0')` — an exact-key comparison. - if (method !== 'join') return; - const sorted = unwrap(member.object as AnyNode); - if (sorted.type !== 'CallExpression') return; - const sortCallee = unwrap((sorted as ESTree.CallExpression).callee as AnyNode); - if (sortCallee.type !== 'MemberExpression') return; - const sortMethod = staticPropertyName(sortCallee as ESTree.MemberExpression); - if (sortMethod === null || !SORT_METHODS.has(sortMethod)) return; - let keys = unwrap((sortCallee as ESTree.MemberExpression).object as AnyNode); - if ( - keys.type === 'ArrayExpression' && - keys.elements.length === 1 && - keys.elements[0]?.type === 'SpreadElement' - ) - keys = unwrap(keys.elements[0].argument); - if (keys.type !== 'CallExpression') return; - if (!isGlobalMethod((keys as ESTree.CallExpression).callee as AnyNode, 'Object', 'keys')) - return; - const keyReceiver = soleArgument(keys as ESTree.CallExpression); - if (!keyReceiver || !isDocumentReceiver(keyReceiver)) return; - report(expression, 'exactKeyJoin'); + reportMemberCall(node, callee); }, }; }, diff --git a/app/tools/oxlint/effect-native/rules/no-symbol-slotted-operation-record.ts b/app/tools/oxlint/effect-native/rules/no-symbol-slotted-operation-record.ts index 2b2c9a478..32a5277b5 100644 --- a/app/tools/oxlint/effect-native/rules/no-symbol-slotted-operation-record.ts +++ b/app/tools/oxlint/effect-native/rules/no-symbol-slotted-operation-record.ts @@ -16,46 +16,15 @@ import { defineRule } from '@oxlint/plugins'; import type { Context, ESTree, Scope } from '@oxlint/plugins'; -import { isTestFile, matchesAny, normalisePath } from '../shared/paths.ts'; +import { isTestFile, matchesAny, workspacePath } from '../shared/paths.ts'; +import { booleanOption as boolean, stringList } from '../shared/options.ts'; +import { unwrapNode, unwrapType } from '../shared/ast.ts'; +import { spanOf } from '../shared/reporting.ts'; type AnyNode = ESTree.Node; -const WORKSPACE_MARKERS: readonly string[] = ['/apps/', '/verticals/', '/packages/', '/scripts/']; - -/** - * Absolute filename → the workspace-relative path the scope globs are written against. - * - * The *last* workspace marker wins so real sources (`/packages/core-runtime/src/x.ts`) and - * this plugin's fixtures (`tools/.../fixtures//invalid/packages/...`) classify identically. - */ -function workspacePath(filename: string): string { - const unified = filename.replaceAll('\\', '/'); - let best = -1; - for (const marker of WORKSPACE_MARKERS) best = Math.max(best, unified.lastIndexOf(marker)); - return best === -1 ? normalisePath(unified) : unified.slice(best + 1); -} - const DEFAULT_INCLUDE_PATHS: readonly string[] = ['apps/**', 'verticals/**', 'packages/**']; -/** `Symbol.iterator` and friends implement a language protocol, not a hand-rolled capability slot. */ -const WELL_KNOWN_SYMBOLS = new Set([ - 'asyncDispose', - 'asyncIterator', - 'dispose', - 'hasInstance', - 'isConcatSpreadable', - 'iterator', - 'match', - 'matchAll', - 'replace', - 'search', - 'species', - 'split', - 'toPrimitive', - 'toStringTag', - 'unscopables', -]); - interface RuleOptions { readonly allowBrandMarkers: boolean; readonly allowSameFileAccessors: boolean; @@ -72,16 +41,6 @@ const DEFAULTS: RuleOptions = { includeTests: false, }; -function stringList(value: unknown, fallback: readonly string[]): readonly string[] { - return Array.isArray(value) && value.every((entry) => typeof entry === 'string') - ? (value as readonly string[]) - : fallback; -} - -function boolean(value: unknown, fallback: boolean): boolean { - return typeof value === 'boolean' ? value : fallback; -} - function readOptions(raw: unknown): RuleOptions { const given = (raw ?? {}) as Partial>; const includePaths = stringList(given.includePaths, DEFAULTS.includePaths); @@ -99,13 +58,6 @@ interface Span { readonly start: number; } -function spanOf(node: AnyNode | null | undefined): Span | null { - const span = node as unknown as { end?: number; start?: number } | null | undefined; - if (span === null || span === undefined) return null; - if (typeof span.start !== 'number' || typeof span.end !== 'number') return null; - return { end: span.end, start: span.start }; -} - /** * `true` when a scope `Definition` node is (or textually wraps) one of the collected declarators. * @@ -120,32 +72,17 @@ function definesSpan(definitionNode: AnyNode | null, declarators: readonly Span[ ); } -/** `(x)` / `x as const` / `x satisfies T` / `x` → `x`. */ -function unwrapValue(node: AnyNode): AnyNode { - let current = node; - for (let guard = 0; guard < 8; guard += 1) { - if ( - current.type === 'ParenthesizedExpression' || - current.type === 'TSAsExpression' || - current.type === 'TSSatisfiesExpression' || - current.type === 'TSNonNullExpression' || - current.type === 'TSTypeAssertion' || - current.type === 'TSInstantiationExpression' - ) { - current = (current as unknown as { expression: AnyNode }).expression; - continue; - } - return current; - } - return current; -} +const VALUE_WRAPPERS = new Set([ + 'ParenthesizedExpression', + 'TSAsExpression', + 'TSSatisfiesExpression', + 'TSNonNullExpression', + 'TSTypeAssertion', + 'TSInstantiationExpression', +]); -function unwrapType(node: AnyNode): AnyNode { - let current = node; - for (let guard = 0; guard < 8 && current.type === 'TSParenthesizedType'; guard += 1) { - current = (current as ESTree.TSParenthesizedType).typeAnnotation as AnyNode; - } - return current; +function unwrapValue(node: AnyNode): AnyNode { + return unwrapNode(node, { wrappers: VALUE_WRAPPERS, maxDepth: 8 }); } /** `Record` → `"Record"`, `A.B` → `"A.B"`. */ @@ -181,6 +118,10 @@ function isSymbolFactoryCall(node: AnyNode | null | undefined, symbolIsGlobal: b const callee = unwrapValue((call as ESTree.CallExpression).callee as AnyNode); if (callee.type === 'Identifier') return (callee as { name: string }).name === 'Symbol'; if (callee.type !== 'MemberExpression') return false; + return isSymbolForMember(callee as ESTree.MemberExpression); +} + +function isSymbolForMember(callee: ESTree.MemberExpression): boolean { const member = callee as unknown as { computed: boolean; object: AnyNode; property: AnyNode }; if ( member.computed || @@ -248,20 +189,81 @@ function programVariableDeclarations( statements.push(...statement.body.body); if (statement.type === 'ExportNamedDeclaration' && statement.declaration) statements.push(statement.declaration); - if (statement.type === 'VariableDeclaration') { - declarations.push(statement as unknown as ESTree.VariableDeclaration); - continue; - } - if (statement.type === 'ExportNamedDeclaration') { - const inner = (statement as unknown as { declaration: AnyNode | null }).declaration; - if (inner !== null && inner.type === 'VariableDeclaration') { - declarations.push(inner as unknown as ESTree.VariableDeclaration); - } - } + const declaration = statementVariableDeclaration(statement); + if (declaration) declarations.push(declaration); } return declarations; } +function statementVariableDeclaration(statement: AnyNode): ESTree.VariableDeclaration | null { + if (statement.type === 'VariableDeclaration') return statement; + if (statement.type !== 'ExportNamedDeclaration') return null; + const inner = statement.declaration; + return inner?.type === 'VariableDeclaration' ? inner : null; +} + +function hasSymbolImport(program: ESTree.Program): boolean { + return program.body.some( + (statement) => + statement.type === 'ImportDeclaration' && + statement.specifiers.some((specifier) => specifier.local.name === 'Symbol'), + ); +} + +function symbolIsGlobal( + program: ESTree.Program, + declarations: readonly ESTree.VariableDeclaration[], +): boolean { + return ( + !hasSymbolImport(program) && + !declarations.some((declaration) => + declaration.declarations.some( + (declarator) => declarator.id.type === 'Identifier' && declarator.id.name === 'Symbol', + ), + ) + ); +} + +function collectLocalSymbols( + declarations: readonly ESTree.VariableDeclaration[], + globalSymbol: boolean, +): Map { + const symbols = new Map(); + for (const declaration of declarations) { + for (const declarator of declaration.declarations) { + collectSymbolDeclarator(symbols, declarator, globalSymbol); + } + } + return symbols; +} + +function collectSymbolDeclarator( + symbols: Map, + declarator: ESTree.VariableDeclarator, + globalSymbol: boolean, +): void { + const id = declarator.id; + if (id.type !== 'Identifier') return; + const annotated = isSymbolTypeAnnotation(typeOfAnnotation(id)); + if (!annotated && !isSymbolFactoryCall(declarator.init, globalSymbol)) return; + const span = spanOf(declarator); + if (!span) return; + const spans = symbols.get(id.name) ?? []; + spans.push(span); + symbols.set(id.name, spans); +} + +function hasNamedOrDefaultImport(program: ESTree.Program): boolean { + return program.body.some( + (statement) => + statement.type === 'ImportDeclaration' && + statement.specifiers.some( + (specifier) => + specifier.type === 'ImportSpecifier' || specifier.type === 'ImportDefaultSpecifier', + ), + ); +} + /** Effect-native rule: capabilities belong on a declared service surface, not in a symbol slot. */ export const rule = defineRule({ meta: { @@ -333,57 +335,9 @@ export const rule = defineRule({ const program = context.sourceCode.ast; - // `Symbol` must be the global one; a module-level `const Symbol = …` shadow disables the - // initialiser heuristic (the explicit `unique symbol` annotation still counts). - let symbolIsGlobal = true; - for (const declaration of programVariableDeclarations(program)) { - for (const declarator of declaration.declarations as readonly AnyNode[]) { - const id = (declarator as unknown as { id: AnyNode }).id; - if (id.type === 'Identifier' && (id as { name: string }).name === 'Symbol') - symbolIsGlobal = false; - } - } - for (const statement of program.body as readonly AnyNode[]) { - if (statement.type !== 'ImportDeclaration') continue; - for (const specifier of (statement as unknown as { specifiers: readonly AnyNode[] }) - .specifiers) { - const local = (specifier as unknown as { local: { name: string } }).local; - if (local.name === 'Symbol') symbolIsGlobal = false; - } - } - - /** Program-scope symbol bindings, by name, with the declarator spans that define them. */ - const localSymbols = new Map(); - for (const declaration of programVariableDeclarations(program)) { - for (const raw of declaration.declarations as readonly AnyNode[]) { - const declarator = raw as unknown as { id: AnyNode; init: AnyNode | null }; - const id = declarator.id; - if (id.type !== 'Identifier') continue; - const annotated = isSymbolTypeAnnotation( - typeOfAnnotation(id as unknown as { typeAnnotation?: unknown }), - ); - if (!annotated && !isSymbolFactoryCall(declarator.init, symbolIsGlobal)) continue; - const span = spanOf(raw); - if (span === null) continue; - const name = (id as { name: string }).name; - const spans = localSymbols.get(name) ?? []; - spans.push(span); - localSymbols.set(name, spans); - } - } - - const importedBindings = new Set(); - for (const statement of program.body as readonly AnyNode[]) { - if (statement.type !== 'ImportDeclaration') continue; - for (const specifier of (statement as unknown as { specifiers: readonly AnyNode[] }) - .specifiers) { - if (specifier.type !== 'ImportSpecifier' && specifier.type !== 'ImportDefaultSpecifier') - continue; - importedBindings.add((specifier as unknown as { local: { name: string } }).local.name); - } - } - - if (localSymbols.size === 0 && importedBindings.size === 0) return {}; + const declarations = programVariableDeclarations(program); + const localSymbols = collectLocalSymbols(declarations, symbolIsGlobal(program, declarations)); + if (localSymbols.size === 0 && !hasNamedOrDefaultImport(program)) return {}; /** * Classify a computed-key / computed-property identifier. diff --git a/app/tools/oxlint/effect-native/rules/no-sync-schema-codec.ts b/app/tools/oxlint/effect-native/rules/no-sync-schema-codec.ts index fde9fa443..dcfcb2b73 100644 --- a/app/tools/oxlint/effect-native/rules/no-sync-schema-codec.ts +++ b/app/tools/oxlint/effect-native/rules/no-sync-schema-codec.ts @@ -1,3 +1,8 @@ +import { + constSchemaAlias as constAlias, + destructuredSchemaIdentity as destructuredIdentity, +} from '../shared/schema-identity.ts'; +import { optionRecord } from '../shared/options.ts'; /** * Audit findings: **A3** — "Replace ambient configuration with Config, ConfigProvider, and Redacted" * and **A7** — "Give topology, composition, and authorization evidence shared Schemas" @@ -60,21 +65,17 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree, Variable } from '@oxlint/plugins'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; +import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; +import { booleanOption, stringArray } from '../shared/options.ts'; +import { keyName, memberName, unwrapNode } from '../shared/ast.ts'; +import { lookupVariable } from '../shared/bindings.ts'; +import { importedName } from '../shared/imports.ts'; +import { isNonReferencePosition, isInErasedTypePosition } from '../shared/reference-positions.ts'; -const SCHEMA_NAMESPACE = 'Schema'; -const EFFECT_ROOT_MODULE = 'effect'; const EFFECT_SCHEMA_MODULE = /^effect\/(?:.*\/)?Schema$/u; -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production defaults instead of forcing the - * fixture config to pass loosened options (which `run-on-repo.mts` reuses verbatim against the repo). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; - /** Synchronous, throwing codec entry points. Everything here has an `Effect`/`Result` sibling. */ const DEFAULT_MEMBERS = [ 'decodeSync', @@ -109,184 +110,95 @@ interface RuleOptions { readonly reexportModules: readonly string[]; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - -function boolean(value: unknown, fallback: boolean): boolean { - return typeof value === 'boolean' ? value : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { allowPaths: stringArray(record.allowPaths, DEFAULT_ALLOW_PATHS), - ignoreTestFiles: boolean(record.ignoreTestFiles, true), + ignoreTestFiles: booleanOption(record.ignoreTestFiles, true), members: stringArray(record.members, DEFAULT_MEMBERS), reexportModules: stringArray(record.reexportModules, DEFAULT_REEXPORT_MODULES), }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); +/** Runtime references exclude both name positions and erased TS ancestry. */ +function isDeclarationPosition(node: ESTree.Node): boolean { + return isNonReferencePosition(node, { variableBindings: true }) || isInErasedTypePosition(node); } -function importedName(specifier: ESTree.ImportSpecifier): string { - return specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; -} +type Definition = Variable['defs'][number]; -/** Non-computed `.decodeUnknownSync`, or computed `["decodeUnknownSync"]`. */ -function memberName(node: ESTree.MemberExpression): string | null { - if (!node.computed) return node.property.type === 'Identifier' ? node.property.name : null; - const property = unwrapExpression(node.property); - if (property.type === 'TemplateLiteral' && property.expressions.length === 0) - return property.quasis[0]?.value.cooked ?? null; - if (property.type === 'Literal' && typeof property.value === 'string') return property.value; - return null; +function schemaMember(host: string | null, member: string | null): string | null { + if (host === '@schema') return member; + return host === '@effect' && member === 'Schema' ? '@schema' : null; } -function lookupVariable( - context: Context, - identifier: Extract, -): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(identifier); - while (scope !== null) { - const variable = scope.set.get(identifier.name); - if (variable !== undefined) return variable; - scope = scope.upper; +function importIdentity(def: Definition, reexports: readonly string[]): string | null { + const specifier = def.node; + const declaration = def.parent; + if (declaration?.type !== 'ImportDeclaration' || declaration.importKind === 'type') return null; + if (specifier.type === 'ImportSpecifier' && specifier.importKind === 'type') return null; + if (EFFECT_SCHEMA_MODULE.test(declaration.source.value)) { + return submoduleImportIdentity(specifier); } - return null; + if (declaration.source.value !== 'effect' && !matchesGlobs(declaration.source.value, reexports)) + return null; + return rootImportIdentity(specifier); } -/** Declaration / key positions where an identifier is not a *reference* to the import. */ -function isDeclarationPosition(node: Extract): boolean { - const parent = node.parent; - if (parent === null || parent === undefined) return true; - if (parent.type === 'ImportSpecifier' || parent.type === 'ImportDefaultSpecifier') return true; - if (parent.type === 'ImportNamespaceSpecifier' || parent.type === 'ExportSpecifier') return true; - // Erased type syntax is never a runtime codec reference. Expression wrappers retain values. - let ancestor: ESTree.Node | null = parent; - let child: ESTree.Node = node; - while (ancestor) { - if ( - ancestor.type.startsWith('TS') && - !('expression' in ancestor && ancestor.expression === child) - ) - return true; - child = ancestor; - ancestor = ancestor.parent ?? null; - } - if (parent.type === 'VariableDeclarator' && parent.id === node) return true; - if (parent.type === 'MemberExpression' && parent.property === node && !parent.computed) - return true; - if (parent.type === 'Property' && parent.key === node && !parent.computed) return true; - if (parent.type === 'PropertyDefinition' && parent.key === node && !parent.computed) return true; - if (parent.type === 'MethodDefinition' && parent.key === node && !parent.computed) return true; - return false; +function submoduleImportIdentity(specifier: ESTree.Node): string | null { + if (specifier.type === 'ImportNamespaceSpecifier') return '@schema'; + return specifier.type === 'ImportSpecifier' ? importedName(specifier) : null; } -function unwrapExpression(node: ESTree.Node): ESTree.Node { - let current = node; - while ( - [ - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'ChainExpression', - 'ParenthesizedExpression', - 'TSInstantiationExpression', - 'TSTypeAssertion', - ].includes(current.type) - ) { - if (!('expression' in current)) break; - current = current.expression as ESTree.Node; - } - return current; +function rootImportIdentity(specifier: ESTree.Node): string | null { + if (specifier.type === 'ImportNamespaceSpecifier') return '@effect'; + return specifier.type === 'ImportSpecifier' && importedName(specifier) === 'Schema' + ? '@schema' + : null; } -/** Resolve only lexical imports and immutable same-file aliases; no cross-file or mutation inference. */ -function schemaIdentity( +function identifierIdentity( context: Context, - input: ESTree.Node, - reexports: readonly string[] = [], - depth = 0, + node: Extract, + reexports: readonly string[], + depth: number, ): string | null { - if (depth > 16) return null; - const node = unwrapExpression(input); - if (node.type === 'MemberExpression') { - const host = schemaIdentity(context, node.object, reexports, depth + 1); - const member = memberName(node); - return host === '@schema' - ? member - : host === '@effect' && member === 'Schema' - ? '@schema' - : null; - } - if (node.type !== 'Identifier') return null; const variable = lookupVariable(context, node); if (!variable) return null; for (const def of variable.defs) { if (def.type === 'ImportBinding') { - const specifier = def.node; - const declaration = def.parent; - if (declaration?.type !== 'ImportDeclaration' || declaration.importKind === 'type') continue; - if (specifier.type === 'ImportSpecifier' && specifier.importKind === 'type') continue; - const source = declaration.source.value; - if (EFFECT_SCHEMA_MODULE.test(source)) { - if (specifier.type === 'ImportNamespaceSpecifier') return '@schema'; - if (specifier.type === 'ImportSpecifier') return importedName(specifier); - } - if (source === 'effect' || matchesGlobs(source, reexports)) { - if (specifier.type === 'ImportNamespaceSpecifier') return '@effect'; - if (specifier.type === 'ImportSpecifier' && importedName(specifier) === 'Schema') - return '@schema'; - } - } - if (def.type !== 'Variable' || def.node.type !== 'VariableDeclarator' || def.node.init === null) - continue; - const declarator = def.node; - if (declarator.init === null) continue; - if (declarator.parent?.type !== 'VariableDeclaration' || declarator.parent.kind !== 'const') - continue; - if (declarator.id.type === 'Identifier') - return schemaIdentity(context, declarator.init, reexports, depth + 1); - if (declarator.id.type !== 'ObjectPattern') continue; - const host = schemaIdentity(context, declarator.init, reexports, depth + 1); - for (const property of declarator.id.properties) { - if ( - property.type !== 'Property' || - property.value.type !== 'Identifier' || - property.value.name !== node.name - ) - continue; - const key = - !property.computed && property.key.type === 'Identifier' - ? property.key.name - : property.key.type === 'Literal' && typeof property.key.value === 'string' - ? property.key.value - : property.key.type === 'TemplateLiteral' && property.key.expressions.length === 0 - ? property.key.quasis[0]?.value.cooked - : null; - if (host === '@schema') return key ?? null; - if (host === '@effect' && key === 'Schema') return '@schema'; + const identity = importIdentity(def, reexports); + if (identity !== null) return identity; } + const alias = constAlias(def); + if (!alias?.init) continue; + if (alias.id.type === 'Identifier') + return schemaIdentity(context, alias.init, reexports, depth + 1); + if (alias.id.type !== 'ObjectPattern') continue; + const host = schemaIdentity(context, alias.init, reexports, depth + 1); + const identity = destructuredIdentity(alias.id, node.name, host); + if (identity !== undefined) return identity; } return null; } +/** Local until shared schemaIdentity preserves cooked template and wrapped computed keys. */ +function schemaIdentity( + context: Context, + input: ESTree.Node, + reexports: readonly string[] = [], + depth = 0, +): string | null { + if (depth > 16) return null; + const node: ESTree.Node = unwrapNode(input); + if (node.type === 'MemberExpression') + return schemaMember( + schemaIdentity(context, node.object, reexports, depth + 1), + memberName(node, { templates: true, unwrap: {} }), + ); + return node.type === 'Identifier' ? identifierIdentity(context, node, reexports, depth) : null; +} + export const rule = defineRule({ meta: { type: 'problem', @@ -375,15 +287,7 @@ export const rule = defineRule({ if (schemaIdentity(context, node.init, options.reexportModules) !== '@schema') return; for (const property of node.id.properties) { if (property.type !== 'Property') continue; - const key = property.key; - const member = - !property.computed && key.type === 'Identifier' - ? key.name - : key.type === 'Literal' && typeof key.value === 'string' - ? key.value - : key.type === 'TemplateLiteral' && key.expressions.length === 0 - ? key.quasis[0]?.value.cooked - : null; + const member = keyName(property.key, property.computed); if (member && members.has(member)) report(property, member); } }, diff --git a/app/tools/oxlint/effect-native/rules/no-threaded-correlation-parameter.ts b/app/tools/oxlint/effect-native/rules/no-threaded-correlation-parameter.ts index 2f7ff1cfb..6f5d25034 100644 --- a/app/tools/oxlint/effect-native/rules/no-threaded-correlation-parameter.ts +++ b/app/tools/oxlint/effect-native/rules/no-threaded-correlation-parameter.ts @@ -26,19 +26,15 @@ import { defineRule } from '@oxlint/plugins'; import { fileURLToPath } from 'node:url'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree, Variable } from '@oxlint/plugins'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; +import { isTestFile, matchesGlobs, rootedScopePath } from '../shared/paths.ts'; +import { keyName as staticKeyName, parentOf, unwrapBinding } from '../shared/ast.ts'; +import { lookupVariable } from '../shared/bindings.ts'; +import { compile, stringList } from '../shared/options.ts'; type AnyNode = ESTree.Node; -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the production `includePaths` defaults instead of - * forcing the fixture config to loosen them (`run-on-repo.mts` reuses that config verbatim). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; - const DEFAULT_AMBIENT_KEYS: readonly string[] = ['correlationId', 'traceId', 'traceparent']; // Source-verified boundary/value declarations, not an exemption for their nested operations. const DEFAULT_WIRE_TYPE_NAMES = @@ -49,9 +45,6 @@ const DEFAULT_IGNORE: readonly string[] = []; /** Type members are only inspected inside a real object-type body. */ const MEMBER_CONTAINERS = new Set(['TSInterfaceBody', 'TSTypeLiteral']); -/** Wrappers between a written parameter and the binding it introduces. */ -const PARAMETER_WRAPPERS = new Set(['AssignmentPattern', 'RestElement', 'TSParameterProperty']); - /** Type wrappers that never change which members an object type declares. */ const TYPE_WRAPPERS = new Set([ 'TSParenthesizedType', @@ -68,22 +61,6 @@ interface RuleOptions { readonly wireTypeNames: RegExp; } -function stringList(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - return value.every((entry) => typeof entry === 'string') - ? (value as readonly string[]) - : fallback; -} - -function compile(value: unknown, fallback: string): RegExp { - const source = typeof value === 'string' && value.length > 0 ? value : fallback; - try { - return new RegExp(source, 'u'); - } catch { - return new RegExp(fallback, 'u'); - } -} - function readOptions(raw: unknown): RuleOptions { const given = (raw ?? {}) as Record; const ambientKeys = stringList(given.ambientKeys, DEFAULT_AMBIENT_KEYS); @@ -98,49 +75,49 @@ function readOptions(raw: unknown): RuleOptions { } function scopePath(filename: string): string { - const unified = filename.replaceAll('\\', '/'); - const fixture = - /(?:^|\/)tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\/(.*)$/u.exec(unified); - if (fixture?.[1]) return fixture[1]; - const root = fileURLToPath(new URL('../../../../', import.meta.url)).replaceAll('\\', '/'); - return unified.startsWith(root) - ? unified.slice(root.length) - : normalisePath(unified).replace(FIXTURE_PREFIX, ''); + return rootedScopePath(filename, fileURLToPath(new URL('../../../../', import.meta.url))); } -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); +/** Private fields are accepted here in addition to the shared static-key policy. */ +function keyName(key: AnyNode, computed: boolean): string | null { + if (!computed && key.type === 'PrivateIdentifier') return key.name; + return staticKeyName(key, computed); } -function parentOf(node: AnyNode): AnyNode | null { - return (node as { parent?: AnyNode | null }).parent ?? null; -} +const NAMED_DECLARATIONS = new Set([ + 'ClassDeclaration', + 'ClassExpression', + 'FunctionDeclaration', + 'FunctionExpression', + 'TSDeclareFunction', + 'TSInterfaceDeclaration', + 'TSTypeAliasDeclaration', + 'VariableDeclarator', +]); +const KEYED_DECLARATIONS = new Set([ + 'AccessorProperty', + 'MethodDefinition', + 'Property', + 'PropertyDefinition', + 'TSMethodSignature', + 'TSPropertySignature', +]); -/** Named or static string/template identity keys; dynamic computed keys are unknown. */ -function keyName(key: AnyNode, computed: boolean): string | null { - if (!computed && (key.type === 'Identifier' || key.type === 'PrivateIdentifier')) return key.name; - if (key.type === 'TemplateLiteral' && key.expressions.length === 0) - return key.quasis[0]?.value.cooked ?? null; - if (key.type === 'Literal') { - const value = (key as { value?: unknown }).value; - return typeof value === 'string' ? value : null; +function declarationName(node: AnyNode): string | null { + if (NAMED_DECLARATIONS.has(node.type)) { + const id = (node as { id?: AnyNode | null }).id; + return id?.type === 'Identifier' ? id.name : null; } - return null; + if (!KEYED_DECLARATIONS.has(node.type)) return null; + const holder = node as unknown as { key: AnyNode; computed: boolean }; + return keyName(holder.key, holder.computed); } -/** `AssignmentPattern` / `RestElement` / `TSParameterProperty` → the binding they wrap. */ -function unwrapBinding(node: AnyNode): AnyNode { - let current = node; - for (let guard = 0; guard < 4; guard += 1) { - if (!PARAMETER_WRAPPERS.has(current.type)) return current; - const inner = - (current as { left?: AnyNode }).left ?? - (current as { argument?: AnyNode }).argument ?? - (current as { parameter?: AnyNode }).parameter; - if (inner === undefined) return current; - current = inner; - } - return current; +function stopsOwnerSearch(node: AnyNode): boolean { + if (node.type === 'BlockStatement') return true; + if (node.type !== 'ArrowFunctionExpression' && node.type !== 'FunctionExpression') return false; + const parent = parentOf(node); + return !parent || !['VariableDeclarator', 'Property', 'MethodDefinition'].includes(parent.type); } /** @@ -148,55 +125,14 @@ function unwrapBinding(node: AnyNode): AnyNode { * Used to name the candidate and apply the explicit boundary-name convention. */ function enclosingNames(node: AnyNode): readonly string[] { - const names: string[] = []; let current: AnyNode | null = parentOf(node); for (let guard = 0; guard < 32 && current !== null; guard += 1) { - switch (current.type) { - case 'ClassDeclaration': - case 'ClassExpression': - case 'FunctionDeclaration': - case 'FunctionExpression': - case 'TSDeclareFunction': - case 'TSInterfaceDeclaration': - case 'TSTypeAliasDeclaration': { - const id = (current as { id?: AnyNode | null }).id ?? null; - if (id !== null && id.type === 'Identifier') names.push((id as { name: string }).name); - break; - } - case 'VariableDeclarator': { - const id = (current as { id: AnyNode }).id; - if (id.type === 'Identifier') names.push((id as { name: string }).name); - break; - } - case 'AccessorProperty': - case 'MethodDefinition': - case 'Property': - case 'PropertyDefinition': - case 'TSMethodSignature': - case 'TSPropertySignature': { - const holder = current as unknown as { key: AnyNode; computed: boolean }; - const name = keyName(holder.key, holder.computed); - if (name !== null) names.push(name); - break; - } - default: - break; - } - if (names.length > 0) return names; - // Never inherit a wire-shaped ancestor through an unrelated anonymous callback/body. - if (current.type === 'BlockStatement') return names; - if (current.type === 'ArrowFunctionExpression' || current.type === 'FunctionExpression') { - const parent = parentOf(current); - if ( - parent?.type !== 'VariableDeclarator' && - parent?.type !== 'Property' && - parent?.type !== 'MethodDefinition' - ) - return names; - } + const name = declarationName(current); + if (name !== null) return [name]; + if (stopsOwnerSearch(current)) return []; current = parentOf(current); } - return names; + return []; } /** Keys declared by an inline object type on a destructured parameter, so they report only once. */ @@ -207,32 +143,68 @@ function inlineMemberKeys(annotation: AnyNode | null | undefined, depth = 0): Re annotation.type === 'TSTypeAnnotation' ? (annotation as { typeAnnotation: AnyNode }).typeAnnotation : annotation; - if (TYPE_WRAPPERS.has(node.type)) { - const inner = - (node as { typeAnnotation?: AnyNode }).typeAnnotation ?? - (node as { elementType?: AnyNode }).elementType; - if (inner !== undefined) for (const key of inlineMemberKeys(inner, depth + 1)) keys.add(key); - return keys; + for (const child of inlineTypeChildren(node)) { + for (const key of inlineMemberKeys(child, depth + 1)) keys.add(key); } - if (node.type === 'TSUnionType' || node.type === 'TSIntersectionType') { - for (const member of (node as { types: readonly AnyNode[] }).types) { - for (const key of inlineMemberKeys(member, depth + 1)) keys.add(key); + addInlinePropertyNames(node, keys); + return keys; +} + +function addInlinePropertyNames(node: AnyNode, keys: Set): void { + if (node.type === 'TSTypeLiteral') { + for (const member of node.members) { + if (member.type !== 'TSPropertySignature') continue; + const name = keyName(member.key, member.computed); + if (name !== null) keys.add(name); } - return keys; } - if (node.type !== 'TSTypeLiteral') return keys; - for (const member of (node as { members: readonly AnyNode[] }).members) { - if (member.type !== 'TSPropertySignature') continue; - const signature = member as unknown as { - key: AnyNode; - computed: boolean; - typeAnnotation: AnyNode | null; - }; - const name = keyName(signature.key, signature.computed); - if (name !== null) keys.add(name); - for (const nested of inlineMemberKeys(signature.typeAnnotation, depth + 1)) keys.add(nested); +} + +function inlineTypeChildren(node: AnyNode): readonly (AnyNode | null | undefined)[] { + if (TYPE_WRAPPERS.has(node.type)) { + const wrapper = node as { typeAnnotation?: AnyNode; elementType?: AnyNode }; + return [wrapper.typeAnnotation ?? wrapper.elementType]; } - return keys; + if (node.type === 'TSUnionType' || node.type === 'TSIntersectionType') return node.types; + if (node.type !== 'TSTypeLiteral') return []; + return node.members.flatMap((member) => + member.type === 'TSPropertySignature' ? [member.typeAnnotation] : [], + ); +} + +function importedContextBinding(definition: Variable['defs'][number]): string | null { + const declaration = definition.parent; + if (declaration?.type !== 'ImportDeclaration') return null; + const specifier = definition.node; + if (declaration.importKind === 'type') return null; + if (specifier.type === 'ImportSpecifier' && specifier.importKind === 'type') return null; + return contextImportName(declaration.source.value, specifier); +} + +function contextImportName(source: string, specifier: AnyNode): string | null { + if (source === 'effect/Context') + return specifier.type === 'ImportSpecifier' + ? `Context.${keyName(specifier.imported, false)}` + : 'Context'; + if (source !== 'effect' && source !== '@modern-js/plugin-bff/effect-edge') return null; + if (specifier.type === 'ImportNamespaceSpecifier') return '$root'; + return specifier.type === 'ImportSpecifier' ? keyName(specifier.imported, false) : null; +} + +function variableContextBinding( + context: Context, + node: AnyNode, + seen: Set, +): string | null { + const variable = lookupVariable(context, node); + if (!variable || seen.has(variable)) return null; + seen.add(variable); + const definition = variable.defs[0]; + if (definition?.type === 'ImportBinding') return importedContextBinding(definition); + if (definition?.type !== 'Variable' || definition.node.type !== 'VariableDeclarator') return null; + if (!definition.node.init) return null; + if (variable.references.some((reference) => reference.isWrite() && !reference.init)) return null; + return contextBinding(context, definition.node.init, seen); } function contextBinding( @@ -257,46 +229,7 @@ function contextBinding( const key = keyName(node.property, node.computed); return base === null || key === null ? null : base === '$root' ? key : `${base}.${key}`; } - if (node.type !== 'Identifier') return null; - let scope: Scope | null = context.sourceCode.getScope(node); - let variable: Variable | undefined; - while (scope) { - variable = scope.set.get(node.name); - if (variable) break; - scope = scope.upper; - } - if (!variable || seen.has(variable)) return null; - seen.add(variable); - const definition = variable.defs[0]; - if (definition?.type === 'ImportBinding') { - const declaration = definition.parent; - if (declaration?.type !== 'ImportDeclaration') return null; - const specifier = definition.node; - if ( - declaration.importKind === 'type' || - (specifier.type === 'ImportSpecifier' && specifier.importKind === 'type') - ) - return null; - const source = declaration.source.value; - if (source === 'effect/Context') - return specifier.type === 'ImportSpecifier' - ? `Context.${keyName(specifier.imported, false)}` - : 'Context'; - if (source !== 'effect' && source !== '@modern-js/plugin-bff/effect-edge') return null; - return specifier.type === 'ImportNamespaceSpecifier' - ? '$root' - : specifier.type === 'ImportSpecifier' - ? keyName(specifier.imported, false) - : null; - } - if ( - definition?.type !== 'Variable' || - definition.node.type !== 'VariableDeclarator' || - !definition.node.init || - variable.references.some((reference) => reference.isWrite() && !reference.init) - ) - return null; - return contextBinding(context, definition.node.init, seen); + return variableContextBinding(context, node, seen); } /** Ambient service payloads and consumption-only casts declare no threaded operation input. */ @@ -330,6 +263,38 @@ function isAmbientOrReadType(context: Context, from: AnyNode): boolean { return false; } +function isConciseWireProjection(owner: AnyNode, wireTypeNames: RegExp): boolean { + if (owner.type !== 'ArrowFunctionExpression' || owner.body.type !== 'ObjectExpression') + return false; + const output = owner.returnType?.typeAnnotation; + return ( + output?.type === 'TSTypeReference' && + output.typeName.type === 'Identifier' && + wireTypeNames.test(output.typeName.name) + ); +} + +/** A flat inline row projection is the same serialization boundary as its wire return type. */ +function isWireProjection(from: AnyNode, wireTypeNames: RegExp): boolean { + let owner = parentOf(from); + while ( + owner && + ![ + 'BlockStatement', + 'TSPropertySignature', + 'TSInterfaceDeclaration', + 'TSTypeAliasDeclaration', + ].includes(owner.type) + ) { + if ( + ['ArrowFunctionExpression', 'FunctionDeclaration', 'FunctionExpression'].includes(owner.type) + ) + return isConciseWireProjection(owner, wireTypeNames); + owner = parentOf(owner); + } + return false; +} + export const rule = defineRule({ meta: { type: 'problem', @@ -420,36 +385,7 @@ export const rule = defineRule({ if (isWireEdge(names)) return; if (messageId === 'threadedField') { if (isAmbientOrReadType(context, from)) return; - // A flat inline row input projected to a known durable/wire type is the same - // serialization boundary as that type, not ambient identity carried inward. - let owner = parentOf(from); - while ( - owner && - ![ - 'BlockStatement', - 'TSPropertySignature', - 'TSInterfaceDeclaration', - 'TSTypeAliasDeclaration', - ].includes(owner.type) - ) { - if ( - owner.type === 'ArrowFunctionExpression' || - owner.type === 'FunctionDeclaration' || - owner.type === 'FunctionExpression' - ) { - const output = owner.returnType?.typeAnnotation; - if ( - owner.type === 'ArrowFunctionExpression' && - owner.body.type === 'ObjectExpression' && - output?.type === 'TSTypeReference' && - output.typeName.type === 'Identifier' && - options.wireTypeNames.test(output.typeName.name) - ) - return; - break; - } - owner = parentOf(owner); - } + if (isWireProjection(from, options.wireTypeNames)) return; } context.report({ data: { key, owner: names[0] ?? '' }, messageId, node }); }; diff --git a/app/tools/oxlint/effect-native/rules/no-throw-in-configuration-parser.ts b/app/tools/oxlint/effect-native/rules/no-throw-in-configuration-parser.ts index 440cad16a..97974bf4b 100644 --- a/app/tools/oxlint/effect-native/rules/no-throw-in-configuration-parser.ts +++ b/app/tools/oxlint/effect-native/rules/no-throw-in-configuration-parser.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * effect-native/no-throw-in-configuration-parser * @@ -85,18 +86,24 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree, Variable } from '@oxlint/plugins'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; +import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; +import { + keyName as staticKeyName, + memberName, + parentOf, + isFunctionNode, + unwrapNode, +} from '../shared/ast.ts'; +import { resolveVariable as lookupNamedVariable } from '../shared/bindings.ts'; +import { stringArray, safeRegExp, stringOption, positiveInteger } from '../shared/options.ts'; type AnyNode = ESTree.Node; -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the production defaults instead of forcing the fixture - * config (which `run-on-repo.mts` reuses verbatim) to loosen options. - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; +function keyName(key: AnyNode): string | null { + return staticKeyName(key, false, { templates: false }); +} const DEFAULT_INCLUDE_PATHS: readonly string[] = ['apps/**', 'verticals/**', 'packages/**']; @@ -126,12 +133,6 @@ const DEFAULT_ENVIRONMENT_READERS: readonly string[] = [ const DEFAULT_MAX_HELPER_DEPTH = 3; -const FUNCTION_TYPES = new Set([ - 'FunctionDeclaration', - 'FunctionExpression', - 'ArrowFunctionExpression', -]); - /** Modules whose default/namespace export *is* the process object. */ const PROCESS_MODULES = new Set(['process', 'node:process']); @@ -152,12 +153,7 @@ const TRANSPARENT = new Set([ 'TSInstantiationExpression', ]); function unwrap(node: AnyNode): AnyNode { - let current = node; - while (TRANSPARENT.has(current.type)) current = (current as { expression: AnyNode }).expression; - return current; -} -function parentOf(node: AnyNode): AnyNode | null { - return (node as { parent?: AnyNode }).parent ?? null; + return unwrapNode(node, { wrappers: TRANSPARENT }); } const ARGUMENT_WRAPPERS = new Set([ 'Property', @@ -239,72 +235,26 @@ const DEFAULTS: RuleOptions = { maxHelperDepth: DEFAULT_MAX_HELPER_DEPTH, }; -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - -function safeRegExp(source: string, fallback: string): RegExp { - try { - return new RegExp(source, 'u'); - } catch { - return new RegExp(fallback, 'u'); - } -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); const includePaths = stringArray(record.includePaths, DEFAULTS.includePaths); - const depth = record.maxHelperDepth; return { allowPaths: stringArray(record.allowPaths, DEFAULTS.allowPaths), ignoreTestFiles: record.ignoreTestFiles !== false, includePaths: includePaths.length > 0 ? includePaths : DEFAULTS.includePaths, - environmentIdentifiers: - typeof record.environmentIdentifiers === 'string' - ? record.environmentIdentifiers - : DEFAULTS.environmentIdentifiers, + environmentIdentifiers: stringOption( + record.environmentIdentifiers, + DEFAULTS.environmentIdentifiers, + ), environmentReaders: stringArray(record.environmentReaders, DEFAULTS.environmentReaders), - environmentTypeNames: - typeof record.environmentTypeNames === 'string' - ? record.environmentTypeNames - : DEFAULTS.environmentTypeNames, + environmentTypeNames: stringOption(record.environmentTypeNames, DEFAULTS.environmentTypeNames), followLocalHelpers: record.followLocalHelpers !== false, - maxHelperDepth: - typeof depth === 'number' && Number.isInteger(depth) && depth >= 0 - ? depth - : DEFAULTS.maxHelperDepth, + maxHelperDepth: positiveInteger(record.maxHelperDepth, DEFAULTS.maxHelperDepth, 0), }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -function isFunctionNode(node: AnyNode | undefined): boolean { - return node !== undefined && FUNCTION_TYPES.has(node.type); -} - -/** `process.env` / `process["env"]` → `"env"`; a dynamic key → `null`. */ function staticPropertyName(node: ESTree.MemberExpression): string | null { - const property = node.property as AnyNode; - if (!node.computed) - return property.type === 'Identifier' ? (property as ESTree.IdentifierName).name : null; - if (property.type === 'TemplateLiteral' && property.expressions.length === 0) - return property.quasis[0]?.value.cooked ?? null; - if (property.type !== 'Literal') return null; - const value = (property as { value?: unknown }).value; - return typeof value === 'string' ? value : null; + return memberName(node, { templates: true }); } /** `NodeJS.ProcessEnv` → `"NodeJS.ProcessEnv"`; a computed/import type → `null`. */ @@ -317,15 +267,6 @@ function qualifiedTypeName(node: AnyNode): string | null { return left === null || right === null ? null : `${left}.${right}`; } -function keyName(key: AnyNode): string | null { - if (key.type === 'Identifier') return (key as ESTree.IdentifierName).name; - if (key.type === 'Literal') { - const value = (key as { value?: unknown }).value; - return typeof value === 'string' ? value : null; - } - return null; -} - interface ThrowRecord { readonly node: AnyNode; /** `start` offsets of every enclosing function, outermost first. */ @@ -457,15 +398,8 @@ export const rule = defineRule({ const throwRecords: ThrowRecord[] = []; - const resolveVariable = (name: string, from: AnyNode): Variable | null => { - let scope: Scope | null = context.sourceCode.getScope(from); - while (scope !== null) { - const variable = scope.set.get(name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; - }; + const resolveVariable = (name: string, from: AnyNode): Variable | null => + lookupNamedVariable(context, name, from); /** `true` when `node` is the global `name` — not a local, parameter, class or imported binding. */ const isUnshadowedGlobal = (node: AnyNode, name: string): boolean => { @@ -489,6 +423,13 @@ export const rule = defineRule({ ? (definition.node as ESTree.VariableDeclarator) : null; }; + const isValueImport = ( + specifier: ESTree.ImportDeclaration['specifiers'][number], + declaration: ESTree.ImportDeclaration, + ): boolean => + declaration?.type === 'ImportDeclaration' && + declaration.importKind !== 'type' && + !(specifier.type === 'ImportSpecifier' && specifier.importKind === 'type'); const importOf = (node: AnyNode): { source: string; member: string } | null => { if (node.type !== 'Identifier') return null; const variable = resolveVariable(node.name, node); @@ -496,17 +437,30 @@ export const rule = defineRule({ if (definition?.type !== 'ImportBinding') return null; const specifier = definition.node as ESTree.ImportDeclaration['specifiers'][number]; const declaration = parentOf(specifier as AnyNode) as ESTree.ImportDeclaration; - if ( - declaration?.type !== 'ImportDeclaration' || - declaration.importKind === 'type' || - (specifier.type === 'ImportSpecifier' && specifier.importKind === 'type') - ) - return null; + if (!isValueImport(specifier, declaration)) return null; return { source: declaration.source.value, member: specifier.type === 'ImportSpecifier' ? (keyName(specifier.imported) ?? '') : '*', }; }; + const isIdentifierEnvHost = ( + node: Extract, + depth: number, + ): boolean => { + const imported = importOf(node); + if (imported) + return ( + PROCESS_MODULES.has(imported.source) && + (imported.member === '*' || imported.member === 'default') + ); + if (ENV_HOSTS.has(node.name) && isUnshadowedGlobal(node, node.name)) return true; + const declaration = declarationOf(node); + return ( + declaration?.id.type === 'Identifier' && + declaration.init !== null && + isEnvHost(declaration.init as AnyNode, depth + 1) + ); + }; const isEnvHost = (input: AnyNode, depth = 0): boolean => { if (depth > 12) return false; const node = unwrap(input); @@ -515,21 +469,10 @@ export const rule = defineRule({ return node.source.type === 'Literal' && PROCESS_MODULES.has(String(node.source.value)); if (node.type === 'MetaProperty') return node.meta.name === 'import' && node.property.name === 'meta'; - if (node.type === 'Identifier') { - const imported = importOf(node); - if (imported) - return ( - PROCESS_MODULES.has(imported.source) && - (imported.member === '*' || imported.member === 'default') - ); - if (ENV_HOSTS.has(node.name) && isUnshadowedGlobal(node, node.name)) return true; - const declaration = declarationOf(node); - return ( - declaration?.id.type === 'Identifier' && - declaration.init !== null && - isEnvHost(declaration.init as AnyNode, depth + 1) - ); - } + if (node.type === 'Identifier') return isIdentifierEnvHost(node, depth); + return isContainerEnvHost(node); + }; + const isContainerEnvHost = (node: AnyNode): boolean => { if (node.type !== 'MemberExpression' || !ENV_HOSTS.has(staticPropertyName(node) ?? '')) return false; const container = unwrap(node.object as AnyNode); @@ -539,43 +482,40 @@ export const rule = defineRule({ isUnshadowedGlobal(container, container.name) ); }; - const isEnvBag = (input: AnyNode, depth = 0): boolean => { - if (depth > 12) return false; - const node = unwrap(input); - if (node.type === 'MemberExpression') { - if (staticPropertyName(node) === 'env' && isEnvHost(node.object as AnyNode)) return true; - // Only a declared class field/parameter property establishes this.environment identity. - if (node.object.type !== 'ThisExpression') return false; - const key = staticPropertyName(node); - let enclosing = parentOf(node); - while ( - enclosing && - enclosing.type !== 'ClassDeclaration' && - enclosing.type !== 'ClassExpression' - ) - enclosing = parentOf(enclosing); - if (!enclosing || key === null) return false; - return enclosing.body.body.some((member) => { - if (member.type === 'PropertyDefinition' && keyName(member.key) === key) - return ( - !!member.typeAnnotation && - isEnvironmentRecordType(member.typeAnnotation.typeAnnotation) - ); - if (member.type !== 'MethodDefinition' || member.kind !== 'constructor') return false; - return member.value.params.some( - (parameter) => - parameter.type === 'TSParameterProperty' && - parameterInfo(parameter).name === key && - parameterInfo(parameter).type !== null && - isEnvironmentRecordType(parameterInfo(parameter).type as AnyNode), - ); - }); - } - if (node.type !== 'Identifier') return false; - const imported = importOf(node); - if (imported) return PROCESS_MODULES.has(imported.source) && imported.member === 'env'; - const declaration = declarationOf(node); - if (declaration?.init) { + const isEnvironmentClassMember = (member: ESTree.Node, key: string): boolean => { + if (member.type === 'PropertyDefinition' && keyName(member.key) === key) + return ( + !!member.typeAnnotation && isEnvironmentRecordType(member.typeAnnotation.typeAnnotation) + ); + if (member.type !== 'MethodDefinition' || member.kind !== 'constructor') return false; + return member.value.params.some( + (parameter) => + parameter.type === 'TSParameterProperty' && + parameterInfo(parameter).name === key && + parameterInfo(parameter).type !== null && + isEnvironmentRecordType(parameterInfo(parameter).type as AnyNode), + ); + }; + const isClassEnvBag = (node: ESTree.MemberExpression): boolean => { + // Only a declared class field/parameter property establishes this.environment identity. + if (node.object.type !== 'ThisExpression') return false; + const key = staticPropertyName(node); + let enclosing = parentOf(node); + while ( + enclosing && + enclosing.type !== 'ClassDeclaration' && + enclosing.type !== 'ClassExpression' + ) + enclosing = parentOf(enclosing); + if (!enclosing || key === null) return false; + return enclosing.body.body.some((member) => isEnvironmentClassMember(member, key)); + }; + const initializedEnvBag = ( + declaration: ESTree.VariableDeclarator, + name: string, + depth: number, + ): boolean | null => { + if (declaration.init) { if (declaration.id.type === 'Identifier') { const init = unwrap(declaration.init as AnyNode); if (init.type === 'ObjectExpression') @@ -592,9 +532,30 @@ export const rule = defineRule({ property.type === 'Property' && keyName(property.key) === 'env' && property.value.type === 'Identifier' && - property.value.name === node.name, + property.value.name === name, ); } + return null; + }; + const isEnvBag = (input: AnyNode, depth = 0): boolean => { + if (depth > 12) return false; + const node = unwrap(input); + if (node.type === 'MemberExpression') { + if (staticPropertyName(node) === 'env' && isEnvHost(node.object as AnyNode)) return true; + return isClassEnvBag(node); + } + if (node.type !== 'Identifier') return false; + return isIdentifierEnvBag(node, depth); + }; + const isIdentifierEnvBag = ( + node: Extract, + depth: number, + ): boolean => { + const imported = importOf(node); + if (imported) return PROCESS_MODULES.has(imported.source) && imported.member === 'env'; + const declaration = declarationOf(node); + const initialized = declaration ? initializedEnvBag(declaration, node.name, depth) : null; + if (initialized !== null) return initialized; const variable = resolveVariable(node.name, node); const definition = variable?.defs[0]; const annotation = ( @@ -628,45 +589,46 @@ export const rule = defineRule({ const init = (definition.node as ESTree.VariableDeclarator).init; return init && isFunctionNode(unwrap(init as AnyNode)) ? unwrap(init as AnyNode) : null; }; + const isCallEnvDerived = (node: ESTree.CallExpression, depth: number): boolean => { + const callee = unwrap(node.callee as AnyNode); + const imported = importOf(callee); + if (callee.type === 'Identifier' && environmentReaders.has(imported?.member ?? callee.name)) + return true; + if (callee.type === 'MemberExpression' && isEnvDerived(callee.object as AnyNode, depth + 1)) + return true; + const target = resolveFunction(callee); + if (!target) return false; + const body = (target as ESTree.ArrowFunctionExpression).body; + // A simple returned env expression is evidence; merely calling a parser is not. + if (body.type !== 'BlockStatement') return isEnvDerived(body, depth + 1); + return body.body.some( + (statement) => + statement.type === 'ReturnStatement' && + statement.argument !== null && + isEnvDerived(statement.argument, depth + 1), + ); + }; + const isIdentifierEnvDerived = (node: AnyNode, depth: number): boolean => { + const declaration = declarationOf(node); + return ( + !!declaration?.init && + (declaration.id.type === 'ObjectPattern' + ? isEnvBag(declaration.init as AnyNode, depth + 1) + : isEnvDerived(declaration.init as AnyNode, depth + 1)) + ); + }; + const isMemberEnvDerived = (node: ESTree.MemberExpression, depth: number): boolean => + isEnvironmentBagRead(node, depth + 1) || isEnvDerived(node.object as AnyNode, depth + 1); const isEnvDerived = (input: AnyNode, depth = 0): boolean => { if (depth > 12) return false; const node = unwrap(input); - if (node.type === 'MemberExpression') - return ( - isEnvironmentBagRead(node, depth + 1) || isEnvDerived(node.object as AnyNode, depth + 1) - ); - if (node.type === 'Identifier') { - const declaration = declarationOf(node); - return ( - !!declaration?.init && - (declaration.id.type === 'ObjectPattern' - ? isEnvBag(declaration.init as AnyNode, depth + 1) - : isEnvDerived(declaration.init as AnyNode, depth + 1)) - ); - } + if (node.type === 'MemberExpression') return isMemberEnvDerived(node, depth); + if (node.type === 'Identifier') return isIdentifierEnvDerived(node, depth); if (node.type === 'LogicalExpression' || node.type === 'BinaryExpression') return isEnvDerived(node.left as AnyNode, depth + 1) || isEnvDerived(node.right, depth + 1); if (node.type === 'ConditionalExpression') return isEnvDerived(node.consequent, depth + 1) || isEnvDerived(node.alternate, depth + 1); - if (node.type === 'CallExpression') { - const callee = unwrap(node.callee as AnyNode); - const imported = importOf(callee); - if (callee.type === 'Identifier' && environmentReaders.has(imported?.member ?? callee.name)) - return true; - if (callee.type === 'MemberExpression' && isEnvDerived(callee.object as AnyNode, depth + 1)) - return true; - const target = resolveFunction(callee); - if (!target) return false; - const body = (target as ESTree.ArrowFunctionExpression).body; - // A simple returned env expression is evidence; merely calling a parser is not. - if (body.type !== 'BlockStatement') return isEnvDerived(body, depth + 1); - return body.body.some( - (statement) => - statement.type === 'ReturnStatement' && - statement.argument !== null && - isEnvDerived(statement.argument, depth + 1), - ); - } + if (node.type === 'CallExpression') return isCallEnvDerived(node, depth); return false; }; @@ -703,22 +665,25 @@ export const rule = defineRule({ /** The type *names* referenced anywhere inside a type annotation (`Readonly` → both). */ const typeNamesIn = (type: AnyNode): readonly string[] => { const names: string[] = []; + const visitReference = (node: ESTree.TSTypeReference, depth: number): void => { + const reference = node as ESTree.TSTypeReference; + const name = qualifiedTypeName(reference.typeName as AnyNode); + if (name !== null) names.push(name); + const parameters = (reference as { typeArguments?: { params?: AnyNode[] } | null }) + .typeArguments; + for (const parameter of parameters?.params ?? []) visit(parameter, depth + 1); + }; const visit = (node: AnyNode | null | undefined, depth: number): void => { - if (node === null || node === undefined || depth > 6) return; + if (!node || depth > 6) return; if (node.type === 'TSTypeReference') { - const reference = node as ESTree.TSTypeReference; - const name = qualifiedTypeName(reference.typeName as AnyNode); - if (name !== null) names.push(name); - const parameters = (reference as { typeArguments?: { params?: AnyNode[] } | null }) - .typeArguments; - for (const parameter of parameters?.params ?? []) visit(parameter, depth + 1); + visitReference(node, depth); return; } - if (node.type === 'TSTypeOperator' || node.type === 'TSParenthesizedType') { + if (['TSTypeOperator', 'TSParenthesizedType'].includes(node.type)) { visit((node as { typeAnnotation?: AnyNode }).typeAnnotation, depth + 1); return; } - if (node.type === 'TSUnionType' || node.type === 'TSIntersectionType') { + if (['TSUnionType', 'TSIntersectionType'].includes(node.type)) { for (const member of (node as { types?: AnyNode[] }).types ?? []) visit(member, depth + 1); } @@ -728,6 +693,25 @@ export const rule = defineRule({ }; /** Optional string dictionaries are a config-shape heuristic; total header/translation maps are not. */ + const resolvedEnvironmentType = ( + type: ESTree.TSTypeReference, + depth: number, + optional: boolean, + ): boolean | null => { + if (type.typeName.type === 'Identifier') { + const variable = resolveVariable(type.typeName.name, type.typeName); + if (variable && variable.defs.length > 0) { + if (variable.defs.length !== 1) return false; + const definition = variable.defs[0].node as AnyNode; + if (definition.type === 'TSTypeAliasDeclaration') + return isEnvironmentRecordType(definition.typeAnnotation, depth + 1, optional); + if (definition.type === 'TSInterfaceDeclaration') + return isEnvironmentRecordType(definition.body, depth + 1, optional); + return false; + } + } + return null; + }; const isEnvironmentRecordType = (type: AnyNode, depth = 0, optional = false): boolean => { if (depth > 12) return false; if (type.type === 'TSTypeAnnotation' || type.type === 'TSParenthesizedType') { @@ -744,28 +728,27 @@ export const rule = defineRule({ ); } if (type.type !== 'TSTypeReference') return false; + return isEnvironmentTypeReference(type, depth, optional); + }; + const isEnvironmentTypeReference = ( + type: ESTree.TSTypeReference, + depth: number, + optional: boolean, + ): boolean => { const name = qualifiedTypeName(type.typeName); const args = type.typeArguments?.params ?? []; - if (type.typeName.type === 'Identifier') { - const variable = resolveVariable(type.typeName.name, type.typeName); - if (variable && variable.defs.length > 0) { - if (variable.defs.length !== 1) return false; - const definition = variable.defs[0].node as AnyNode; - if (definition.type === 'TSTypeAliasDeclaration') - return isEnvironmentRecordType(definition.typeAnnotation, depth + 1, optional); - if (definition.type === 'TSInterfaceDeclaration') - return isEnvironmentRecordType(definition.body, depth + 1, optional); - return false; - } - } + const resolved = resolvedEnvironmentType(type, depth, optional); + if (resolved !== null) return resolved; if (name === 'NodeJS.ProcessEnv') return true; if (name === 'Readonly' || name === 'Partial') { return ( !!args[0] && isEnvironmentRecordType(args[0], depth + 1, optional || name === 'Partial') ); } + return name === 'Record' && isStringRecordArguments(args, optional); + }; + const isStringRecordArguments = (args: readonly AnyNode[], optional: boolean): boolean => { return ( - name === 'Record' && args.length === 2 && args[0]?.type === 'TSStringKeyword' && !!args[1] && @@ -790,42 +773,43 @@ export const rule = defineRule({ /** Pass 1, rule 2 + 3: does this function take an environment record? */ const takesEnvironmentParameter = (node: AnyNode): boolean => { const parameters = (node as { params?: AnyNode[] }).params ?? []; - for (const parameter of parameters) { - const { name, type } = parameterInfo(parameter); - if (type !== null && isEnvironmentRecordType(type)) return true; - if ( - type !== null && + return parameters.some(isEnvironmentParameter); + }; + const isEnvironmentParameter = (parameter: AnyNode): boolean => { + const { name, type } = parameterInfo(parameter); + if (type !== null) + return ( + isEnvironmentRecordType(type) || typeNamesIn(type).some((typeName) => environmentTypeName.test(typeName)) - ) - return true; - if (name === null || !environmentIdentifier.test(name)) continue; - if (type === null || isEnvironmentRecordType(type)) return true; - } - return false; + ); + return name !== null && environmentIdentifier.test(name); }; /** `Effect.try(...)`, `E.Effect.gen(...)`, `Schema.filter(...)`, `x.pipe(...)`. */ + const isEffectImport = (node: AnyNode): boolean => { + const imported = importOf(node); + return imported !== null && /^effect(?:\/|$)/u.test(imported.source); + }; + const isEffectBarrelMember = (object: ESTree.MemberExpression): boolean => { + const imported = importOf(unwrap(object.object as AnyNode)); + return ( + imported?.source === 'effect' && + imported.member === '*' && + EFFECT_NAMESPACES.has(staticPropertyName(object) ?? '') + ); + }; const isEffectHostCall = (call: AnyNode): boolean => { if (call.type !== 'CallExpression') return false; const callee = unwrap(call.callee as AnyNode); if (callee.type === 'Identifier') { - const imported = importOf(callee); - return imported !== null && /^effect(?:\/|$)/u.test(imported.source); + return isEffectImport(callee); } if (callee.type !== 'MemberExpression') return false; const object = unwrap(callee.object as AnyNode); if (object.type === 'Identifier') { - const imported = importOf(object); - return imported !== null && /^effect(?:\/|$)/u.test(imported.source); - } - if (object.type === 'MemberExpression') { - const imported = importOf(unwrap(object.object as AnyNode)); - return ( - imported?.source === 'effect' && - imported.member === '*' && - EFFECT_NAMESPACES.has(staticPropertyName(object) ?? '') - ); + return isEffectImport(object); } + if (object.type === 'MemberExpression') return isEffectBarrelMember(object); return staticPropertyName(callee) === 'pipe' && isEffectHostCall(object); }; @@ -834,24 +818,28 @@ export const rule = defineRule({ * array / spread positions) of an Effect combinator call — those throws are owned by * `effect-native/no-throw-in-effect-callback`. */ + const isEffectArgument = (ancestors: readonly AnyNode[], index: number): boolean => { + let child: AnyNode = ancestors[index] as AnyNode; + let cursor = index - 1; + while (cursor >= 0) { + const parent = ancestors[cursor] as AnyNode; + if (parent.type === 'CallExpression') { + const callee = (parent as ESTree.CallExpression).callee as AnyNode; + const isCallee = callee.start === child.start && callee.end === child.end; + if (!isCallee && isEffectHostCall(parent)) return true; + break; + } + if (!ARGUMENT_WRAPPERS.has(parent.type)) break; + child = parent; + cursor -= 1; + } + return false; + }; const isInsideEffectCallback = (node: AnyNode): boolean => { const ancestors = context.sourceCode.getAncestors(node); for (let index = ancestors.length - 1; index >= 0; index -= 1) { if (!isFunctionNode(ancestors[index] as AnyNode)) continue; - let child: AnyNode = ancestors[index] as AnyNode; - let cursor = index - 1; - while (cursor >= 0) { - const parent = ancestors[cursor] as AnyNode; - if (parent.type === 'CallExpression') { - const callee = (parent as ESTree.CallExpression).callee as AnyNode; - const isCallee = callee.start === child.start && callee.end === child.end; - if (!isCallee && isEffectHostCall(parent)) return true; - break; - } - if (!ARGUMENT_WRAPPERS.has(parent.type)) break; - child = parent; - cursor -= 1; - } + if (isEffectArgument(ancestors as AnyNode[], index)) return true; } return false; }; @@ -860,6 +848,86 @@ export const rule = defineRule({ if (name !== null && !functionNames.has(start)) functionNames.set(start, name); }; + type CallSite = (typeof calls)[number]; + const isPrivateHelper = (target: AnyNode): boolean => { + let parent = parentOf(target); + if (parent?.type === 'VariableDeclarator') parent = parentOf(parent); + if (parent?.type === 'VariableDeclaration') parent = parentOf(parent); + if (parent?.type === 'ExportNamedDeclaration' || parent?.type === 'ExportDefaultDeclaration') + return false; + return true; + }; + const hasNonCallReferences = (start: number, sites: readonly CallSite[]): boolean => { + const name = functionNames.get(start); + const variable = name ? resolveVariable(name, sites[0].node.callee as AnyNode) : null; + if ( + !variable || + variable.references.some((reference) => { + if (reference.init) return false; + const identifier = reference.identifier as AnyNode; + return !sites.some((entry) => { + const callee = unwrap(entry.node.callee as AnyNode); + return callee.start === identifier.start; + }); + }) + ) + return true; + return false; + }; + const canMarkHelper = (start: number, target: AnyNode): boolean => { + if (markedFunctions.has(start) || !isPrivateHelper(target)) return false; + const sites = calls.filter( + (entry) => resolveFunction(entry.node.callee as AnyNode)?.start === start, + ); + if (sites.length === 0 || sites.some((entry) => !markedFunctions.has(entry.owner))) + return false; + if (hasNonCallReferences(start, sites)) return false; + if ( + !sites.every( + (entry) => + helperMarked.has(entry.owner) || + entry.node.arguments.some((argument) => isEnvDerived(argument as AnyNode)), + ) + ) + return false; + return true; + }; + const propagateHelpers = (): void => { + if (!options.followLocalHelpers) return; + for (let depth = 0; depth < options.maxHelperDepth; depth += 1) { + const next = [...functionNodes] + .filter(([start, target]) => canMarkHelper(start, target)) + .map(([start]) => start); + for (const start of next) { + markedFunctions.add(start); + helperMarked.add(start); + } + if (next.length === 0) break; + } + }; + const reportThrow = (record: ThrowRecord): void => { + if (record.insideEffectCallback) return; + if (record.chain.length === 0) { + if (markedModule) + context.report({ node: record.node, messageId: 'throwInConfigurationParser' }); + return; + } + const parser = record.chain.find( + (start) => markedFunctions.has(start) && !helperMarked.has(start), + ); + if (parser !== undefined) { + context.report({ node: record.node, messageId: 'throwInConfigurationParser' }); + return; + } + const helper = record.chain.find((start) => helperMarked.has(start)); + if (helper === undefined) return; + context.report({ + node: record.node, + messageId: 'throwInConfigurationHelper', + data: { helper: functionNames.get(helper) ?? 'a configuration helper' }, + }); + }; + return { // Environment reads: `.env`, `environment['X']`, `env.PORT`. MemberExpression(node) { @@ -926,80 +994,8 @@ export const rule = defineRule({ }, 'Program:exit'() { - // Private helpers only, every reference a call from a marked parser, with a config-derived - // argument at the initial hop. Module startup calls and exported/domain helpers do not qualify. - if (options.followLocalHelpers) { - for (let depth = 0; depth < options.maxHelperDepth; depth += 1) { - const next: number[] = []; - for (const [start, target] of functionNodes) { - if (markedFunctions.has(start)) continue; - let parent = parentOf(target); - if (parent?.type === 'VariableDeclarator') parent = parentOf(parent); - if (parent?.type === 'VariableDeclaration') parent = parentOf(parent); - if ( - parent?.type === 'ExportNamedDeclaration' || - parent?.type === 'ExportDefaultDeclaration' - ) - continue; - const sites = calls.filter( - (entry) => resolveFunction(entry.node.callee as AnyNode)?.start === start, - ); - if (sites.length === 0 || sites.some((entry) => !markedFunctions.has(entry.owner))) - continue; - const name = functionNames.get(start); - const variable = name ? resolveVariable(name, sites[0].node.callee as AnyNode) : null; - if ( - !variable || - variable.references.some((reference) => { - if (reference.init) return false; - const identifier = reference.identifier as AnyNode; - return !sites.some((entry) => { - const callee = unwrap(entry.node.callee as AnyNode); - return callee.start === identifier.start; - }); - }) - ) - continue; - if ( - !sites.every( - (entry) => - helperMarked.has(entry.owner) || - entry.node.arguments.some((argument) => isEnvDerived(argument as AnyNode)), - ) - ) - continue; - next.push(start); - } - for (const start of next) { - markedFunctions.add(start); - helperMarked.add(start); - } - if (next.length === 0) break; - } - } - - for (const record of throwRecords) { - if (record.insideEffectCallback) continue; - if (record.chain.length === 0) { - if (markedModule) - context.report({ node: record.node, messageId: 'throwInConfigurationParser' }); - continue; - } - const parser = record.chain.find( - (start) => markedFunctions.has(start) && !helperMarked.has(start), - ); - if (parser !== undefined) { - context.report({ node: record.node, messageId: 'throwInConfigurationParser' }); - continue; - } - const helper = record.chain.find((start) => helperMarked.has(start)); - if (helper === undefined) continue; - context.report({ - node: record.node, - messageId: 'throwInConfigurationHelper', - data: { helper: functionNames.get(helper) ?? 'a configuration helper' }, - }); - } + propagateHelpers(); + throwRecords.forEach(reportThrow); }, }; }, diff --git a/app/tools/oxlint/effect-native/rules/no-throw-in-effect-callback.ts b/app/tools/oxlint/effect-native/rules/no-throw-in-effect-callback.ts index 6625c98f9..5c274759c 100644 --- a/app/tools/oxlint/effect-native/rules/no-throw-in-effect-callback.ts +++ b/app/tools/oxlint/effect-native/rules/no-throw-in-effect-callback.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * effect-native/no-throw-in-effect-callback * @@ -86,17 +87,24 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree, Variable } from '@oxlint/plugins'; import { collectEffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isScriptFile, isTestFile, normalisePath } from '../shared/paths.ts'; - -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production defaults instead of forcing the - * fixture config to pass loosened options (which `run-on-repo.mts` reuses verbatim). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; +import { isScriptFile, isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; +import { stringArray } from '../shared/options.ts'; +import { + unwrapNode as unwrap, + parentOf, + nearestFunction as enclosingFunction, + FUNCTION_TYPES, +} from '../shared/ast.ts'; +import { lookupVariable } from '../shared/bindings.ts'; +import { effectOrigin } from '../shared/effect-identity.ts'; +import { + collectRootNamespaces, + collectDirectMemberImports, + importDeclarations, +} from '../shared/imports.ts'; /** S1/A4 are application-architecture findings: `scripts/**` is excluded on purpose (see B3). */ const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**']; @@ -137,12 +145,6 @@ const DEFAULT_EFFECT_MODULES = ['@modern-js/plugin-bff/effect-edge']; const EFFECT_ROOT_MODULE = 'effect'; const EFFECT_SUBMODULE = /^effect\/(?:.*\/)?([A-Za-z0-9_$]+)$/u; -const FUNCTION_TYPES = new Set([ - 'FunctionDeclaration', - 'FunctionExpression', - 'ArrowFunctionExpression', -]); - /** Node types that can sit between a callback and the call it is an argument of. */ const ARGUMENT_WRAPPERS = new Set([ 'ConditionalExpression', @@ -170,18 +172,8 @@ interface RuleOptions { readonly effectModules: readonly string[]; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { include: stringArray(record.include, DEFAULT_INCLUDE), ignore: stringArray(record.ignore, DEFAULT_IGNORE), @@ -193,135 +185,24 @@ function readOptions(context: Context): RuleOptions { }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -/** Strip wrappers that never change what an expression denotes. */ -function unwrap(node: ESTree.Node): ESTree.Node { - let current: ESTree.Node = node; - for (;;) { - if ( - current.type === 'ChainExpression' || - current.type === 'ParenthesizedExpression' || - current.type === 'TSNonNullExpression' || - current.type === 'TSAsExpression' || - current.type === 'TSSatisfiesExpression' || - current.type === 'TSInstantiationExpression' || - current.type === 'TSTypeAssertion' - ) { - current = current.expression as ESTree.Node; - continue; - } - return current; - } -} - -/** Non-computed `.name`, or computed `["name"]`. */ -function memberName(node: ESTree.MemberExpression): string | null { - if (!node.computed) return node.property.type === 'Identifier' ? node.property.name : null; - const property = unwrap(node.property); - if (property.type === 'Literal' && typeof property.value === 'string') return property.value; - if (property.type === 'TemplateLiteral' && property.expressions.length === 0) { - return property.quasis[0]?.value.cooked ?? null; - } - return null; -} - -function lookupVariable( - context: Context, - identifier: Extract, -): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(identifier); - while (scope !== null) { - const variable = scope.set.get(identifier.name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; -} - -/** - * `true` when the identifier still resolves to an `import` binding. Unresolved names fall back to - * `true` because the module-level import declaration already proved the binding exists; only a local - * shadow (parameter, `const`, catch clause, …) rejects the match. - */ -function resolvesToImport( - context: Context, - identifier: Extract, -): boolean { - const variable = lookupVariable(context, identifier); - if (variable === null) return true; - if (variable.defs.length === 0) return true; - return variable.defs.some((definition) => definition.type === 'ImportBinding'); -} - -interface ModuleView { - /** local identifier → Effect namespace name (`Effect`, `Layer`, …). */ - readonly namespaceLocals: ReadonlyMap; - /** locals bound by `import * as X from "effect"` — `X.Effect.gen(…)`. */ - readonly rootNamespaces: ReadonlySet; - /** local identifier → owning namespace, for `import { gen } from "effect/Effect"`. */ - readonly directMembers: ReadonlyMap; - /** whether the file imports `effect` / `effect/*` / a configured barrel at all. */ - readonly importsEffect: boolean; -} - -function collectModuleView(program: ESTree.Program, options: RuleOptions): ModuleView { +function collectModuleView(program: ESTree.Program, options: RuleOptions): boolean { const shared = collectEffectBindings(program); - const namespaceLocals = new Map(shared.namespaces); - const rootNamespaces = new Set(); - const directMembers = new Map(); - let importsEffect = shared.importsEffect; - - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - const source = statement.source.value; - if (source === EFFECT_ROOT_MODULE) { - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportNamespaceSpecifier') rootNamespaces.add(specifier.local.name); - } - continue; - } - const submodule = EFFECT_SUBMODULE.exec(source)?.[1]; - if (submodule !== undefined && options.namespaces.includes(submodule)) { - // `import { gen } from "effect/Effect"` — the member is reachable without a namespace. - for (const specifier of statement.specifiers) { - if (specifier.type !== 'ImportSpecifier') continue; - directMembers.set(specifier.local.name, submodule); - } - continue; - } - if (options.effectModules.includes(source)) { - importsEffect = true; - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportSpecifier') { - const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; - namespaceLocals.set(specifier.local.name, imported); - } else if (specifier.type === 'ImportNamespaceSpecifier') { - rootNamespaces.add(specifier.local.name); - } - } - } - } - return { namespaceLocals, rootNamespaces, directMembers, importsEffect }; + const rootNamespaces = collectRootNamespaces(program); + const directMembers = collectDirectMemberImports(program, undefined, {}, (source) => { + const namespace = EFFECT_SUBMODULE.exec(source)?.[1]; + return namespace !== undefined && options.namespaces.includes(namespace) ? namespace : null; + }); + const barrels = importDeclarations( + program, + (source) => source !== EFFECT_ROOT_MODULE && options.effectModules.includes(source), + ); + return ( + shared.importsEffect || rootNamespaces.size > 0 || directMembers.size > 0 || barrels.length > 0 + ); } /** `Effect.gen` / `E.gen` / `Effect["gen"]` / `Eff.Effect.gen` / bare `gen` from `effect/Effect`. */ -function isEffectCallee( - context: Context, - callee: ESTree.Node, - view: ModuleView, - options: RuleOptions, -): boolean { +function isEffectCallee(context: Context, callee: ESTree.Node, options: RuleOptions): boolean { const target = unwrap(callee); if (target.type === 'CallExpression') { const origin = effectOrigin(context, target.callee, options.effectModules); @@ -333,22 +214,6 @@ function isEffectCallee( return !['succeed', 'fail', 'die', 'fromNullable', 'fromIterable'].includes(origin[1]!); } -function parentOf(node: ESTree.Node): ESTree.Node | null { - const parent = (node as { parent?: ESTree.Node | null }).parent; - return parent ?? null; -} - -/** Nearest enclosing function, or `null` at `Program`. */ -function enclosingFunction(node: ESTree.Node): ESTree.Node | null { - let current = parentOf(node); - while (current !== null) { - if (FUNCTION_TYPES.has(current.type)) return current; - if (current.type === 'Program') return null; - current = parentOf(current); - } - return null; -} - /** The `CallExpression` this node is (possibly wrapped) an argument of, or `null`. */ function argumentCall(node: ESTree.Node): ESTree.CallExpression | null { let current = node; @@ -368,6 +233,28 @@ function argumentCall(node: ESTree.Node): ESTree.CallExpression | null { return null; } +function isAdapterCall(context: Context, call: ESTree.CallExpression): boolean { + const origin = effectOrigin(context, call.callee, [ + 'react', + '@tanstack/react-query', + '@tanstack/react-router', + ]); + return ( + origin?.length === 1 && + ['useCallback', 'useMutation', 'useQuery', 'queryOptions', 'mutationOptions'].includes( + origin[0]!, + ) + ); +} + +function isDataCall(context: Context, call: ESTree.CallExpression, options: RuleOptions): boolean { + const origin = effectOrigin(context, call.callee, options.effectModules); + return ( + origin?.length === 2 && + ['succeed', 'fail', 'die', 'fromNullable', 'fromIterable'].includes(origin[1]!) + ); +} + /** * Transitively: is this node lexically inside a callback passed to an Effect combinator? Nested * non-Effect callbacks (`db.transaction(async (tx) => …)`) keep climbing to their outer function. @@ -375,7 +262,6 @@ function argumentCall(node: ESTree.Node): ESTree.CallExpression | null { function isInsideEffectCallback( context: Context, node: ESTree.Node, - view: ModuleView, options: RuleOptions, ): boolean { let cursor: ESTree.Node = node; @@ -384,25 +270,9 @@ function isInsideEffectCallback( if (fn === null) return false; const call = argumentCall(fn); if (call !== null) { - const adapter = effectOrigin(context, call.callee, [ - 'react', - '@tanstack/react-query', - '@tanstack/react-router', - ]); - if ( - adapter?.length === 1 && - ['useCallback', 'useMutation', 'useQuery', 'queryOptions', 'mutationOptions'].includes( - adapter[0]!, - ) - ) - return false; - if (isEffectCallee(context, call.callee, view, options)) return true; - const origin = effectOrigin(context, call.callee, options.effectModules); - if ( - origin?.length === 2 && - ['succeed', 'fail', 'die', 'fromNullable', 'fromIterable'].includes(origin[1]!) - ) - return false; + if (isAdapterCall(context, call)) return false; + if (isEffectCallee(context, call.callee, options)) return true; + if (isDataCall(context, call, options)) return false; } cursor = fn; } @@ -439,122 +309,16 @@ function sentinelName( const variable = lookupVariable(context, callee); if (variable === null || variable.defs.length === 0) return null; - for (const definition of variable.defs) { - if (definition.type === 'ClassName' || definition.type === 'FunctionName') return callee.name; - if (definition.type === 'Variable') return callee.name; - if (definition.type === 'ImportBinding') { - const source = importSourceOf(definition); - if ( - source !== null && - options.localImportPrefixes.some((prefix) => source.startsWith(prefix)) - ) { - return callee.name; - } - } - } - return null; + return variable.defs.some((definition) => isLocalDefinition(definition, options)) + ? callee.name + : null; } -// Resolve runtime identity, not spelling. Only immutable same-file aliases are followed; -// dynamic imports, mutable rebinding and arbitrary cross-module re-exports remain unknown. -function effectOrigin( - context: Context, - input: ESTree.Node, - barrels: readonly string[], - depth = 0, -): readonly string[] | null { - if (depth > 24) return null; - let node = input; - while ( - [ - 'ParenthesizedExpression', - 'ChainExpression', - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSInstantiationExpression', - 'TSTypeAssertion', - ].includes(node.type) - ) { - node = (node as { expression: ESTree.Node }).expression; - } - const keyOf = (key: ESTree.Node, computed: boolean): string | null => { - if (!computed && key.type === 'Identifier') return key.name; - if (key.type === 'Literal' && typeof key.value === 'string') return key.value; - if (key.type === 'TemplateLiteral' && key.expressions.length === 0) - return key.quasis[0]?.value.cooked ?? null; - return null; - }; - if (node.type === 'MemberExpression') { - const key = keyOf(node.property, node.computed); - const base = effectOrigin(context, node.object, barrels, depth + 1); - return base && key !== null ? [...base, key] : null; - } - if (node.type !== 'Identifier') return null; - let scope: ReturnType | null = - context.sourceCode.getScope(node); - while (scope) { - const variable = scope.set.get(node.name); - const defs = variable?.defs.filter( - (def) => - !['TSInterfaceDeclaration', 'TSTypeAliasDeclaration', 'TSTypeParameter'].includes( - def.node.type, - ), - ); - if (!variable || !defs?.length) { - scope = scope.upper; - continue; - } - if (defs.length !== 1) return null; - const def = defs[0]!; - if (def.type === 'ImportBinding') { - const spec = def.node; - const declaration = def.parent?.type === 'ImportDeclaration' ? def.parent : spec.parent; - if ( - declaration?.type !== 'ImportDeclaration' || - declaration.importKind === 'type' || - (spec as { importKind?: string }).importKind === 'type' - ) - return null; - const source = declaration.source.value; - const root = source === 'effect' || barrels.some((glob) => globToRegExp(glob).test(source)); - if (!root && !source.startsWith('effect/')) return null; - const base = root ? [] : [source.split('/').at(-1)!]; - if (spec.type === 'ImportNamespaceSpecifier' || spec.type === 'ImportDefaultSpecifier') - return base; - if (spec.type !== 'ImportSpecifier') return null; - return [ - ...base, - spec.imported.type === 'Identifier' ? spec.imported.name : spec.imported.value, - ]; - } - const declaration = def.node; - if ( - declaration.type !== 'VariableDeclarator' || - !declaration.init || - declaration.parent?.type !== 'VariableDeclaration' || - declaration.parent.kind !== 'const' - ) - return null; - if (variable.references.some((reference) => reference.isWrite() && !reference.init)) - return null; - const base = effectOrigin(context, declaration.init, barrels, depth + 1); - if (!base) return null; - if (declaration.id.type === 'Identifier') return base; - if (declaration.id.type !== 'ObjectPattern') return null; - for (const property of declaration.id.properties) { - if ( - property.type !== 'Property' || - property.value.type !== 'Identifier' || - property.value.name !== node.name - ) - continue; - const key = keyOf(property.key, property.computed); - return key === null ? null : [...base, key]; - } - return null; - } - return null; +function isLocalDefinition(definition: Variable['defs'][number], options: RuleOptions): boolean { + if (['ClassName', 'FunctionName', 'Variable'].includes(definition.type)) return true; + if (definition.type !== 'ImportBinding') return false; + const source = importSourceOf(definition); + return source !== null && options.localImportPrefixes.some((prefix) => source.startsWith(prefix)); } export const rule = defineRule({ @@ -620,9 +384,7 @@ export const rule = defineRule({ if (isScriptFile(path)) return {}; if (!options.includeTests && isTestFile(path)) return {}; - const view = collectModuleView(context.sourceCode.ast, options); - if (!view.importsEffect && view.rootNamespaces.size === 0 && view.directMembers.size === 0) - return {}; + if (!collectModuleView(context.sourceCode.ast, options)) return {}; const fileMode = options.mode === 'effect-files'; @@ -635,7 +397,7 @@ export const rule = defineRule({ if (parent.type === 'TryStatement' && parent.block === current && parent.handler) return; current = parent; } - const insideCallback = isInsideEffectCallback(context, node, view, options); + const insideCallback = isInsideEffectCallback(context, node, options); if (!insideCallback && !fileMode) return; const name = sentinelName(context, node.argument as ESTree.Node, options); diff --git a/app/tools/oxlint/effect-native/rules/no-throw-in-scripts.ts b/app/tools/oxlint/effect-native/rules/no-throw-in-scripts.ts index ca5b3ced6..774f254ad 100644 --- a/app/tools/oxlint/effect-native/rules/no-throw-in-scripts.ts +++ b/app/tools/oxlint/effect-native/rules/no-throw-in-scripts.ts @@ -77,24 +77,14 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree } from '@oxlint/plugins'; import { collectEffectBindings } from '../shared/effect-imports.ts'; import type { EffectBindings } from '../shared/effect-imports.ts'; -import { - globToRegExp, - isScriptFile, - isTestFile, - matchesAny, - normalisePath, -} from '../shared/paths.ts'; - -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets the fixtures exercise the real production defaults instead of forcing - * the fixture config to pass loosened options (which `run-on-repo.mts` reuses verbatim). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; +import { globToRegExp, scriptScope, inScriptScope } from '../shared/paths.ts'; +import { parentOf, unwrapNode as unwrap, memberName as staticMemberName } from '../shared/ast.ts'; +import { resolveVariable } from '../shared/bindings.ts'; +import { provenance } from '../shared/provenance.ts'; /** Native error globals. A `throw new X(...)` against one of these is the B3 "manual throw". */ const NATIVE_ERROR_NAMES = new Set([ @@ -109,20 +99,6 @@ const NATIVE_ERROR_NAMES = new Set([ 'DOMException', ]); -/** `Effect.try` / `Effect.tryPromise` — the only combinators `allowInsideEffectTry` covers. */ -const EFFECT_TRY_MEMBERS = new Set(['try', 'tryPromise']); - -/** Wrappers that do not change the value of an expression. */ -const TRANSPARENT_TYPES = new Set([ - 'ParenthesizedExpression', - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSInstantiationExpression', - 'TSTypeAssertion', - 'ChainExpression', -]); - type AnyNode = ESTree.Node; interface RuleOptions { @@ -154,36 +130,6 @@ function readOptions(raw: unknown): RuleOptions { }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real script paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function parentOf(node: AnyNode): AnyNode | null { - return (node as { parent?: AnyNode | null }).parent ?? null; -} - -/** Strip `(...)`, `as`, `satisfies`, `!`, `` and `a?.b` wrappers from an expression. */ -function unwrap(node: AnyNode): AnyNode { - let current = node; - while (TRANSPARENT_TYPES.has(current.type)) { - const inner = (current as { expression?: AnyNode }).expression; - if (inner === undefined || inner === null) return current; - current = inner; - } - return current; -} - -function resolveVariable(context: Context, name: string, from: AnyNode): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(from); - while (scope !== null) { - const variable = scope.set.get(name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; -} - /** `throw error;` where `error` is bound by a `catch (error)` / `catch ({ cause })` clause. */ function isCatchBinding(context: Context, node: AnyNode): boolean { if (node.type !== 'Identifier') return false; @@ -209,17 +155,8 @@ function nativeErrorName(context: Context, node: AnyNode): string | null { : null; } -function staticMemberName(node: ESTree.MemberExpression): string | null { - const property = node.property as AnyNode; - if (!node.computed) - return property.type === 'Identifier' ? (property as ESTree.IdentifierName).name : null; - if (property.type !== 'Literal') return null; - const value = (property as { value?: unknown }).value; - return typeof value === 'string' ? value : null; -} - /** `Effect.try` / `Effect.tryPromise` where `Effect` really comes from `effect` / `effect/*`. */ -function isEffectTryCallee(node: AnyNode, bindings: EffectBindings, context: Context): boolean { +function isEffectTryCallee(node: AnyNode, context: Context): boolean { return ['Effect.try', 'Effect.tryPromise'].includes(provenance(context, node) ?? ''); } @@ -227,7 +164,7 @@ function isEffectTryCallee(node: AnyNode, bindings: EffectBindings, context: Con * `true` when the throw sits lexically inside the arguments of `Effect.try(...)` / * `Effect.tryPromise(...)` — both the positional callback and the `{ try: … , catch: … }` form. */ -function isInsideEffectTry(node: AnyNode, bindings: EffectBindings, context: Context): boolean { +function isInsideEffectTry(node: AnyNode, context: Context): boolean { let child: AnyNode = node; let parent = parentOf(child); while (parent !== null) { @@ -235,7 +172,7 @@ function isInsideEffectTry(node: AnyNode, bindings: EffectBindings, context: Con const call = parent as ESTree.CallExpression; if ( (call.arguments as readonly AnyNode[]).includes(child) && - isEffectTryCallee(call.callee as AnyNode, bindings, context) + isEffectTryCallee(call.callee as AnyNode, context) ) { return true; } @@ -246,27 +183,21 @@ function isInsideEffectTry(node: AnyNode, bindings: EffectBindings, context: Con return false; } +function describeInvocation(input: AnyNode, construct: boolean): string { + const callee = unwrap(input); + const prefix = construct ? 'new ' : ''; + if (callee.type === 'Identifier') return `${prefix}${callee.name}(...)`; + if (callee.type === 'MemberExpression') { + const name = staticMemberName(callee); + if (name !== null) return `${prefix}...${name}(...)`; + } + return construct ? 'new ...(...)' : 'a call result'; +} + /** A short, human-readable rendering of the thrown expression for the diagnostic text. */ function describeThrown(node: AnyNode): string { - if (node.type === 'NewExpression') { - const callee = unwrap((node as ESTree.NewExpression).callee as AnyNode); - if (callee.type === 'Identifier') - return `new ${(callee as ESTree.IdentifierReference).name}(...)`; - if (callee.type === 'MemberExpression') { - const name = staticMemberName(callee as ESTree.MemberExpression); - if (name !== null) return `new ...${name}(...)`; - } - return 'new ...(...)'; - } - if (node.type === 'CallExpression') { - const callee = unwrap((node as ESTree.CallExpression).callee as AnyNode); - if (callee.type === 'Identifier') return `${(callee as ESTree.IdentifierReference).name}(...)`; - if (callee.type === 'MemberExpression') { - const name = staticMemberName(callee as ESTree.MemberExpression); - if (name !== null) return `...${name}(...)`; - } - return 'a call result'; - } + if (node.type === 'NewExpression') return describeInvocation(node.callee, true); + if (node.type === 'CallExpression') return describeInvocation(node.callee, false); if (node.type === 'Identifier') return (node as ESTree.IdentifierReference).name; if (node.type === 'MemberExpression') { const name = staticMemberName(node as ESTree.MemberExpression); @@ -338,7 +269,7 @@ export const rule = defineRule({ options.allowInsideEffectTry && bindings !== null && bindings.importsEffect && - isInsideEffectTry(statement, bindings, context) + isInsideEffectTry(statement, context) ) { return; } @@ -358,213 +289,3 @@ export const rule = defineRule({ }; }, }); - -/** Bounded, lexical provenance only; no type checker or interprocedural/data-flow inference. */ -type Syntax = ESTree.Node & Record; -function syntax(node: unknown): Syntax | null { - let n = node as Syntax | null; - while ( - n && - [ - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSTypeAssertion', - 'TSInstantiationExpression', - 'ParenthesizedExpression', - 'ChainExpression', - 'AwaitExpression', - ].includes(n.type) - ) - n = n.expression ?? n.argument; - return n; -} -function lexicalVariable(context: Context, node: Syntax): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(node); - while (scope) { - const v = scope.set.get(node.name); - if (v) return v; - scope = scope.upper; - } - return null; -} -function literalText(node: unknown): string | null { - const n = syntax(node); - if (n?.type === 'Literal' && typeof n.value === 'string') return n.value; - if (n?.type === 'TemplateLiteral' && n.expressions.length === 0) - return n.quasis[0]?.value.cooked ?? null; - return null; -} -function propertyText(node: unknown): string | null { - const n = node as Syntax; - const key = syntax(n.property ?? n.key); - return !n.computed && key?.type === 'Identifier' ? key.name : literalText(key); -} -function moduleIdentity(source: string): string { - if (/^(?:node:)?(?:process|console|util|module)$/.test(source)) - return source.replace(/^node:/, ''); - if (source === 'effect/Effect') return 'Effect'; - if (source === 'effect/ManagedRuntime') return 'ManagedRuntime'; - return source; -} -function bindingPath(pattern: Syntax, name: string): string[] | null { - if (pattern.type === 'Identifier') return pattern.name === name ? [] : null; - if (pattern.type === 'AssignmentPattern') return bindingPath(pattern.left, name); - if (pattern.type !== 'ObjectPattern') return null; - for (const p of pattern.properties) { - if (p.type !== 'Property') continue; - const key = propertyText(p), - tail = bindingPath(p.value, name); - if (key !== null && tail !== null) return [key, ...tail]; - } - return null; -} -function provenance(context: Context, node: unknown, seen = new Set()): string | null { - const n = syntax(node); - if (!n) return null; - if (n.type === 'Identifier') { - const v = lexicalVariable(context, n); - if (!v || v.defs.length === 0) - return [ - 'process', - 'console', - 'Bun', - 'globalThis', - 'global', - 'window', - 'self', - 'require', - 'Array', - 'Set', - ].includes(n.name) - ? n.name - : null; - if (seen.has(v) || v.defs.length !== 1) return null; - const next = new Set(seen); - next.add(v); - const def = v.defs[0] as any; - if (def.type === 'ImportBinding') { - const spec = def.node as Syntax; - const decl = (def.parent ?? spec.parent) as Syntax; - if (decl.importKind === 'type' || spec.importKind === 'type') return null; - const source = literalText(decl.source); - if (!source) return null; - const base = moduleIdentity(source); - if (spec.type === 'ImportNamespaceSpecifier' || spec.type === 'ImportDefaultSpecifier') - return base; - const name = spec.imported?.name ?? spec.imported?.value; - if (name === 'default') return base; - if (base === 'effect') return name; - return `${base}.${name}`; - } - if (def.type !== 'Variable' || def.node.type !== 'VariableDeclarator') return null; - // A declaration is not a reaching-definition analysis: reassigned aliases are unknown. - if (v.references.some((r: any) => r.init !== true && r.isWrite())) return null; - const d = def.node as Syntax; - const base = provenance(context, d.init, next), - path = bindingPath(d.id, n.name); - return base !== null && path !== null ? [base, ...path].join('.') : null; - } - if (n.type === 'MemberExpression') { - const base = provenance(context, n.object, seen), - key = propertyText(n); - if (base === null || key === null) return null; - if ( - ['globalThis', 'global', 'window', 'self'].includes(base) && - ['process', 'console', 'Bun'].includes(key) - ) - return key; - if (['process', 'console', 'util', 'module'].includes(base) && key === 'default') return base; - if (base === 'effect') return key; - return `${base}.${key}`; - } - if (n.type === 'ImportExpression') { - const text = literalText(n.source); - return text === null ? null : moduleIdentity(text); - } - if (n.type === 'CallExpression') { - const callee = provenance(context, n.callee, seen); - if (callee === 'require') { - const text = literalText(n.arguments[0]); - return text === null ? null : moduleIdentity(text); - } - if (callee === 'module.createRequire') return 'require'; - if (callee === 'ManagedRuntime.make') return 'Runtime'; - } - return null; -} -/** Only value references, never property names, bindings or TS-only identifiers. */ -function valueReference(context: Context, node: unknown): boolean { - const n = node as Syntax, - p = n.parent as Syntax | undefined; - if (!p) return false; - if (p.type.startsWith('Import') || p.type === 'ExportSpecifier') return false; - if (p.type === 'MemberExpression' && p.property === n && !p.computed) return false; - if ( - [ - 'Property', - 'PropertyDefinition', - 'MethodDefinition', - 'TSPropertySignature', - 'TSMethodSignature', - ].includes(p.type) && - p.key === n && - !p.computed && - !(p.shorthand && p.value === n) - ) - return false; - if (['LabeledStatement', 'BreakStatement', 'ContinueStatement'].includes(p.type)) return false; - let child: Syntax = n; - let parent: Syntax | null = p; - while (parent) { - if ( - parent.type.startsWith('TS') && - !( - [ - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSTypeAssertion', - 'TSInstantiationExpression', - ].includes(parent.type) && parent.expression === child - ) - ) - return false; - if ( - parent.type.endsWith('Statement') || - parent.type.endsWith('Declaration') || - parent.type.includes('Function') - ) - break; - child = parent; - parent = parent.parent as Syntax | null; - } - const v = lexicalVariable(context, n); - return ( - !v || - v.references.some( - (r: any) => - r.identifier === n && - r.isRead() && - (typeof r.isValueReference !== 'function' || r.isValueReference()), - ) - ); -} -/** Strip fixture scaffolding first; do not renormalise a relative script path around inner markers. */ -function scriptScope(filename: string): string { - const unified = filename.replaceAll('\\', '/'); - const fixture = unified.match( - /(?:^|\/)tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\/(.*)$/u, - ); - if (fixture) return fixture[1]; - if (!unified.startsWith('/') && !/^[A-Za-z]:\//u.test(unified)) - return unified.replace(/^\.\//, ''); - const match = unified.match(/(?:^|\/)((?:apps|packages|verticals|scripts|tools)\/.*)$/u); - return match?.[1] ?? unified; -} -function inScriptScope(path: string): boolean { - return ( - /(?:^|\/)scripts\//u.test(path) && - !/(?:^|\/)(?:tests?|__tests__)\/|\.(?:test|spec|test-d|spec-d)\.[cm]?[jt]sx?$/u.test(path) - ); -} diff --git a/app/tools/oxlint/effect-native/rules/no-unbranded-identifier-schema.ts b/app/tools/oxlint/effect-native/rules/no-unbranded-identifier-schema.ts index 7dc842450..69196651f 100644 --- a/app/tools/oxlint/effect-native/rules/no-unbranded-identifier-schema.ts +++ b/app/tools/oxlint/effect-native/rules/no-unbranded-identifier-schema.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * Audit finding: **A2** — "Make Schema the sole authority for contracts and domain models" * (`docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`). A2 measures **zero branded identifiers** and @@ -67,18 +68,23 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree } from '@oxlint/plugins'; import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; +import { matchesGlobs } from '../shared/paths.ts'; +import { + isSchemaRuleInScope, + isSchemaConstructorArgument as isConstructorArgument, +} from '../shared/schema-rule-support.ts'; +import { stringArray, stringOption, safeRegExp } from '../shared/options.ts'; +import { memberName, keyName, skipWrappers, unwrapNode } from '../shared/ast.ts'; +import { resolveVariable } from '../shared/bindings.ts'; +import { collectNamedImports, collectRootNamespaces } from '../shared/imports.ts'; const SCHEMA_NAMESPACE = 'Schema'; const EFFECT_ROOT_MODULE = 'effect'; const EFFECT_SCHEMA_MODULE = /^effect\/(?:.*\/)?Schema$/u; -/** Fixture files mirror repo paths under `tests/fixtures//{valid,invalid}/`; strip that prefix. */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; - const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; const DEFAULT_IGNORE: string[] = []; const DEFAULT_KEY_PATTERN = '^(?:.*(?:Ids?|Keys?)|ico|dic)$'; @@ -177,28 +183,15 @@ interface RuleOptions { readonly ignoreTests: boolean; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - -function stringOption(value: unknown, fallback: string): string { - return typeof value === 'string' && value.length > 0 ? value : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { brandHelpers: stringArray(record.brandHelpers, DEFAULT_BRAND_HELPERS), - identifierKeyPattern: stringOption(record.identifierKeyPattern, DEFAULT_KEY_PATTERN), + identifierKeyPattern: stringOption(record.identifierKeyPattern, DEFAULT_KEY_PATTERN, false), identifierSchemaNamePattern: stringOption( record.identifierSchemaNamePattern, DEFAULT_SCHEMA_NAME_PATTERN, + false, ), ignore: stringArray(record.ignore, DEFAULT_IGNORE), ignoreTests: record.ignoreTests === true, @@ -207,45 +200,8 @@ function readOptions(context: Context): RuleOptions { }; } -function safeRegExp(source: string, fallback: string): RegExp { - try { - return new RegExp(source, 'u'); - } catch { - return new RegExp(fallback, 'u'); - } -} - -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -function importedName(specifier: ESTree.ImportSpecifier): string { - return specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; -} - -/** Non-computed `.Struct`, or computed `["Struct"]`. */ -function memberName(node: ESTree.MemberExpression): string | null { - if (!node.computed) return node.property.type === 'Identifier' ? node.property.name : null; - const property = node.property; - if (property.type === 'Literal' && typeof property.value === 'string') return property.value; - return null; -} - function unwrap(node: ESTree.Node): ESTree.Node { - let current = node; - for (let guard = 0; guard < 16; guard += 1) { - if (!UNWRAPPABLE.has(current.type)) return current; - const inner = (current as { expression?: ESTree.Node }).expression; - if (inner === undefined) return current; - current = inner; - } - return current; + return unwrapNode(node, { wrappers: UNWRAPPABLE, maxDepth: 16 }); } /** A local that stands for one `Schema.` rather than for the `Schema` namespace. */ @@ -278,58 +234,32 @@ function collectSchemaLocals( bindings: EffectBindings, reexportModules: readonly string[], ): SchemaLocals { + const isReexport = (source: string): boolean => matchesGlobs(source, reexportModules); const schema = new Set(); - const barrel = new Set(); - const brandDirect = new Set(); const pipe = new Set(); - const members = new Map(); for (const [local, namespace] of bindings.namespaces) { if (namespace === SCHEMA_NAMESPACE) schema.add(local); if (namespace === 'pipe') pipe.add(local); } - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - const source = statement.source.value; - const isReexport = matchesGlobs(source, reexportModules); - if (source === EFFECT_ROOT_MODULE || isReexport) { - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportNamespaceSpecifier') barrel.add(specifier.local.name); - } - } - if (isReexport) { - for (const specifier of statement.specifiers) { - if (specifier.type !== 'ImportSpecifier') continue; - const imported = importedName(specifier); - if (imported === SCHEMA_NAMESPACE) schema.add(specifier.local.name); - if (imported === 'pipe') pipe.add(specifier.local.name); - // A barrel also re-exports plain values; only names that really are Schema members count. - if (KNOWN_SCHEMA_MEMBERS.has(imported)) { - members.set(specifier.local.name, { declarator: null, member: imported }); - } - } - } - // `import { Struct, String as SchemaString } from "effect/Schema"` — the members themselves. - // The root `effect` barrel is deliberately excluded: its `Struct` is `effect/Struct`, not a Schema. - if (EFFECT_SCHEMA_MODULE.test(source)) { - for (const specifier of statement.specifiers) { - if (specifier.type !== 'ImportSpecifier') continue; - const imported = importedName(specifier); - if (BRAND_MEMBERS.has(imported)) brandDirect.add(specifier.local.name); - members.set(specifier.local.name, { declarator: null, member: imported }); - } - } + const barrel = collectRootNamespaces( + program, + (source) => source === EFFECT_ROOT_MODULE || isReexport(source), + ); + const reexports = collectNamedImports(program, isReexport); + for (const [local, member] of reexports) { + if (member === SCHEMA_NAMESPACE) schema.add(local); + if (member === 'pipe') pipe.add(local); } - return { barrel, brandDirect, members, pipe, schema }; -} - -function lookupVariable(context: Context, identifier: ESTree.Node, name: string): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(identifier); - while (scope !== null) { - const variable = scope.set.get(name); - if (variable !== undefined) return variable; - scope = scope.upper; + const direct = collectNamedImports(program, (source) => EFFECT_SCHEMA_MODULE.test(source)); + const members = new Map(); + for (const [local, member] of reexports) { + if (KNOWN_SCHEMA_MEMBERS.has(member)) members.set(local, { declarator: null, member }); } - return null; + for (const [local, member] of direct) members.set(local, { declarator: null, member }); + const brandDirect = new Set( + [...direct].filter(([, member]) => BRAND_MEMBERS.has(member)).map(([local]) => local), + ); + return { barrel, brandDirect, members, pipe, schema }; } /** Capitalised brand suggestion: `tenantId` → `TenantId`, `ContactsIcoSchema` → `ContactsIco`. */ @@ -387,10 +317,7 @@ export const rule = defineRule({ }, create(context) { const options = readOptions(context); - const path = scopePath(context.filename); - if (matchesGlobs(path, options.ignore)) return {}; - if (!matchesGlobs(path, options.include)) return {}; - if (options.ignoreTests && isTestFile(path)) return {}; + if (!isSchemaRuleInScope(context.filename, options)) return {}; const keyPattern = safeRegExp(options.identifierKeyPattern, DEFAULT_KEY_PATTERN); const schemaNamePattern = safeRegExp( @@ -416,14 +343,14 @@ export const rule = defineRule({ /** `true` when the namespace identifier still resolves to its `effect` import (no local shadow). */ const resolvesToImport = (node: ESTree.Node, name: string): boolean => { - const variable = lookupVariable(context, node, name); + const variable = resolveVariable(context, name, node); if (variable === null || variable.defs.length === 0) return true; return variable.defs.some((definition) => definition.type === 'ImportBinding'); }; /** The in-file `const` declarator an identifier resolves to, or `null` for imports/params/globals. */ const localDeclarator = (node: ESTree.Node, name: string): ESTree.VariableDeclarator | null => { - const variable = lookupVariable(context, node, name); + const variable = resolveVariable(context, name, node); if (variable === null || variable.defs.length !== 1) return null; const definition = variable.defs[0]; if (definition === undefined || definition.type !== 'Variable') return null; @@ -449,92 +376,83 @@ export const rule = defineRule({ return declarator !== null && declarator.start === binding.declarator ? binding.member : null; }; - /** `Schema.Struct` / `S.Struct` / `Effect.Schema.Struct` / `Schema["Struct"]` / bare `Struct` → `"Struct"`. */ + const isImportedLocal = (node: ESTree.Node, names: ReadonlySet): boolean => + node.type === 'Identifier' && names.has(node.name) && resolvesToImport(node, node.name); + + const isSchemaSource = (node: ESTree.Node): boolean => { + const source = unwrap(node); + if (source.type === 'Identifier') return isImportedLocal(source, locals.schema); + if (source.type !== 'MemberExpression' || memberName(source) !== SCHEMA_NAMESPACE) + return false; + return isImportedLocal(unwrap(source.object), locals.barrel); + }; + const schemaMember = (node: ESTree.Node): string | null => { if (node.type === 'Identifier') return memberOfIdentifier(node, node.name); - if (node.type !== 'MemberExpression') return null; - const member = memberName(node); - if (member === null) return null; - const object = unwrap(node.object); - if (object.type === 'Identifier') { - if (!locals.schema.has(object.name)) return null; - return resolvesToImport(object, object.name) ? member : null; - } - if (object.type !== 'MemberExpression') return null; - if (memberName(object) !== SCHEMA_NAMESPACE) return null; - const root = unwrap(object.object); - if (root.type !== 'Identifier') return null; - if (!locals.barrel.has(root.name)) return null; - return resolvesToImport(root, root.name) ? member : null; + if (node.type !== 'MemberExpression' || !isSchemaSource(node.object)) return null; + return memberName(node); + }; + + const isBrandIdentifier = (node: Extract): boolean => { + if (isImportedLocal(node, locals.brandDirect)) return true; + if (options.brandHelpers.includes(node.name)) return true; + const member = memberOfIdentifier(node, node.name); + return member !== null && BRAND_MEMBERS.has(member); }; - /** `Schema.brand('X')`, bare `Schema.brand`, `import { brand }`, or a configured helper. */ const isBrandExpression = (node: ESTree.Node): boolean => { const expression = unwrap(node); - if (expression.type === 'CallExpression') { - const callee = unwrap(expression.callee); - if (callee.type === 'Identifier' && options.brandHelpers.includes(callee.name)) return true; - return isBrandExpression(callee); - } - if (expression.type === 'Identifier') { - if ( - locals.brandDirect.has(expression.name) && - resolvesToImport(expression, expression.name) - ) - return true; - if (options.brandHelpers.includes(expression.name)) return true; - const member = memberOfIdentifier(expression, expression.name); - return member !== null && BRAND_MEMBERS.has(member); - } - if (expression.type === 'MemberExpression') { - const member = memberName(expression); - if (member === null) return false; - if (BRAND_MEMBERS.has(member) && schemaMember(expression) !== null) return true; - return options.brandHelpers.includes(member); - } - return false; + if (expression.type === 'CallExpression') return isBrandExpression(expression.callee); + if (expression.type === 'Identifier') return isBrandIdentifier(expression); + if (expression.type !== 'MemberExpression') return false; + const member = memberName(expression); + if (member === null) return false; + return ( + (BRAND_MEMBERS.has(member) && schemaMember(expression) !== null) || + options.brandHelpers.includes(member) + ); + }; + + const recordDestructuredProperty = ( + declarator: ESTree.VariableDeclarator, + property: ESTree.ObjectPattern['properties'][number], + ): void => { + if (property.type !== 'Property' || property.computed || property.value.type !== 'Identifier') + return; + const member = keyName(property.key); + if (member === null) return; + const members = destructuredMembers.get(declarator.start) ?? new Map(); + members.set(property.value.name, member); + destructuredMembers.set(declarator.start, members); }; - /** - * `const { Struct, String: Str } = Schema` / `const { Struct } = Effect.Schema` — namespace - * destructuring binds the same members a named import would, so record them the same way. - */ const collectDestructuredMembers = (): void => { for (const declarator of declarators) { - if (declarator.id.type !== 'ObjectPattern') continue; - const init = - declarator.init === null || declarator.init === undefined - ? null - : unwrap(declarator.init); - if (init === null) continue; - let isSchemaSource = false; - if (init.type === 'Identifier') { - isSchemaSource = locals.schema.has(init.name) && resolvesToImport(init, init.name); - } else if (init.type === 'MemberExpression' && memberName(init) === SCHEMA_NAMESPACE) { - const root = unwrap(init.object); - isSchemaSource = - root.type === 'Identifier' && - locals.barrel.has(root.name) && - resolvesToImport(root, root.name); - } - if (!isSchemaSource) continue; - for (const property of declarator.id.properties) { - if (property.type !== 'Property' || property.computed) continue; - const key = property.key; - const member = - key.type === 'Identifier' - ? key.name - : key.type === 'Literal' && typeof key.value === 'string' - ? key.value - : null; - if (member === null || property.value.type !== 'Identifier') continue; - const members = destructuredMembers.get(declarator.start) ?? new Map(); - members.set(property.value.name, member); - destructuredMembers.set(declarator.start, members); - } + if (declarator.id.type !== 'ObjectPattern' || declarator.init == null) continue; + if (!isSchemaSource(declarator.init)) continue; + for (const property of declarator.id.properties) + recordDestructuredProperty(declarator, property); } }; + const identityResult = (body: ESTree.Node): ESTree.Node | null => { + if (body.type !== 'BlockStatement') return body; + if (body.body.length !== 1 || body.body[0]?.type !== 'ReturnStatement') return null; + return body.body[0].argument; + }; + + const isIdentityFunction = (step: ESTree.Node): boolean => { + if (step.type !== 'ArrowFunctionExpression' && step.type !== 'FunctionExpression') + return false; + if (step.body === null || step.params.length !== 1 || step.params[0]?.type !== 'Identifier') + return false; + const returned = identityResult(step.body); + return returned?.type === 'Identifier' && returned.name === step.params[0].name; + }; + + const isTransparentMember = (member: string): boolean => + TRANSPARENT_WRAPPERS.has(member) || (TRANSPARENT_METHODS.has(member) && member !== 'pipe'); + /** A pipe step must visibly preserve the string schema; arbitrary transforms may decode it * to a branded or non-string target. Do not infer their output from the encoded input. */ const isTransparentStep = (node: ESTree.Node, depth = 0): boolean => { @@ -542,9 +460,7 @@ export const rule = defineRule({ const step = unwrap(node); const member = schemaMember(step.type === 'CallExpression' ? unwrap(step.callee) : step); if (member !== null) { - return ( - TRANSPARENT_WRAPPERS.has(member) || (TRANSPARENT_METHODS.has(member) && member !== 'pipe') - ); + return isTransparentMember(member); } if (step.type === 'Identifier') { const declaration = localDeclarator(step, step.name); @@ -555,18 +471,7 @@ export const rule = defineRule({ return false; return declaration.init !== null && isTransparentStep(declaration.init, depth + 1); } - // The one helper whose result is syntactically known: `(schema) => schema`. - if (step.type !== 'ArrowFunctionExpression' && step.type !== 'FunctionExpression') - return false; - if (step.body === null) return false; - if (step.params.length !== 1 || step.params[0]?.type !== 'Identifier') return false; - const returned = - step.body.type === 'BlockStatement' - ? step.body.body.length === 1 && step.body.body[0]?.type === 'ReturnStatement' - ? step.body.body[0].argument - : null - : step.body; - return returned?.type === 'Identifier' && returned.name === step.params[0].name; + return isIdentityFunction(step); }; /** @@ -597,67 +502,72 @@ export const rule = defineRule({ } if (expression.type !== 'CallExpression') return false; - const callee = unwrap(expression.callee); + return isStringRootedCall(expression, seen, depth); + }; - // `pipe(Schema.String, Schema.brand('X'))` — checked before member resolution so that a - // `pipe` binding can never be mistaken for a Schema combinator of the same name. + const firstArgumentRooted = ( + expression: ESTree.CallExpression, + seen: Set, + depth: number, + ): boolean => { + const first = expression.arguments[0]; + return ( + first !== undefined && + first.type !== 'SpreadElement' && + isStringRooted(first, seen, depth + 1) + ); + }; + + const isStringRootedMethod = ( + expression: ESTree.CallExpression, + callee: ESTree.MemberExpression, + seen: Set, + depth: number, + ): boolean => { + const method = memberName(callee); + if (method === null || BRAND_MEMBERS.has(method) || !TRANSPARENT_METHODS.has(method)) + return false; + if (expression.arguments.some((argument) => isBrandExpression(argument))) return false; if ( - callee.type === 'Identifier' && - locals.pipe.has(callee.name) && - resolvesToImport(callee, callee.name) - ) { + method === 'pipe' && + !expression.arguments.every((argument) => isTransparentStep(argument)) + ) + return false; + return isStringRooted(callee.object, seen, depth + 1); + }; + + const isStringRootedCall = ( + expression: ESTree.CallExpression, + seen: Set, + depth: number, + ): boolean => { + const callee = unwrap(expression.callee); + if (isImportedLocal(callee, locals.pipe)) { if (expression.arguments.some((argument) => isBrandExpression(argument))) return false; if (!expression.arguments.slice(1).every((argument) => isTransparentStep(argument))) return false; - const first = expression.arguments[0]; - if (first === undefined || first.type === 'SpreadElement') return false; - return isStringRooted(first, seen, depth + 1); + return firstArgumentRooted(expression, seen, depth); } - - // `Schema.NullOr(inner)`, `Schema.optionalKey(inner)`, `Schema.Array(inner)`, bare `NullOr(inner)`, ... const wrapper = schemaMember(callee); if (wrapper !== null) { - if (BRAND_MEMBERS.has(wrapper)) return false; - if (!TRANSPARENT_WRAPPERS.has(wrapper)) return false; - const first = expression.arguments[0]; - if (first === undefined || first.type === 'SpreadElement') return false; - return isStringRooted(first, seen, depth + 1); - } - - // `inner.check(...)` / `inner.annotate(...)` / `inner.pipe(Schema.brand('X'))` / `inner.brand('X')`. - if (callee.type === 'MemberExpression') { - const method = memberName(callee); - if (method === null) return false; - if (BRAND_MEMBERS.has(method)) return false; - if (!TRANSPARENT_METHODS.has(method)) return false; - if (expression.arguments.some((argument) => isBrandExpression(argument))) return false; - if ( - method === 'pipe' && - !expression.arguments.every((argument) => isTransparentStep(argument)) - ) - return false; - return isStringRooted(callee.object, seen, depth + 1); + return ( + !BRAND_MEMBERS.has(wrapper) && + TRANSPARENT_WRAPPERS.has(wrapper) && + firstArgumentRooted(expression, seen, depth) + ); } - - return false; + return ( + callee.type === 'MemberExpression' && isStringRootedMethod(expression, callee, seen, depth) + ); }; /** Is `node` an argument of a `Schema.Struct` / `Schema.TaggedError()('T', ...)` style call? */ - const isSchemaConstructorArgument = (node: ESTree.Node): boolean => { - const parent = node.parent; - if (parent === null || parent === undefined) return false; - if (parent.type !== 'CallExpression') return false; - if (!parent.arguments.some((argument) => argument === node)) return false; - // Walk the callee chain: `Schema.TaggedError()` is a CallExpression callee. - let callee: ESTree.Node = unwrap(parent.callee); - for (let guard = 0; guard < 8; guard += 1) { - const member = schemaMember(callee); - if (member !== null) return FIELD_BAG_CONSTRUCTORS.has(member); - if (callee.type !== 'CallExpression') return false; - callee = unwrap(callee.callee); - } - return false; - }; + const isSchemaConstructorArgument = (node: ESTree.Node): boolean => + isConstructorArgument(node, { + constructors: FIELD_BAG_CONSTRUCTORS, + resolveMember: schemaMember, + unwrap, + }); /** * A `const someFields = { ... }` object that this file later hands to a Schema constructor — @@ -665,15 +575,7 @@ export const rule = defineRule({ * (`Schema.TaggedError()('T', errorFields)`). */ const isSpreadFieldBag = (node: ESTree.Node): boolean => { - let current: ESTree.Node = node; - // `{ ... } as const` keeps the declarator one level up. - while ( - current.parent !== null && - current.parent !== undefined && - UNWRAPPABLE.has(current.parent.type) - ) { - current = current.parent; - } + const { node: current } = skipWrappers(node, UNWRAPPABLE); const parent = current.parent; if (parent === null || parent === undefined) return false; if (parent.type !== 'VariableDeclarator' || parent.init !== current) return false; @@ -684,22 +586,73 @@ export const rule = defineRule({ }; const isFieldBag = (node: ESTree.ObjectExpression): boolean => { - let current: ESTree.Node = node; - while ( - current.parent !== null && - current.parent !== undefined && - UNWRAPPABLE.has(current.parent.type) - ) { - current = current.parent; - } + const { node: current } = skipWrappers(node, UNWRAPPABLE); return isSchemaConstructorArgument(current) || isSpreadFieldBag(node); }; const propertyKey = (property: ESTree.ObjectProperty): string | null => { - const key = property.key; - if (!property.computed && key.type === 'Identifier') return key.name; - if (key.type === 'Literal' && typeof key.value === 'string') return key.value; - return null; + return keyName(property.key, property.computed); + }; + + const isModuleDeclarator = (declarator: ESTree.VariableDeclarator): boolean => { + const declaration = declarator.parent; + if (declaration?.type !== 'VariableDeclaration') return false; + const owner = declaration.parent; + return owner?.type === 'Program' || owner?.type === 'ExportNamedDeclaration'; + }; + + const reportedDeclarators = new Set(); + const reports: Array<{ + readonly node: ESTree.Node; + readonly messageId: 'unbrandedField' | 'unbrandedSchema'; + readonly data: Record; + readonly start: number; + }> = []; + + const reportDeclarator = (declarator: ESTree.VariableDeclarator): void => { + if (declarator.id.type !== 'Identifier') return; + if (!isModuleDeclarator(declarator)) return; + const name = declarator.id.name; + if (!schemaNamePattern.test(name)) return; + if (!isStringRooted(declarator.init, new Set(), 0)) return; + reportedDeclarators.add(declarator.start); + reports.push({ + data: { brand: brandName(name), name }, + messageId: 'unbrandedSchema', + node: declarator.id, + start: declarator.start, + }); + }; + + const reportProperty = (property: ESTree.ObjectExpression['properties'][number]): void => { + if (property.type !== 'Property') return; + if (property.kind !== 'init' || property.method) return; + const key = propertyKey(property); + if (key === null || !keyPattern.test(key)) return; + const value = unwrap(property.value); + // A value that resolves to an in-file declarator this rule already reports is the + // same defect: fixing the shared schema fixes the field. Report the source only. + if (value.type === 'Identifier') { + const declarator = localDeclarator(value, value.name); + if (declarator !== null && reportedDeclarators.has(declarator.start)) return; + } + if (!isStringRooted(property.value, new Set(), 0)) return; + reports.push({ + data: { brand: brandName(key), key }, + messageId: 'unbrandedField', + node: property, + start: property.start, + }); + }; + + const collectBagIdentifiers = (): void => { + for (const call of calls) { + for (const argument of call.arguments) { + const value = unwrap(argument); + if (value.type === 'Identifier' && isSchemaConstructorArgument(argument)) + bagIdentifierNames.add(value.name); + } + } }; return { @@ -724,71 +677,11 @@ export const rule = defineRule({ if (locals.schema.size === 0 && locals.barrel.size === 0 && locals.members.size === 0) return; collectDestructuredMembers(); - - // A field bag handed to a constructor by name: `Schema.TaggedError()('T', errorFields)`. - for (const call of calls) { - for (const argument of call.arguments) { - const value = unwrap(argument); - if (value.type !== 'Identifier') continue; - if (isSchemaConstructorArgument(argument)) bagIdentifierNames.add(value.name); - } - } - - // Pass 1: shared identifier schemas (`const TenantIdSchema = Schema.String...`). - const reportedDeclarators = new Set(); - const reports: Array<{ - readonly node: ESTree.Node; - readonly messageId: 'unbrandedField' | 'unbrandedSchema'; - readonly data: Record; - readonly start: number; - }> = []; - - for (const declarator of declarators) { - if (declarator.id.type !== 'Identifier') continue; - const declaration = declarator.parent; - if (declaration === null || declaration === undefined) continue; - if (declaration.type !== 'VariableDeclaration') continue; - const owner = declaration.parent; - const moduleLevel = - owner !== null && - owner !== undefined && - (owner.type === 'Program' || owner.type === 'ExportNamedDeclaration'); - if (!moduleLevel) continue; - const name = declarator.id.name; - if (!schemaNamePattern.test(name)) continue; - if (!isStringRooted(declarator.init, new Set(), 0)) continue; - reportedDeclarators.add(declarator.start); - reports.push({ - data: { brand: brandName(name), name }, - messageId: 'unbrandedSchema', - node: declarator.id, - start: declarator.start, - }); - } - - // Pass 2: identifier fields inside Schema field bags. + collectBagIdentifiers(); + for (const declarator of declarators) reportDeclarator(declarator); for (const object of objects) { if (!isFieldBag(object)) continue; - for (const property of object.properties) { - if (property.type !== 'Property') continue; - if (property.kind !== 'init' || property.method) continue; - const key = propertyKey(property); - if (key === null || !keyPattern.test(key)) continue; - const value = unwrap(property.value); - // A value that resolves to an in-file declarator this rule already reports is the - // same defect: fixing the shared schema fixes the field. Report the source only. - if (value.type === 'Identifier') { - const declarator = localDeclarator(value, value.name); - if (declarator !== null && reportedDeclarators.has(declarator.start)) continue; - } - if (!isStringRooted(property.value, new Set(), 0)) continue; - reports.push({ - data: { brand: brandName(key), key }, - messageId: 'unbrandedField', - node: property, - start: property.start, - }); - } + for (const property of object.properties) reportProperty(property); } reports.sort((left, right) => left.start - right.start); diff --git a/app/tools/oxlint/effect-native/rules/no-unjustified-file-wide-lint-suppression.ts b/app/tools/oxlint/effect-native/rules/no-unjustified-file-wide-lint-suppression.ts index 5decce424..da230f84f 100644 --- a/app/tools/oxlint/effect-native/rules/no-unjustified-file-wide-lint-suppression.ts +++ b/app/tools/oxlint/effect-native/rules/no-unjustified-file-wide-lint-suppression.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * Audit finding: **A8** — "Fix the generators before generating more code" * (`docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`). A8's Effect v4 target ends with an explicit @@ -71,14 +72,8 @@ import { defineRule } from '@oxlint/plugins'; import type { Comment, Context } from '@oxlint/plugins'; -import { globToRegExp, normalisePath } from '../shared/paths.ts'; - -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production defaults instead of forcing the - * fixture config to pass loosened options (which `run-on-repo.mts` reuses against the real repo). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; +import { scopePath, matchesGlobs } from '../shared/paths.ts'; +import { stringArray, booleanOption as boolean } from '../shared/options.ts'; /** A8 names `scripts/` and `tools/oxlint` explicitly; the seam suppressions live across all roots. */ const DEFAULT_PATHS: readonly string[] = [ @@ -142,22 +137,8 @@ interface RuleOptions { readonly paths: readonly string[]; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - -function boolean(value: unknown, fallback: boolean): boolean { - return typeof value === 'boolean' ? value : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); const minimum = record.minJustificationLength; const pattern = record.expiryPattern; return { @@ -175,15 +156,6 @@ function readOptions(context: Context): RuleOptions { }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - function compileExpiry(pattern: string): RegExp | null { try { return new RegExp(pattern, 'iu'); @@ -268,6 +240,23 @@ function justificationReasons( return []; } +/** Returns null when a later enable fully bounds this disable region. */ +function unboundedRules( + directive: Directive, + laterComments: readonly Comment[], +): readonly string[] | null { + const remaining = new Set(directive.rules.map(normaliseRuleName)); + for (const later of laterComments) { + const enable = ENABLE.exec(later.value.trim()); + if (enable === null) continue; + const rules = parseRuleList(splitDescription(enable.groups?.rest ?? '').head); + if (rules.length === 0) return null; + for (const name of rules) remaining.delete(normaliseRuleName(name)); + if (directive.rules.length > 0 && remaining.size === 0) return null; + } + return [...remaining]; +} + export const rule = defineRule({ meta: { type: 'problem', @@ -343,48 +332,34 @@ export const rule = defineRule({ const seamRules = new Set(options.effectSeamRules.map(normaliseRuleName)); const expiry = compileExpiry(options.expiryPattern); - const inspect = (comment: Comment, unboundedRules?: readonly string[]): void => { - if (comment.type === 'Shebang') return; - - if (options.includeTsNocheck && TS_NOCHECK.test(comment.value.trim())) { - context.report({ node: comment, messageId: 'tsNocheck' }); - return; - } - - const trimmed = comment.value.trim(); - const effectDiagnostics = EFFECT_DIAGNOSTICS.exec(trimmed); - if (effectDiagnostics !== null) { - if (!options.includeEffectDiagnosticsDirectives) return; - const body = effectDiagnostics.groups?.rest ?? ''; - const parsed = splitDescription(body); - const silenced = parseRuleList(parsed.head).filter((token) => - SILENCED_SEVERITY.has((token.split(':')[1] ?? '').toLowerCase()), - ); - if (silenced.length === 0) return; - const reasons = [...justificationReasons(parsed.description, options, expiry)]; - if (silenced.some((token) => token.split(':')[0] === '*')) - reasons.push('a wildcard suppresses every diagnostic rather than naming exact rules'); - // A file-wide Effect diagnostic waiver is never line-scoped and never narrow, so an - // ungoverned one is always reported; a justified, expiring one is accepted. - if (reasons.length === 0) return; - context.report({ - node: comment, - messageId: 'ungovernedEffectDiagnostics', - data: { - reason: reasons.join('; '), - rules: quoteList(silenced.map((token) => token.split(':')[0] ?? token)), - }, - }); - return; - } - - const parsedDirective = parseDirective(comment.value); - if (parsedDirective === null) return; - const directive = - unboundedRules === undefined - ? parsedDirective - : { ...parsedDirective, rules: unboundedRules }; + const inspectEffectDiagnostics = ( + comment: Comment, + effectDiagnostics: RegExpExecArray, + ): void => { + if (!options.includeEffectDiagnosticsDirectives) return; + const body = effectDiagnostics.groups?.rest ?? ''; + const parsed = splitDescription(body); + const silenced = parseRuleList(parsed.head).filter((token) => + SILENCED_SEVERITY.has((token.split(':')[1] ?? '').toLowerCase()), + ); + if (silenced.length === 0) return; + const reasons = [...justificationReasons(parsed.description, options, expiry)]; + if (silenced.some((token) => token.split(':')[0] === '*')) + reasons.push('a wildcard suppresses every diagnostic rather than naming exact rules'); + // A file-wide Effect diagnostic waiver is never line-scoped and never narrow, so an + // ungoverned one is always reported; a justified, expiring one is accepted. + if (reasons.length === 0) return; + context.report({ + node: comment, + messageId: 'ungovernedEffectDiagnostics', + data: { + reason: reasons.join('; '), + rules: quoteList(silenced.map((token) => token.split(':')[0] ?? token)), + }, + }); + }; + const inspectDirective = (comment: Comment, directive: Directive): void => { const reasons: string[] = []; const seamHits = directive.rules.filter((name) => seamRules.has(normaliseRuleName(name))); @@ -415,6 +390,30 @@ export const rule = defineRule({ }); }; + const inspect = (comment: Comment, unboundedRules?: readonly string[]): void => { + if (comment.type === 'Shebang') return; + + if (options.includeTsNocheck && TS_NOCHECK.test(comment.value.trim())) { + context.report({ node: comment, messageId: 'tsNocheck' }); + return; + } + + const effectDiagnostics = EFFECT_DIAGNOSTICS.exec(comment.value.trim()); + if (effectDiagnostics !== null) { + inspectEffectDiagnostics(comment, effectDiagnostics); + return; + } + + const parsedDirective = parseDirective(comment.value); + if (parsedDirective === null) return; + const directive = + unboundedRules === undefined + ? parsedDirective + : { ...parsedDirective, rules: unboundedRules }; + + inspectDirective(comment, directive); + }; + return { Program(node) { const comments = context.sourceCode.getAllComments?.() ?? node.comments; @@ -423,24 +422,8 @@ export const rule = defineRule({ for (const [index, comment] of comments.entries()) { const directive = parseDirective(comment.value); if (directive !== null) { - const remaining = new Set(directive.rules.map(normaliseRuleName)); - let bounded = false; - for (const later of comments.slice(index + 1)) { - const enable = ENABLE.exec(later.value.trim()); - if (enable === null) continue; - const rules = parseRuleList(splitDescription(enable.groups?.rest ?? '').head); - if (rules.length === 0) { - bounded = true; - break; - } - for (const name of rules) remaining.delete(normaliseRuleName(name)); - if (directive.rules.length > 0 && remaining.size === 0) { - bounded = true; - break; - } - } - if (bounded) continue; - inspect(comment, [...remaining]); + const remaining = unboundedRules(directive, comments.slice(index + 1)); + if (remaining !== null) inspect(comment, remaining); continue; } inspect(comment); diff --git a/app/tools/oxlint/effect-native/rules/no-unmanaged-mutable-state.ts b/app/tools/oxlint/effect-native/rules/no-unmanaged-mutable-state.ts index 900d62b5c..366af05d3 100644 --- a/app/tools/oxlint/effect-native/rules/no-unmanaged-mutable-state.ts +++ b/app/tools/oxlint/effect-native/rules/no-unmanaged-mutable-state.ts @@ -25,9 +25,13 @@ import { defineRule } from '@oxlint/plugins'; import { fileURLToPath } from 'node:url'; -import type { Context, ESTree, Reference, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree, Reference, Variable } from '@oxlint/plugins'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; +import { matchesGlobs as matchesAny, isTestFile, normalisePath } from '../shared/paths.ts'; + +import { staticString, skipWrappers, unwrapNode } from '../shared/ast.ts'; +import { isUnshadowedGlobal, resolveVariable } from '../shared/bindings.ts'; +import { booleanOption as boolean, stringList } from '../shared/options.ts'; type AnyNode = ESTree.Node; @@ -40,10 +44,6 @@ function workspacePath(filename: string): string { const root = fileURLToPath(new URL('../../../../', import.meta.url)).replaceAll('\\', '/'); return unified.startsWith(root) ? unified.slice(root.length) : normalisePath(unified); } -function matchesAny(path: string, patterns: readonly string[]): boolean { - return patterns.some((pattern) => globToRegExp(pattern).test(path)); -} - /** Generated output is never source; not overridable through options. */ const ALWAYS_IGNORED: readonly string[] = [ '**/dist/**', @@ -57,17 +57,6 @@ const ALWAYS_IGNORED: readonly string[] = [ /** Globals that can be used to reach a constructor indirectly (`globalThis.WeakMap`). */ const CONTAINER_GLOBALS = new Set(['globalThis', 'global', 'window', 'self']); -/** Wrappers that do not change what an expression *is*. */ -const TRANSPARENT_PARENTS = new Set([ - 'ParenthesizedExpression', - 'ChainExpression', - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSNonNullExpression', - 'TSInstantiationExpression', - 'TSTypeAssertion', -]); - /** Identity-keyed collections that carry data beside the typed model. */ const WEAK_CONSTRUCTORS: readonly string[] = ['WeakMap', 'WeakSet']; /** Added when `includeWeakRef` is enabled: lifecycle side channels rather than data side channels. */ @@ -114,16 +103,6 @@ const DEFAULTS: RuleOptions = { mutatingMembers: DEFAULT_MUTATING_MEMBERS, }; -function stringList(value: unknown, fallback: readonly string[]): readonly string[] { - return Array.isArray(value) && value.every((entry) => typeof entry === 'string') - ? (value as readonly string[]) - : fallback; -} - -function boolean(value: unknown, fallback: boolean): boolean { - return typeof value === 'boolean' ? value : fallback; -} - function readOptions(raw: unknown): RuleOptions { const given = (raw ?? {}) as Partial>; const include = stringList(given.include, DEFAULTS.include); @@ -138,61 +117,43 @@ function readOptions(raw: unknown): RuleOptions { }; } -function parentOf(node: AnyNode): AnyNode | null { - return (node as { parent?: AnyNode | null }).parent ?? null; -} - -/** Climb through parentheses / type wrappers; returns the outermost equivalent node and its parent. */ -function skipWrappers(node: AnyNode): { readonly node: AnyNode; readonly parent: AnyNode | null } { - let current = node; - let parent = parentOf(current); - while (parent !== null && TRANSPARENT_PARENTS.has(parent.type)) { - current = parent; - parent = parentOf(current); - } - return { node: current, parent }; -} - -/** Strip wrappers *inwards*: `[] as const` / `(new Map())` → the container expression itself. */ function unwrap(node: AnyNode): AnyNode { - let current = node; - for (let depth = 0; depth < 10; depth += 1) { - if (!TRANSPARENT_PARENTS.has(current.type)) return current; - const inner = (current as { expression?: AnyNode }).expression; - if (inner === undefined || inner === null) return current; - current = inner; - } - return current; + return unwrapNode(node, { maxDepth: 10 }); } -/** `x.name` / `x["name"]` → `"name"`; a dynamic key → `null`. */ function staticPropertyName(node: ESTree.MemberExpression): string | null { - const property = unwrap(node.property); - if (!node.computed) - return property.type === 'Identifier' ? (property as ESTree.IdentifierName).name : null; - if (property.type === 'TemplateLiteral' && property.expressions.length === 0) - return property.quasis[0]?.value.cooked ?? null; - if (property.type !== 'Literal') return null; - const value = (property as { value?: unknown }).value; - return typeof value === 'string' ? value : null; + if (!node.computed) return node.property.type === 'Identifier' ? node.property.name : null; + return staticString(unwrap(node.property), { templates: true }); } -function resolveVariable(context: Context, name: string, from: AnyNode): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(from); - while (scope !== null) { - const variable = scope.set.get(name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; +function immutableVariable( + context: Context, + node: AnyNode & { readonly name: string }, + seen: Set, +): Variable | null { + const variable = resolveVariable(context, node.name, node); + if (!variable || seen.has(variable)) return null; + if (variable.references.some((reference) => reference.isWrite() && !reference.init)) return null; + seen.add(variable); + return variable; +} + +function variableInitializer(variable: Variable | null): AnyNode | null { + const definition = variable?.defs[0]; + return definition?.type === 'Variable' && definition.node.type === 'VariableDeclarator' + ? (definition.node.init ?? null) + : null; } -/** `true` when `node` is the ambient global `name` — not a local, parameter, class or import. */ -function isUnshadowedGlobal(context: Context, node: AnyNode, name: string): boolean { - if (node.type !== 'Identifier') return false; - if ((node as ESTree.IdentifierReference).name !== name) return false; - const variable = resolveVariable(context, name, node); - return variable === null || variable.defs.length === 0; +function constructorAlias(variable: Variable): AnyNode | null { + const initial = variableInitializer(variable); + if (initial) return initial; + const definition = variable.defs[0]; + if (definition?.type !== 'ClassName') return null; + const node = definition.node; + return node.type === 'ClassDeclaration' || node.type === 'ClassExpression' + ? node.superClass + : null; } /** @@ -210,28 +171,10 @@ function globalConstructorName( if (node.type === 'Identifier') { const name = (node as ESTree.IdentifierReference).name; if (isUnshadowedGlobal(context, node, name)) return name; - const variable = resolveVariable(context, name, node); - if ( - !variable || - seen.has(variable) || - variable.references.some((reference) => reference.isWrite() && !reference.init) - ) - return null; - seen.add(variable); - const definition = variable.defs[0]; - if ( - definition?.type === 'Variable' && - definition.node.type === 'VariableDeclarator' && - definition.node.init - ) - return globalConstructorName(context, definition.node.init, seen); - if ( - definition?.type === 'ClassName' && - (definition.node.type === 'ClassDeclaration' || definition.node.type === 'ClassExpression') && - definition.node.superClass - ) - return globalConstructorName(context, definition.node.superClass, seen); - return null; + const variable = immutableVariable(context, node, seen); + if (!variable) return null; + const alias = constructorAlias(variable); + return alias ? globalConstructorName(context, alias, seen) : null; } if (node.type !== 'MemberExpression') return null; const member = node as ESTree.MemberExpression; @@ -263,6 +206,33 @@ function moduleDeclarations(program: ESTree.Program): readonly ESTree.VariableDe return declarations; } +function inScope(path: string, options: RuleOptions): boolean { + const include = options.includeScripts ? [...options.include, 'scripts/**'] : options.include; + if (!matchesAny(path, include)) return false; + if ( + [ALWAYS_IGNORED, options.ignore, options.allowPaths].some((patterns) => + matchesAny(path, patterns), + ) + ) + return false; + if (!options.includeTests && isTestFile(path)) return false; + return options.includeScripts || !/(?:^|\/)scripts\//u.test(path); +} + +function moduleClassOwner(node: ESTree.PropertyDefinition) { + const body = node.parent; + const owner = body?.type === 'ClassBody' ? body.parent : null; + if (owner?.type !== 'ClassDeclaration' || !owner.id) return null; + const ancestor = + owner.parent?.type === 'ExportNamedDeclaration' ? owner.parent.parent : owner.parent; + return ancestor?.type === 'Program' ? owner : null; +} + +function staticFieldKey(node: ESTree.PropertyDefinition): string | null { + if (node.key.type === 'Identifier' || node.key.type === 'PrivateIdentifier') return node.key.name; + return node.key.type === 'Literal' ? String(node.key.value) : null; +} + export const rule = defineRule({ meta: { type: 'problem', @@ -338,13 +308,7 @@ export const rule = defineRule({ const options = readOptions(context.options[0]); const path = workspacePath(context.filename); - const include = options.includeScripts ? [...options.include, 'scripts/**'] : options.include; - if (!matchesAny(path, include)) return {}; - if (matchesAny(path, ALWAYS_IGNORED)) return {}; - if (matchesAny(path, options.ignore)) return {}; - if (matchesAny(path, options.allowPaths)) return {}; - if (!options.includeTests && isTestFile(path)) return {}; - if (!options.includeScripts && /(?:^|\/)scripts\//u.test(path)) return {}; + if (!inScope(path, options)) return {}; const weakConstructors = new Set( options.includeWeakRef ? [...WEAK_CONSTRUCTORS, ...WEAK_REF_CONSTRUCTORS] : WEAK_CONSTRUCTORS, @@ -374,23 +338,24 @@ export const rule = defineRule({ const name = globalConstructorName(context, value.callee); return name && CONTAINER_CONSTRUCTORS.has(name) ? 'collection' : null; } - if (value.type !== 'CallExpression') return null; + return value.type === 'CallExpression' ? factoryContainerKind(value, depth) : null; + }; + const factoryContainerKind = ( + value: ESTree.CallExpression, + depth: number, + ): 'object' | 'collection' | null => { const callee = unwrap(value.callee); if (callee.type !== 'MemberExpression') return null; - const method = staticPropertyName(callee); + const method = staticPropertyName(callee) ?? ''; const object = unwrap(callee.object); if (isUnshadowedGlobal(context, object, 'Object')) { - if (method === 'create' || method === 'fromEntries') return 'object'; - if (['entries', 'keys', 'values'].includes(method ?? '')) return 'collection'; + if (['create', 'fromEntries'].includes(method)) return 'object'; + if (['entries', 'keys', 'values'].includes(method)) return 'collection'; } - if (isUnshadowedGlobal(context, object, 'Array') && (method === 'from' || method === 'of')) - return 'collection'; - if ( - ['slice', 'concat', 'map', 'filter', 'flat', 'flatMap'].includes(method ?? '') && - containerKind(object, depth + 1) === 'collection' - ) + if (isUnshadowedGlobal(context, object, 'Array') && ['from', 'of'].includes(method)) return 'collection'; - return null; + if (!['slice', 'concat', 'map', 'filter', 'flat', 'flatMap'].includes(method)) return null; + return containerKind(object, depth + 1) === 'collection' ? 'collection' : null; }; const propertyValue = (input: AnyNode | null, key: string | null): AnyNode | null => { const value = valueOf(input); @@ -425,21 +390,23 @@ export const rule = defineRule({ current = outer.node; parent = outer.parent; } - if (parent?.type === 'AssignmentExpression' && parent.left === current) return true; - if ( - parent?.type === 'UpdateExpression' || - (parent?.type === 'UnaryExpression' && parent.operator === 'delete') - ) - return true; - if (parent?.type === 'CallExpression' && parent.arguments[0] === current) { - const callee = unwrap(parent.callee); - return ( - callee.type === 'MemberExpression' && - staticPropertyName(callee) === 'assign' && - isUnshadowedGlobal(context, unwrap(callee.object), 'Object') - ); - } - return false; + return directlyMutated(current, parent); + }; + const directlyMutated = (current: AnyNode, parent: AnyNode | null): boolean => { + if (!parent) return false; + if (parent.type === 'AssignmentExpression' && parent.left === current) return true; + if (parent.type === 'UpdateExpression') return true; + if (parent.type === 'UnaryExpression' && parent.operator === 'delete') return true; + if (parent.type !== 'CallExpression' || parent.arguments[0] !== current) return false; + return isObjectAssign(parent.callee); + }; + const isObjectAssign = (input: AnyNode): boolean => { + const callee = unwrap(input); + return ( + callee.type === 'MemberExpression' && + staticPropertyName(callee) === 'assign' && + isUnshadowedGlobal(context, unwrap(callee.object), 'Object') + ); }; const isMutatingReference = (reference: Reference, init: AnyNode | null): boolean => { if (reference.init) return false; @@ -453,21 +420,8 @@ export const rule = defineRule({ isUnshadowedGlobal(context, node, node.name) ) return true; - const variable = resolveVariable(context, node.name, node); - if ( - !variable || - seen.has(variable) || - variable.references.some((reference) => reference.isWrite() && !reference.init) - ) - return false; - seen.add(variable); - const definition = variable.defs[0]; - return ( - definition?.type === 'Variable' && - definition.node.type === 'VariableDeclarator' && - !!definition.node.init && - globalContainer(definition.node.init, seen) - ); + const initial = variableInitializer(immutableVariable(context, node, seen)); + return initial !== null && globalContainer(initial, seen); }; const reportDeclarator = (declarator: ESTree.VariableDeclarator): void => { @@ -487,6 +441,25 @@ export const rule = defineRule({ } }; + const inspectDeclarator = ( + declarator: ESTree.VariableDeclarator, + kind: ESTree.VariableDeclaration['kind'], + ): void => { + if (kind === 'let' || kind === 'var') { + reportDeclarator(declarator); + return; + } + if (kind !== 'const' || declarator.id.type !== 'Identifier') return; + if (!containerKind(declarator.init ?? null)) return; + const variables = context.sourceCode.getDeclaredVariables(declarator); + const mutated = variables.some((variable) => + variable.references.some((reference) => + isMutatingReference(reference, declarator.init ?? null), + ), + ); + if (mutated) reportDeclarator(declarator); + }; + return { AssignmentExpression(node) { const left = unwrap(node.left); @@ -499,18 +472,9 @@ export const rule = defineRule({ }, PropertyDefinition(node) { if (!node.static || node.declare || !node.value) return; - const body = node.parent; - const owner = body?.type === 'ClassBody' ? body.parent : null; - if (owner?.type !== 'ClassDeclaration' || !owner.id) return; - let ancestor = owner.parent; - if (ancestor?.type === 'ExportNamedDeclaration') ancestor = ancestor.parent; - if (ancestor?.type !== 'Program') return; - const key = - node.key.type === 'Identifier' || node.key.type === 'PrivateIdentifier' - ? node.key.name - : node.key.type === 'Literal' - ? String(node.key.value) - : null; + const owner = moduleClassOwner(node); + if (!owner?.id) return; + const key = staticFieldKey(node); if (key === null) return; const variables = context.sourceCode.getDeclaredVariables(owner); const mutated = variables.some((variable) => @@ -553,25 +517,8 @@ export const rule = defineRule({ // (2) module-scope `let`/`var`, and mutated module-scope container `const`s. Program(node) { for (const declaration of moduleDeclarations(node)) { - const mutableBinding = declaration.kind === 'let' || declaration.kind === 'var'; - for (const declarator of declaration.declarations) { - if (mutableBinding) { - reportDeclarator(declarator); - continue; - } - if (declaration.kind !== 'const') continue; - if ((declarator.id as AnyNode).type !== 'Identifier') continue; - if (!containerKind(declarator.init ?? null)) continue; - const variables = context.sourceCode.getDeclaredVariables( - declarator as unknown as AnyNode, - ); - const mutated = variables.some((variable) => - variable.references.some((reference) => - isMutatingReference(reference, declarator.init ?? null), - ), - ); - if (mutated) reportDeclarator(declarator); - } + for (const declarator of declaration.declarations) + inspectDeclarator(declarator, declaration.kind); } }, }; diff --git a/app/tools/oxlint/effect-native/rules/no-unredacted-secret-field.ts b/app/tools/oxlint/effect-native/rules/no-unredacted-secret-field.ts index 8529687a3..572d7b9d7 100644 --- a/app/tools/oxlint/effect-native/rules/no-unredacted-secret-field.ts +++ b/app/tools/oxlint/effect-native/rules/no-unredacted-secret-field.ts @@ -87,19 +87,17 @@ */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { ESTree } from '@oxlint/plugins'; import { collectEffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; +import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; +import { compile, stringList } from '../shared/options.ts'; +import { parentOf, unwrapNode } from '../shared/ast.ts'; +import { resolveVariable, resolvesToImport as importedReference } from '../shared/bindings.ts'; +import { collectRootNamespaces, collectNamedImports, importedName } from '../shared/imports.ts'; type AnyNode = ESTree.Node; - -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the production `includePaths` defaults instead of - * forcing the fixture config to loosen them (`run-on-repo.mts` reuses that config verbatim). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; +type IdentifierNode = Extract; const DEFAULT_INCLUDE_PATHS: readonly string[] = [ 'apps/**', @@ -135,6 +133,10 @@ const EFFECT_ROOT_MODULE = 'effect'; const SCHEMA_NAMESPACE = 'Schema'; const CONFIG_NAMESPACE = 'Config'; const PIPE_EXPORT = 'pipe'; +const DIRECT_NAMESPACES = new Map([ + ['effect/Schema', SCHEMA_NAMESPACE], + ['effect/Config', CONFIG_NAMESPACE], +]); /** Type-reference containers whose single string argument is still a bag of raw strings. */ const STRING_CONTAINERS = new Set(['Array', 'ReadonlyArray']); @@ -213,22 +215,6 @@ interface RuleOptions { readonly secretNames: RegExp; } -function stringList(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - return value.every((entry) => typeof entry === 'string') - ? (value as readonly string[]) - : fallback; -} - -function compile(value: unknown, fallback: string, flags: string): RegExp { - const source = typeof value === 'string' && value.length > 0 ? value : fallback; - try { - return new RegExp(source, flags); - } catch { - return new RegExp(fallback, flags); - } -} - function readOptions(raw: unknown): RuleOptions { const given = (raw ?? {}) as Record; const includePaths = stringList(given.includePaths, DEFAULT_INCLUDE_PATHS); @@ -242,27 +228,8 @@ function readOptions(raw: unknown): RuleOptions { }; } -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -function parentOf(node: AnyNode): AnyNode | null { - return (node as { parent?: AnyNode | null }).parent ?? null; -} - function unwrap(node: AnyNode): AnyNode { - let current = node; - for (let guard = 0; guard < 16; guard += 1) { - if (!UNWRAPPABLE.has(current.type)) return current; - const inner = (current as { expression?: AnyNode }).expression; - if (inner === undefined) return current; - current = inner; - } - return current; + return unwrapNode(node, { wrappers: UNWRAPPABLE, maxDepth: 16 }); } /** `.String` or `["String"]` → `"String"`; a dynamic key → `null`. */ @@ -313,44 +280,55 @@ function unwrapType(node: AnyNode): AnyNode { return current; } -/** `string`, `string | null`, `readonly string[]`, `ReadonlySet` — but never `Redacted`. */ +/** Nullable and literal alternatives do not themselves establish a raw string payload. */ +function isNeutralStringAlternative(type: AnyNode): boolean { + if (type.type === 'TSNullKeyword' || type.type === 'TSUndefinedKeyword') return true; + return ( + type.type === 'TSLiteralType' && + type.literal.type === 'Literal' && + typeof type.literal.value === 'string' + ); +} + +function isStringUnion(types: readonly AnyNode[], depth: number): boolean { + let sawString = false; + for (const member of types) { + const inner = unwrapType(member); + if (isNeutralStringAlternative(inner)) continue; + if (!isStringShaped(inner, depth + 1)) return false; + sawString = true; + } + return sawString; +} + +function isStringContainer(type: ESTree.TSTypeReference, depth: number): boolean { + const name = typeNameOf(type.typeName); + if (name === null || !STRING_CONTAINERS.has(name)) return false; + const args = type.typeArguments?.params ?? []; + return args.length === 1 && args[0] !== undefined && isStringShaped(args[0], depth + 1); +} + +/** Raw strings, nullable alternatives and ordered collections, never redacted types. */ function isStringShaped(node: AnyNode, depth: number): boolean { if (depth > 6) return false; const type = unwrapType(node); - if (type.type === 'TSStringKeyword') return true; - if (type.type === 'TSLiteralType' && type.literal.type === 'TemplateLiteral') - return type.literal.expressions.length > 0; - if (type.type === 'TSTemplateLiteralType') - return type.types.some((member) => isStringShaped(member, depth + 1)); - if (type.type === 'TSIntersectionType') - return type.types.some((member) => isStringShaped(member, depth + 1)); - if (type.type === 'TSArrayType') - return isStringShaped((type as { elementType: AnyNode }).elementType, depth + 1); - if (type.type === 'TSUnionType') { - let sawString = false; - for (const member of (type as { types: readonly AnyNode[] }).types) { - const inner = unwrapType(member); - if (inner.type === 'TSNullKeyword' || inner.type === 'TSUndefinedKeyword') continue; - if ( - inner.type === 'TSLiteralType' && - inner.literal.type === 'Literal' && - typeof inner.literal.value === 'string' - ) - continue; - if (!isStringShaped(inner, depth + 1)) return false; - sawString = true; - } - return sawString; - } - if (type.type === 'TSTypeReference') { - const name = typeNameOf((type as { typeName: AnyNode }).typeName); - if (name === null || !STRING_CONTAINERS.has(name)) return false; - const args = - (type as { typeArguments?: { params?: readonly AnyNode[] } | null }).typeArguments?.params ?? - []; - return args.length === 1 && args[0] !== undefined && isStringShaped(args[0], depth + 1); + switch (type.type) { + case 'TSStringKeyword': + return true; + case 'TSLiteralType': + return type.literal.type === 'TemplateLiteral' && type.literal.expressions.length > 0; + case 'TSTemplateLiteralType': + case 'TSIntersectionType': + return type.types.some((member) => isStringShaped(member, depth + 1)); + case 'TSArrayType': + return isStringShaped(type.elementType, depth + 1); + case 'TSUnionType': + return isStringUnion(type.types, depth); + case 'TSTypeReference': + return isStringContainer(type, depth); + default: + return false; } - return false; } export const rule = defineRule({ @@ -436,112 +414,95 @@ export const rule = defineRule({ const isSecretName = (name: string): boolean => options.secretNames.test(name); - const lookupVariable = (identifier: AnyNode, name: string): Variable | null => { - let scope: Scope | null = context.sourceCode.getScope(identifier); - while (scope !== null) { - const variable = scope.set.get(name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; + const lookupVariable = (identifier: AnyNode, name: string) => + resolveVariable(context, name, identifier); + + const resolvesToImport = (node: AnyNode): boolean => importedReference(context, node); + + const singleDefinition = (node: IdentifierNode) => { + const variable = lookupVariable(node, node.name); + return variable?.defs.length === 1 ? variable.defs[0] : undefined; }; - /** `true` when the namespace identifier still resolves to its import (no local shadow). */ - const resolvesToImport = (node: AnyNode, name: string): boolean => { - const variable = lookupVariable(node, name); - if (variable === null || variable.defs.length === 0) return true; - return variable.defs.some((definition) => definition.type === 'ImportBinding'); + const constantInitializer = (node: IdentifierNode): AnyNode | null => { + const definition = singleDefinition(node); + if (definition?.type !== 'Variable') return null; + const declaration = definition.node as ESTree.VariableDeclarator; + if (declaration.id.type !== 'Identifier' || !declaration.init) return null; + return (parentOf(declaration) as ESTree.VariableDeclaration)?.kind === 'const' + ? (declaration.init as AnyNode) + : null; }; - /** Resolve exact import identity, including direct submodule members and const aliases. */ - const resolveMember = ( - input: AnyNode, - depth = 0, + const valueImport = (node: IdentifierNode) => { + const definition = singleDefinition(node); + if (definition?.type !== 'ImportBinding') return null; + const specifier = definition.node as ESTree.ImportSpecifier; + const declaration = parentOf(specifier) as ESTree.ImportDeclaration; + if ( + declaration?.type !== 'ImportDeclaration' || + declaration.importKind === 'type' || + specifier.importKind === 'type' + ) + return null; + return { specifier, declaration }; + }; + + const directMember = ( + node: IdentifierNode, + depth: number, ): { namespace: string; member: string } | null => { - if (depth > 12) return null; - const node = unwrap(input); - if (node.type === 'Identifier') { - const variable = lookupVariable(node, node.name); - if (!variable || variable.defs.length !== 1) return null; - const definition = variable.defs[0]; - if (definition.type === 'Variable') { - const declaration = definition.node as ESTree.VariableDeclarator; - if ( - declaration.id.type !== 'Identifier' || - !declaration.init || - (parentOf(declaration) as ESTree.VariableDeclaration)?.kind !== 'const' - ) - return null; - return resolveMember(declaration.init as AnyNode, depth + 1); - } - if (definition.type !== 'ImportBinding') return null; - const specifier = definition.node as ESTree.ImportSpecifier; - const declaration = parentOf(specifier as AnyNode) as ESTree.ImportDeclaration; - if ( - declaration?.type !== 'ImportDeclaration' || - declaration.importKind === 'type' || - specifier.importKind === 'type' - ) - return null; - if (specifier.type !== 'ImportSpecifier') return null; - const member = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; - const namespace = - declaration.source.value === 'effect/Schema' - ? 'Schema' - : declaration.source.value === 'effect/Config' - ? 'Config' - : null; - return namespace ? { namespace, member } : null; - } - if (node.type !== 'MemberExpression') return null; - const member = memberName(node); - if (!member) return null; - const object = unwrap(node.object as AnyNode); - if (object.type === 'Identifier') { - const variable = lookupVariable(object, object.name); - const definition = variable?.defs.length === 1 ? variable.defs[0] : undefined; - if (definition?.type !== 'ImportBinding') return null; - const specifier = definition.node as ESTree.ImportSpecifier; - const declaration = parentOf(specifier as AnyNode) as ESTree.ImportDeclaration; - if ( - declaration?.type !== 'ImportDeclaration' || - declaration.importKind === 'type' || - specifier.importKind === 'type' - ) - return null; - const namespace = namespaces.get(object.name); - return namespace === 'Schema' || namespace === 'Config' ? { namespace, member } : null; - } - if (object.type !== 'MemberExpression') return null; + const initializer = constantInitializer(node); + if (initializer) return resolveMember(initializer, depth + 1); + const binding = valueImport(node); + if (!binding || binding.specifier.type !== 'ImportSpecifier') return null; + const namespace = DIRECT_NAMESPACES.get(binding.declaration.source.value); + return namespace ? { namespace, member: importedName(binding.specifier) } : null; + }; + + const namespaceMember = (object: IdentifierNode, member: string) => { + if (!valueImport(object)) return null; + const namespace = namespaces.get(object.name); + return namespace === 'Schema' || namespace === 'Config' ? { namespace, member } : null; + }; + + const barrelMember = (object: ESTree.MemberExpression, member: string) => { const namespace = memberName(object); const root = unwrap(object.object as AnyNode); if ( !namespace || root.type !== 'Identifier' || !barrels.has(root.name) || - !resolvesToImport(root, root.name) + !resolvesToImport(root) ) return null; return { namespace, member }; }; + + /** Exact imports and bounded const aliases, without broadening namespace alias support. */ + const resolveMember = ( + input: AnyNode, + depth = 0, + ): { namespace: string; member: string } | null => { + if (depth > 12) return null; + const node = unwrap(input); + if (node.type === 'Identifier') return directMember(node, depth); + if (node.type !== 'MemberExpression') return null; + const member = memberName(node); + if (!member) return null; + const object = unwrap(node.object as AnyNode); + if (object.type === 'Identifier') return namespaceMember(object, member); + return object.type === 'MemberExpression' ? barrelMember(object, member) : null; + }; + const configKey = (node: AnyNode, depth = 0): string | null => { if (depth > 12) return null; const value = unwrap(node); const literal = staticString(value); if (literal !== null) return literal; if (value.type !== 'Identifier') return null; - const variable = lookupVariable(value, value.name); - const definition = variable?.defs.length === 1 ? variable.defs[0] : undefined; - if (definition?.type !== 'Variable') return null; - const declaration = definition.node as ESTree.VariableDeclarator; - return declaration.id.type === 'Identifier' && - declaration.init && - (parentOf(declaration) as ESTree.VariableDeclaration)?.kind === 'const' - ? configKey(declaration.init as AnyNode, depth + 1) - : null; + const initializer = constantInitializer(value); + return initializer ? configKey(initializer, depth + 1) : null; }; const isRedaction = (node: AnyNode): boolean => { const expression = unwrap(node); @@ -557,54 +518,54 @@ export const rule = defineRule({ const isPipeIdentifier = (node: AnyNode): boolean => { if (node.type !== 'Identifier') return false; const name = (node as { name: string }).name; - return namespaces.get(name) === PIPE_EXPORT && resolvesToImport(node, name); + return namespaces.get(name) === PIPE_EXPORT && resolvesToImport(node); + }; + + const isSchemaMember = (node: AnyNode, members: ReadonlySet): boolean => { + const resolved = resolveMember(node); + return ( + resolved !== null && resolved.namespace === SCHEMA_NAMESPACE && members.has(resolved.member) + ); + }; + + const stringSchemaArgument = (call: ESTree.CallExpression, depth: number): boolean => { + const argument = call.arguments[0]; + return ( + argument !== undefined && + argument.type !== 'SpreadElement' && + isStringSchema(argument, depth + 1) + ); + }; + + const isStringSchemaChain = ( + call: ESTree.CallExpression, + callee: ESTree.MemberExpression, + depth: number, + ): boolean | null => { + const method = memberName(callee); + if (method === null || !SCHEMA_CHAIN_METHODS.has(method)) return null; + if (method === 'pipe' && call.arguments.some((argument) => isRedaction(argument as AnyNode))) + return false; + return isStringSchema(callee.object as AnyNode, depth + 1); }; - /** `Schema.String`, `Schema.String.check(...)`, `Schema.optional(Schema.Trim)`, `pipe(Schema.String, …)`. */ + /** String constructors, absence wrappers and non-redacting fluent chains. */ const isStringSchema = (node: AnyNode, depth: number): boolean => { if (depth > 8) return false; const expression = unwrap(node); - const direct = resolveMember(expression); - if ( - direct !== null && - direct.namespace === SCHEMA_NAMESPACE && - STRING_SCHEMAS.has(direct.member) - ) - return true; + if (isSchemaMember(expression, STRING_SCHEMAS)) return true; if (expression.type !== 'CallExpression') return false; - const call = expression as ESTree.CallExpression; - const callee = unwrap(call.callee as AnyNode); - const firstArgument = call.arguments[0] as AnyNode | undefined; - const wrapped = - firstArgument !== undefined && firstArgument.type !== 'SpreadElement' - ? firstArgument - : undefined; - const called = resolveMember(callee); - if ( - called !== null && - called.namespace === SCHEMA_NAMESPACE && - SCHEMA_WRAPPERS.has(called.member) - ) { - return wrapped !== undefined && isStringSchema(wrapped, depth + 1); - } + const callee = unwrap(expression.callee as AnyNode); + if (isSchemaMember(callee, SCHEMA_WRAPPERS)) return stringSchemaArgument(expression, depth); if (callee.type === 'MemberExpression') { - const method = memberName(callee as ESTree.MemberExpression); - if (method !== null && SCHEMA_CHAIN_METHODS.has(method)) { - if ( - method === 'pipe' && - call.arguments.some((argument) => isRedaction(argument as AnyNode)) - ) - return false; - return isStringSchema((callee as ESTree.MemberExpression).object as AnyNode, depth + 1); - } + const chain = isStringSchemaChain(expression, callee, depth); + if (chain !== null) return chain; } - if (isPipeIdentifier(callee)) - return ( - !call.arguments.slice(1).some((argument) => isRedaction(argument as AnyNode)) && - wrapped !== undefined && - isStringSchema(wrapped, depth + 1) - ); - return false; + return ( + isPipeIdentifier(callee) && + !expression.arguments.slice(1).some((argument) => isRedaction(argument as AnyNode)) && + stringSchemaArgument(expression, depth) + ); }; const report = (node: AnyNode, messageId: string, data: Record): void => { @@ -628,90 +589,72 @@ export const rule = defineRule({ return null; }; + const accessor = (node: AnyNode, kind: 'get' | 'set') => { + const method = node.type === 'TSMethodSignature' ? node : parentOf(node); + if (!method) return null; + if ( + method.type !== 'TSMethodSignature' && + method.type !== 'MethodDefinition' && + method.type !== 'TSAbstractMethodDefinition' + ) + return null; + return method.kind === kind ? method : null; + }; + + const reportField = (field: { key: AnyNode; computed: boolean }): void => { + const name = keyName(field.key, field.computed); + if (name === null || !isSecretName(name)) return; + report(field.key, 'secretField', { name }); + }; + + const reportParameter = (identifier: IdentifierNode): void => { + const owner = parameterOwner(identifier); + if (owner === null) return; + const setter = accessor(owner, 'set'); + const setterName = setter ? keyName(setter.key, setter.computed) : null; + const name = setterName && isSecretName(setterName) ? setterName : identifier.name; + if (isSecretName(name)) report(identifier, 'secretParameter', { name }); + }; + return { Program(node) { - const bindings = collectEffectBindings(node); - namespaces = new Map(bindings.namespaces); - barrels = new Set(); - for (const statement of node.body) { - if (statement.type !== 'ImportDeclaration' || statement.importKind === 'type') continue; - const source = statement.source.value; - const isReexport = matchesGlobs(source, options.reexportModules); - if (source === EFFECT_ROOT_MODULE || isReexport) { - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportNamespaceSpecifier') barrels.add(specifier.local.name); - } - } - if (!isReexport) continue; - for (const specifier of statement.specifiers) { - if (specifier.type !== 'ImportSpecifier' || specifier.importKind === 'type') continue; - const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; - namespaces.set(specifier.local.name, imported); - } + namespaces = new Map(collectEffectBindings(node).namespaces); + const isReexport = (source: string) => matchesGlobs(source, options.reexportModules); + barrels = collectRootNamespaces( + node, + (source) => source === EFFECT_ROOT_MODULE || isReexport(source), + { valueOnly: true }, + ); + for (const [local, imported] of collectNamedImports(node, isReexport, undefined, { + valueOnly: true, + })) { + namespaces.set(local, imported); } }, // Cases 1 + 2: a string-shaped type annotation on a credential-shaped name. TSTypeAnnotation(node) { - const annotated = parentOf(node as unknown as AnyNode); - if (annotated === null) return; - const annotation = (node as { typeAnnotation: AnyNode }).typeAnnotation; - if (!isStringShaped(annotation, 0)) return; - - if (annotated.type === 'TSPropertySignature') { - const signature = annotated as ESTree.TSPropertySignature; - const name = keyName(signature.key as AnyNode, signature.computed); - if (name === null || !isSecretName(name)) return; - report(signature.key as AnyNode, 'secretField', { name }); - return; - } + const annotated = parentOf(node); + if (annotated === null || !isStringShaped(node.typeAnnotation, 0)) return; if ( [ + 'TSPropertySignature', 'PropertyDefinition', 'AccessorProperty', 'TSAbstractPropertyDefinition', 'TSAbstractAccessorProperty', ].includes(annotated.type) ) { - const definition = annotated as unknown as { key: AnyNode; computed: boolean }; - const name = keyName(definition.key, definition.computed); - if (name === null || !isSecretName(name)) return; - report(definition.key, 'secretField', { name }); + reportField(annotated as unknown as { key: AnyNode; computed: boolean }); return; } - const method = annotated.type === 'TSMethodSignature' ? annotated : parentOf(annotated); - if ( - method && - (method.type === 'TSMethodSignature' || - method.type === 'MethodDefinition' || - method.type === 'TSAbstractMethodDefinition') && - method.kind === 'get' - ) { - const name = keyName(method.key, method.computed); - if (name && isSecretName(name)) report(method.key, 'secretField', { name }); + const getter = accessor(annotated, 'get'); + if (getter) { + const name = keyName(getter.key, getter.computed); + if (name && isSecretName(name)) report(getter.key, 'secretField', { name }); return; } - if (annotated.type !== 'Identifier') return; - const owner = parameterOwner(annotated); - if (owner === null) return; - const setter = owner.type === 'TSMethodSignature' ? owner : parentOf(owner); - const setterName = - setter && - (setter.type === 'TSMethodSignature' || - setter.type === 'MethodDefinition' || - setter.type === 'TSAbstractMethodDefinition') && - setter.kind === 'set' - ? keyName(setter.key, setter.computed) - : null; - const name = - setterName && isSecretName(setterName) - ? setterName - : (annotated as { name: string }).name; - if (!isSecretName(name)) return; - report(annotated, 'secretParameter', { name }); + if (annotated.type === 'Identifier') reportParameter(annotated); }, // Case 3: a credential-shaped field in a Schema field bag. diff --git a/app/tools/oxlint/effect-native/rules/no-wide-factory-signature.ts b/app/tools/oxlint/effect-native/rules/no-wide-factory-signature.ts index ef92160e8..c1d4cdb58 100644 --- a/app/tools/oxlint/effect-native/rules/no-wide-factory-signature.ts +++ b/app/tools/oxlint/effect-native/rules/no-wide-factory-signature.ts @@ -14,18 +14,13 @@ import { defineRule } from '@oxlint/plugins'; import type { ESTree } from '@oxlint/plugins'; -import { bindingsFor } from '../shared/effect-imports.ts'; -import { globToRegExp, isScriptFile, isTestFile, normalisePath } from '../shared/paths.ts'; +import { keyName as staticKeyName, parentOf, unwrapBinding } from '../shared/ast.ts'; +import { lookupVariable } from '../shared/bindings.ts'; +import { booleanOption, compile, positiveInteger, stringList } from '../shared/options.ts'; +import { isScriptFile, isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; type AnyNode = ESTree.Node; -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the production `includePaths` defaults instead of - * forcing the fixture config to loosen them (`run-on-repo.mts` reuses that config verbatim). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; - const DEFAULT_FACTORY_NAME_PATTERN = '^(make|create|build|define)[A-Z]'; const DEFAULT_MAX_POSITIONAL_PARAMS = 2; const DEFAULT_OPTION_BAG_TYPE_PATTERN = '(Options|Dependencies|Deps|Config)$'; @@ -33,9 +28,6 @@ const DEFAULT_INCLUDE_PATHS: readonly string[] = ['apps/**', 'verticals/**', 'pa const DEFAULT_IGNORE: readonly string[] = []; const DEFAULT_IGNORE_NAMES: readonly string[] = []; -/** Wrappers between a written parameter and the binding it introduces. */ -const PARAMETER_WRAPPERS = new Set(['AssignmentPattern', 'RestElement', 'TSParameterProperty']); - /** Expression wrappers between a function expression and the declaration that names it. */ const VALUE_WRAPPERS = new Set([ 'TSAsExpression', @@ -76,81 +68,29 @@ interface RuleOptions { readonly optionBagTypePattern: RegExp; } -function stringList(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - return value.every((entry) => typeof entry === 'string') - ? (value as readonly string[]) - : fallback; -} - -function compile(value: unknown, fallback: string): RegExp { - const source = typeof value === 'string' && value.length > 0 ? value : fallback; - try { - return new RegExp(source, 'u'); - } catch { - return new RegExp(fallback, 'u'); - } -} - function readOptions(raw: unknown): RuleOptions { const given = (raw ?? {}) as Record; const includePaths = stringList(given.includePaths, DEFAULT_INCLUDE_PATHS); - const max = given.maxPositionalParams; return { factoryNamePattern: compile(given.factoryNamePattern, DEFAULT_FACTORY_NAME_PATTERN), - flagOptionBags: typeof given.flagOptionBags === 'boolean' ? given.flagOptionBags : true, + flagOptionBags: booleanOption(given.flagOptionBags, true), ignore: stringList(given.ignore, DEFAULT_IGNORE), ignoreNames: new Set(stringList(given.ignoreNames, DEFAULT_IGNORE_NAMES)), includePaths: includePaths.length > 0 ? includePaths : DEFAULT_INCLUDE_PATHS, - includeScripts: typeof given.includeScripts === 'boolean' ? given.includeScripts : false, - includeTests: typeof given.includeTests === 'boolean' ? given.includeTests : false, - includeTypeSignatures: - typeof given.includeTypeSignatures === 'boolean' ? given.includeTypeSignatures : true, - maxPositionalParams: - typeof max === 'number' && Number.isInteger(max) && max >= 0 - ? max - : DEFAULT_MAX_POSITIONAL_PARAMS, + includeScripts: booleanOption(given.includeScripts, false), + includeTests: booleanOption(given.includeTests, false), + includeTypeSignatures: booleanOption(given.includeTypeSignatures, true), + maxPositionalParams: positiveInteger( + given.maxPositionalParams, + DEFAULT_MAX_POSITIONAL_PARAMS, + 0, + ), optionBagTypePattern: compile(given.optionBagTypePattern, DEFAULT_OPTION_BAG_TYPE_PATTERN), }; } -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -function parentOf(node: AnyNode): AnyNode | null { - return (node as { parent?: AnyNode | null }).parent ?? null; -} - -/** `{ makeX: … }` / `{ "makeX": … }` → `"makeX"`; computed keys are unknowable and yield `null`. */ function keyName(key: AnyNode, computed: boolean): string | null { - if (!computed && key.type === 'Identifier') return (key as { name: string }).name; - if (key.type === 'TemplateLiteral' && key.expressions.length === 0) - return key.quasis[0]?.value.cooked ?? null; - if (key.type === 'Literal') { - const value = (key as { value?: unknown }).value; - return typeof value === 'string' ? value : null; - } - return null; -} - -/** `AssignmentPattern` / `RestElement` / `TSParameterProperty` → the binding they wrap. */ -function unwrapBinding(node: AnyNode): AnyNode { - let current = node; - for (let guard = 0; guard < 4; guard += 1) { - if (!PARAMETER_WRAPPERS.has(current.type)) return current; - const inner = - (current as { left?: AnyNode }).left ?? - (current as { argument?: AnyNode }).argument ?? - (current as { parameter?: AnyNode }).parameter; - if (inner === undefined) return current; - current = inner; - } - return current; + return staticKeyName(key, computed, { templates: true, rawTemplates: false, singleQuasi: false }); } /** @@ -175,6 +115,10 @@ function typeReferenceNames(annotation: AnyNode | null | undefined, depth = 0): annotation.type === 'TSTypeAnnotation' ? (annotation as { typeAnnotation: AnyNode }).typeAnnotation : annotation; + return namesInType(node, depth); +} + +function namesInType(node: AnyNode, depth: number): readonly string[] { if (TYPE_WRAPPERS.has(node.type)) { const inner = (node as { typeAnnotation?: AnyNode }).typeAnnotation ?? @@ -189,12 +133,13 @@ function typeReferenceNames(annotation: AnyNode | null | undefined, depth = 0): return names; } if (node.type !== 'TSTypeReference') return []; - const typeName = (node as { typeName: AnyNode }).typeName; - if (typeName.type === 'Identifier') return [(typeName as { name: string }).name]; - if (typeName.type === 'TSQualifiedName') { - const right = (typeName as { right: AnyNode }).right; - return right.type === 'Identifier' ? [(right as { name: string }).name] : []; - } + return referenceLeafNames((node as { typeName: AnyNode }).typeName); +} + +function referenceLeafNames(typeName: AnyNode): readonly string[] { + if (typeName.type === 'Identifier') return [typeName.name]; + if (typeName.type === 'TSQualifiedName' && typeName.right.type === 'Identifier') + return [typeName.right.name]; return []; } @@ -220,21 +165,23 @@ function declaredName(fn: AnyNode): FactoryName | null { parent = parentOf(parent); } if (parent === null) return null; + return holderName(parent, child); +} + +function assignmentName(left: AnyNode): FactoryName | null { + if (left.type === 'Identifier') return { name: left.name, node: left }; + if (left.type !== 'MemberExpression') return null; + const name = keyName(left.property, left.computed); + return name === null ? null : { name, node: left.property }; +} + +function holderName(parent: AnyNode, child: AnyNode): FactoryName | null { if (parent.type === 'VariableDeclarator') { - const holder = parent as unknown as { id: AnyNode; init: AnyNode | null }; - if (holder.init !== child || holder.id.type !== 'Identifier') return null; - return { name: (holder.id as { name: string }).name, node: holder.id }; + if (parent.init !== child || parent.id.type !== 'Identifier') return null; + return { name: parent.id.name, node: parent.id }; } if (parent.type === 'AssignmentExpression') { - const holder = parent as unknown as { left: AnyNode; right: AnyNode }; - if (holder.right !== child) return null; - if (holder.left.type === 'Identifier') { - return { name: (holder.left as { name: string }).name, node: holder.left }; - } - if (holder.left.type !== 'MemberExpression') return null; - const member = holder.left as unknown as { property: AnyNode; computed: boolean }; - const name = keyName(member.property, member.computed); - return name === null ? null : { name, node: member.property }; + return parent.right === child ? assignmentName(parent.left) : null; } if (!NAMED_MEMBERS.has(parent.type)) return null; const holder = parent as unknown as { key: AnyNode; computed: boolean; value: AnyNode | null }; @@ -351,7 +298,6 @@ export const rule = defineRule({ if (!options.includeScripts && isScriptFile(path)) return {}; if (!options.includeTests && isTestFile(path)) return {}; - const bindings = bindingsFor(context); // Resolve actual lexical imports and immutable aliases; raw text (including comments and // Node stream .pipe calls) is never evidence that a body constructs an Effect. const resolve = (node: any, seen = new Set()): string | null => { @@ -365,32 +311,26 @@ export const rule = defineRule({ return object && key ? `${object}.${key}` : null; } if (node.type !== 'Identifier') return null; - for ( - let scope: import('@oxlint/plugins').Scope | null = context.sourceCode.getScope(node); - scope; - scope = scope.upper - ) { - const variable = scope.set.get(node.name); - if (!variable) continue; - for (const def of variable.defs as any[]) { - if (def.type === 'ImportBinding') { - const source = def.parent?.source?.value; - if (source !== 'effect' && source !== 'effect/Effect') return null; - const imported = def.node.imported?.name ?? def.node.imported?.value; - return source === 'effect/Effect' - ? imported - ? `Effect.${imported}` - : 'Effect' - : (imported ?? 'root'); - } - if ( - def.type === 'Variable' && - def.parent?.kind === 'const' && - !variable.references.some((r: any) => r.isWrite() && !r.init) - ) - return resolve(def.node.init, seen); - } - return null; + const variable = lookupVariable(context, node); + if (!variable) return null; + return resolveDefinitions(variable, seen); + }; + const importIdentity = (def: any): string | null => { + const source = def.parent?.source?.value; + if (source !== 'effect' && source !== 'effect/Effect') return null; + const imported = def.node.imported?.name ?? def.node.imported?.value; + if (source === 'effect/Effect') return imported ? `Effect.${imported}` : 'Effect'; + return imported ?? 'root'; + }; + const resolveDefinitions = ( + variable: import('@oxlint/plugins').Variable, + seen: Set, + ): string | null => { + for (const def of variable.defs as any[]) { + if (def.type === 'ImportBinding') return importIdentity(def); + if (def.type !== 'Variable' || def.parent?.kind !== 'const') continue; + if (!variable.references.some((r) => r.isWrite() && !r.init)) + return resolve(def.node.init, seen); } return null; }; @@ -416,7 +356,10 @@ export const rule = defineRule({ const dataType = (node: any, seen = new Set()): boolean => { if (!node || seen.has(node)) return false; seen.add(node); - if (node.type === 'TSTypeAnnotation' || node.type === 'TSTypeOperator') + return classifyDataType(node, seen); + }; + const classifyDataType = (node: any, seen: Set): boolean => { + if (['TSTypeAnnotation', 'TSTypeOperator'].includes(node.type)) return dataType(node.typeAnnotation, seen); if ( [ @@ -429,38 +372,32 @@ export const rule = defineRule({ ].includes(node.type) ) return true; - if (node.type === 'TSUnionType' || node.type === 'TSIntersectionType') + if (['TSUnionType', 'TSIntersectionType'].includes(node.type)) return node.types.every((t: any) => dataType(t, new Set(seen))); if (node.type === 'TSArrayType') return dataType(node.elementType, seen); if (node.type === 'TSTypeLiteral' || node.type === 'TSInterfaceDeclaration') { - const members = node.members ?? node.body.body; - return ( - members.every( - (m: any) => - m.type === 'TSPropertySignature' && dataType(m.typeAnnotation, new Set(seen)), - ) && - (node.extends ?? []).every((e: any) => - dataType({ type: 'TSTypeReference', typeName: e.expression }, new Set(seen)), - ) - ); + return dataMembers(node, seen); } if (node.type !== 'TSTypeReference' || node.typeName.type !== 'Identifier') return false; - // Resolve from the use's lexical scope, not a file-wide name map. - for ( - let scope: import('@oxlint/plugins').Scope | null = context.sourceCode.getScope( - node.typeName, - ); - scope; - scope = scope.upper - ) { - const variable = scope.set.get(node.typeName.name); - if (!variable) continue; - const declaration: any = variable.defs.find((d: any) => - ['TSTypeAliasDeclaration', 'TSInterfaceDeclaration'].includes(d.node.type), - )?.node; - return declaration ? dataType(declaration.typeAnnotation ?? declaration, seen) : false; - } - return false; + return dataReference(node.typeName, seen); + }; + const dataMembers = (node: any, seen: Set): boolean => { + const members = node.members ?? node.body.body; + return ( + members.every( + (m: any) => m.type === 'TSPropertySignature' && dataType(m.typeAnnotation, new Set(seen)), + ) && + (node.extends ?? []).every((e: any) => + dataType({ type: 'TSTypeReference', typeName: e.expression }, new Set(seen)), + ) + ); + }; + const dataReference = (name: AnyNode, seen: Set): boolean => { + const variable = lookupVariable(context, name); + const declaration: any = variable?.defs.find((d: any) => + ['TSTypeAliasDeclaration', 'TSInterfaceDeclaration'].includes(d.node.type), + )?.node; + return declaration ? dataType(declaration.typeAnnotation ?? declaration, seen) : false; }; const optionBagType = (params: readonly AnyNode[]): string | null => { @@ -495,6 +432,15 @@ export const rule = defineRule({ }); return; } + inspectOptionBag(fn, identity, hasBody, params, count); + }; + const inspectOptionBag = ( + fn: AnyNode, + identity: FactoryName, + hasBody: boolean, + params: readonly AnyNode[], + count: number, + ): void => { if (!options.flagOptionBags || !hasBody || count === 0) return; const bagType = optionBagType(params); if (bagType === null) return; diff --git a/app/tools/oxlint/effect-native/rules/prefer-effect-fn-for-operations.ts b/app/tools/oxlint/effect-native/rules/prefer-effect-fn-for-operations.ts index 6d14bc8a4..d7a948056 100644 --- a/app/tools/oxlint/effect-native/rules/prefer-effect-fn-for-operations.ts +++ b/app/tools/oxlint/effect-native/rules/prefer-effect-fn-for-operations.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * Audit A6/B4 (`docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`) asks for Effect.fn * on service operations and handlers. Named Effect.fn standardizes spans and definition/call-site @@ -20,18 +21,24 @@ import { defineRule } from '@oxlint/plugins'; import { fileURLToPath } from 'node:url'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree } from '@oxlint/plugins'; import { collectEffectBindings, effectMember } from '../shared/effect-imports.ts'; import type { EffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; - -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production `include` defaults instead of - * forcing the fixture config to pass loosened options (which `run-on-repo.mts` reuses verbatim). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; +import { matchesGlobs, isTestFile, normalisePath, rootedScopePath } from '../shared/paths.ts'; +import { stringArray } from '../shared/options.ts'; +import { + parentOf, + unwrapNode as unwrap, + memberName as sharedMemberName, + keyName as sharedKeyName, +} from '../shared/ast.ts'; +import { resolvesToImport as sharedResolvesToImport } from '../shared/bindings.ts'; +import { + collectRootNamespaces as sharedRootNamespaces, + collectNamedImports, + importDeclarations, +} from '../shared/imports.ts'; const DEFAULT_INCLUDE: readonly string[] = ['apps/**', 'verticals/**', 'packages/**']; @@ -67,18 +74,8 @@ interface RuleOptions { readonly reexportModules: readonly string[]; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); const minParams = typeof record.minParams === 'number' && Number.isInteger(record.minParams) ? record.minParams @@ -97,92 +94,16 @@ function readOptions(context: Context): RuleOptions { /** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ function scopePath(filename: string): string { - const unified = filename.replaceAll('\\', '/'); - const fixture = - /(?:^|\/)tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\/(.*)$/u.exec(unified); - if (fixture?.[1]) return fixture[1]; - const root = fileURLToPath(new URL('../../../../', import.meta.url)).replaceAll('\\', '/'); - return unified.startsWith(root) - ? unified.slice(root.length) - : normalisePath(unified).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -function parentOf(node: ESTree.Node): ESTree.Node | null { - return (node as { parent?: ESTree.Node | null }).parent ?? null; -} - -/** Strip wrappers that never change what an expression denotes. */ -function unwrap(node: ESTree.Node): ESTree.Node { - let current = node; - for (;;) { - if ( - current.type === 'ChainExpression' || - current.type === 'TSNonNullExpression' || - current.type === 'TSAsExpression' || - current.type === 'TSSatisfiesExpression' || - current.type === 'TSInstantiationExpression' || - current.type === 'ParenthesizedExpression' - ) { - const inner = (current as unknown as { expression?: ESTree.Node }).expression; - if (inner === undefined) return current; - current = inner; - continue; - } - if (current.type === 'TSTypeAssertion') { - const inner = (current as unknown as { expression?: ESTree.Node }).expression; - if (inner === undefined) return current; - current = inner; - continue; - } - return current; - } + return rootedScopePath(filename, fileURLToPath(new URL('../../../../', import.meta.url))); } -/** Non-computed `.gen`, or computed `["gen"]`. */ function memberName(node: ESTree.MemberExpression): string | null { if (!node.computed) return node.property.type === 'Identifier' ? node.property.name : null; - const property = unwrap(node.property); - if (property.type === 'Literal' && typeof property.value === 'string') return property.value; - if (property.type === 'TemplateLiteral' && property.expressions.length === 0) - return property.quasis[0]?.value.cooked ?? null; - return null; + return sharedMemberName(node, { templates: true, unwrap: {} }); } -function lookupVariable( - context: Context, - identifier: Extract, -): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(identifier); - while (scope !== null) { - const variable = scope.set.get(identifier.name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; -} - -/** - * `true` when the identifier still resolves to an `import` binding. Unresolved names fall back to - * `true` because the module-level import declaration already proved the binding exists; only a local - * shadow (parameter, `const`, catch clause, …) rejects the match. - */ -function resolvesToImport( - context: Context, - identifier: Extract, -): boolean { - const variable = lookupVariable(context, identifier); - if (variable === null || variable.defs.length === 0) return true; - return variable.defs.some( - (definition) => - definition.type === 'ImportBinding' && - definition.parent?.type === 'ImportDeclaration' && - definition.parent.importKind !== 'type' && - (definition.node.type !== 'ImportSpecifier' || definition.node.importKind !== 'type'), - ); +function resolvesToImport(context: Context, identifier: ESTree.Node): boolean { + return sharedResolvesToImport(context, identifier, true); } /** @@ -193,16 +114,10 @@ function collectRootNamespaces( program: ESTree.Program, reexportModules: readonly string[], ): ReadonlySet { - const locals = new Set(); - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - const source = statement.source.value; - if (source !== EFFECT_ROOT_MODULE && !reexportModules.includes(source)) continue; - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportNamespaceSpecifier') locals.add(specifier.local.name); - } - } - return locals; + return sharedRootNamespaces( + program, + (source) => source === EFFECT_ROOT_MODULE || reexportModules.includes(source), + ); } /** @@ -213,40 +128,22 @@ function collectReexportBindings( program: ESTree.Program, reexportModules: readonly string[], ): { readonly namespaces: ReadonlyMap; readonly found: boolean } { - const namespaces = new Map(); - let found = false; - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - if (!reexportModules.includes(statement.source.value)) continue; - found = true; - for (const specifier of statement.specifiers) { - if (specifier.type !== 'ImportSpecifier') continue; - const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; - namespaces.set(specifier.local.name, imported); - } - } - return { namespaces, found }; + const accepts = (source: string): boolean => reexportModules.includes(source); + return { + namespaces: collectNamedImports(program, accepts), + found: importDeclarations(program, accepts).length > 0, + }; } /** Locals bound by `import { gen as effectGen } from "effect/Effect"`. */ function collectDirectMemberImports(program: ESTree.Program, member: string): ReadonlySet { - const locals = new Set(); - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - if (!EFFECT_EFFECT_MODULE.test(statement.source.value)) continue; - for (const specifier of statement.specifiers) { - if (specifier.type !== 'ImportSpecifier') continue; - const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; - if (imported === member) locals.add(specifier.local.name); - } - } - return locals; + return new Set( + collectNamedImports( + program, + (source) => EFFECT_EFFECT_MODULE.test(source), + new Set([member]), + ).keys(), + ); } interface Resolver { @@ -271,17 +168,8 @@ function resolveNamespaceMember( const member = memberName(node); if (member === null) return null; const object = unwrap(node.object); - if (object.type === 'Identifier') { - if ( - resolver.rootNamespaces.has(object.name) && - member === PIPE_MEMBER && - resolvesToImport(context, object) - ) - return { namespace: 'Function', member }; - const namespace = resolver.bindings.namespaces.get(object.name); - if (namespace === undefined) return null; - return resolvesToImport(context, object) ? { namespace, member } : null; - } + if (object.type === 'Identifier') + return resolveIdentifierMember(object, member, context, resolver); // `E.Effect.gen` where `E` is `import * as E from "effect"`. if (object.type !== 'MemberExpression') return null; const namespace = memberName(object); @@ -291,6 +179,23 @@ function resolveNamespaceMember( return resolvesToImport(context, object.object) ? { namespace, member } : null; } +function resolveIdentifierMember( + object: Extract, + member: string, + context: Context, + resolver: Resolver, +): { namespace: string; member: string } | null { + if ( + resolver.rootNamespaces.has(object.name) && + member === PIPE_MEMBER && + resolvesToImport(context, object) + ) + return { namespace: 'Function', member }; + const namespace = resolver.bindings.namespaces.get(object.name); + if (namespace === undefined) return null; + return resolvesToImport(context, object) ? { namespace, member } : null; +} + /** Does this call expression denote `Effect.gen(...)`? */ function isEffectGenCall(node: ESTree.Node, context: Context, resolver: Resolver): boolean { if (node.type !== 'CallExpression') return false; @@ -314,67 +219,82 @@ function genAnchor(node: ESTree.CallExpression): ESTree.Node { * Peel `.pipe(...)` chains and `pipe(value, …)` calls so * `Effect.gen(...).pipe(Effect.withSpan('X'))` still reduces to the `Effect.gen` call. */ +const preserving = new Set([ + 'withSpan', + 'withLogSpan', + 'annotateLogs', + 'annotateSpans', + 'map', + 'flatMap', + 'tap', + 'tapError', + 'tapCause', + 'mapError', + 'catch', + 'catchTag', + 'catchTags', + 'catchCause', + 'provide', + 'provideService', + 'provideServiceEffect', + 'ensuring', + 'onExit', + 'scoped', + 'orDie', + 'retry', + 'timeout', + 'timeoutOrElse', + 'as', + 'asVoid', + 'exit', + 'result', + 'option', + 'withConcurrency', + 'withMinimumLogLevel', +]); + +function isDataFirstPipe(callee: ESTree.Node, context: Context, resolver: Resolver): boolean { + if (callee.type === 'Identifier') + return resolver.pipeLocals.has(callee.name) && resolvesToImport(context, callee); + if (callee.type !== 'MemberExpression') return false; + const matched = resolveNamespaceMember(callee, context, resolver); + return ( + matched !== null && matched.member === PIPE_MEMBER && PIPE_NAMESPACES.has(matched.namespace) + ); +} + +function isPreservingOperator( + argument: ESTree.Node, + context: Context, + resolver: Resolver, +): boolean { + let operator = unwrap(argument); + if (operator.type === 'CallExpression') operator = unwrap(operator.callee); + if (operator.type !== 'MemberExpression') return false; + const resolved = resolveNamespaceMember(operator, context, resolver); + return resolved?.namespace === EFFECT_NAMESPACE && preserving.has(resolved.member); +} + +function isPipeMethod(callee: ESTree.Node, dataFirst: boolean): boolean { + return callee.type === 'MemberExpression' && memberName(callee) === PIPE_MEMBER && !dataFirst; +} + function peelPipes(expression: ESTree.Node, context: Context, resolver: Resolver): ESTree.Node { let current = unwrap(expression); for (let guard = 0; guard < 64; guard += 1) { if (current.type !== 'CallExpression') return current; const callee = unwrap(current.callee); - const matched = - callee.type === 'MemberExpression' ? resolveNamespaceMember(callee, context, resolver) : null; - const dataFirst = - callee.type === 'Identifier' - ? resolver.pipeLocals.has(callee.name) && resolvesToImport(context, callee) - : matched !== null && - matched.member === PIPE_MEMBER && - PIPE_NAMESPACES.has(matched.namespace); - const method = - callee.type === 'MemberExpression' && memberName(callee) === PIPE_MEMBER && !dataFirst; + const dataFirst = isDataFirstPipe(callee, context, resolver); + const method = isPipeMethod(callee, dataFirst); if (!dataFirst && !method) return current; // A pipeline can leave Effect (runners, predicates, or arbitrary user functions). Only // peel syntactically known Effect-to-Effect operators, never assume a pipe preserves types. - const preserving = new Set([ - 'withSpan', - 'withLogSpan', - 'annotateLogs', - 'annotateSpans', - 'map', - 'flatMap', - 'tap', - 'tapError', - 'tapCause', - 'mapError', - 'catch', - 'catchTag', - 'catchTags', - 'catchCause', - 'provide', - 'provideService', - 'provideServiceEffect', - 'ensuring', - 'onExit', - 'scoped', - 'orDie', - 'retry', - 'timeout', - 'timeoutOrElse', - 'as', - 'asVoid', - 'exit', - 'result', - 'option', - 'withConcurrency', - 'withMinimumLogLevel', - ]); - for (const argument of current.arguments.slice(dataFirst ? 1 : 0)) { - let operator = unwrap(argument); - if (operator.type === 'CallExpression') operator = unwrap(operator.callee); - const resolved = - operator.type === 'MemberExpression' - ? resolveNamespaceMember(operator, context, resolver) - : null; - if (resolved?.namespace !== EFFECT_NAMESPACE || !preserving.has(resolved.member)) - return expression; - } + if ( + !current.arguments + .slice(dataFirst ? 1 : 0) + .every((argument) => isPreservingOperator(argument, context, resolver)) + ) + return expression; const next = dataFirst ? current.arguments[0] : (callee as ESTree.MemberExpression).object; if (next === undefined || next.type === 'SpreadElement') return current; current = unwrap(next); @@ -386,6 +306,19 @@ function peelPipes(expression: ESTree.Node, context: Context, resolver: Resolver * The single expression the function evaluates to, or `null` when the body does more than that. * Leading `const`/`let`/`var` declarations are tolerated when `allowLeadingConstants`. */ +function isAllowedLeadingStatement( + statement: ESTree.Node, + allowLeadingConstants: boolean, +): boolean { + if ( + new Set(['TSTypeAliasDeclaration', 'TSInterfaceDeclaration', 'TSDeclareFunction']).has( + statement.type, + ) + ) + return true; + return allowLeadingConstants && statement.type === 'VariableDeclaration'; +} + function soleReturnedExpression( fn: { readonly body?: ESTree.Node | null }, allowLeadingConstants: boolean, @@ -396,15 +329,12 @@ function soleReturnedExpression( const statements = body.body.filter((statement) => statement.type !== 'EmptyStatement'); const last = statements.at(-1); if (last === undefined || last.type !== 'ReturnStatement' || last.argument === null) return null; - for (const statement of statements.slice(0, -1)) { - if ( - statement.type === 'TSTypeAliasDeclaration' || - statement.type === 'TSInterfaceDeclaration' || - statement.type === 'TSDeclareFunction' - ) - continue; - if (!allowLeadingConstants || statement.type !== 'VariableDeclaration') return null; - } + if ( + !statements + .slice(0, -1) + .every((statement) => isAllowedLeadingStatement(statement, allowLeadingConstants)) + ) + return null; return last.argument; } @@ -463,34 +393,44 @@ function isExemptArgument( } function keyName(node: ESTree.Node | null | undefined, computed: boolean): string | null { - if (node === null || node === undefined) return null; - if (!computed && node.type === 'Identifier') return node.name; - if (node.type === 'Literal' && typeof node.value === 'string') return node.value; - return null; + return sharedKeyName(node, computed, { templates: false }); +} + +/** Name of an assignment's identifier or member target. */ +function assignmentName(left: ESTree.Node): string | null { + if (left.type === 'Identifier') return left.name; + return left.type === 'MemberExpression' ? memberName(left) : null; } -/** The declaration name attached to a node, if the node is being named by its parent. */ +const PROPERTY_CONTAINERS: ReadonlySet = new Set([ + 'Property', + 'PropertyDefinition', + 'MethodDefinition', + 'AccessorProperty', +]); + +function propertyContainerName(parent: ESTree.Node): string | null { + const property = parent as Extract; + return keyName(property.key, property.computed === true); +} + +function identifierName(node: ESTree.Node): string | null { + return node.type === 'Identifier' ? node.name : null; +} + +/** The declaration name attached to a node by its parent. */ function nameFromParent(parent: ESTree.Node): string | null { + if (PROPERTY_CONTAINERS.has(parent.type)) return propertyContainerName(parent); switch (parent.type) { case 'VariableDeclarator': - return parent.id.type === 'Identifier' ? parent.id.name : null; - case 'Property': - return keyName(parent.key as ESTree.Node, parent.computed === true); - case 'PropertyDefinition': - case 'MethodDefinition': - case 'AccessorProperty': - return keyName(parent.key as ESTree.Node, parent.computed === true); + return identifierName(parent.id); case 'ClassDeclaration': case 'ClassExpression': - return parent.id !== null && parent.id !== undefined ? parent.id.name : null; - case 'AssignmentExpression': { - const left = parent.left; - if (left.type === 'Identifier') return left.name; - if (left.type === 'MemberExpression') return memberName(left); - return null; - } + return parent.id?.name ?? null; + case 'AssignmentExpression': + return assignmentName(parent.left); case 'TSModuleDeclaration': - return parent.id.type === 'Identifier' ? parent.id.name : null; + return identifierName(parent.id); default: return null; } @@ -599,6 +539,20 @@ function parameterList(fn: ESTree.Node): string { return names.join(', '); } +function isIncludedPath(path: string, options: RuleOptions): boolean { + if (!matchesGlobs(path, options.include)) return false; + if ( + /\.d\.[cm]?ts$/u.test(path) || + /(?:^|\/)(?:dist(?:-[^/]+)?|build|\.output|node_modules)\//u.test(path) + ) + return false; + if (matchesGlobs(path, options.ignore)) return false; + if (!options.includeTests && isTestFile(path)) return false; + if (!options.includeScripts && /(?:^|\/)scripts\//u.test(path)) return false; + + return true; +} + /** A6/B4: service operations and handlers must be `Effect.fn`, not `arrow => Effect.gen`. */ export const rule = defineRule({ meta: { @@ -647,15 +601,7 @@ export const rule = defineRule({ create(context) { const options = readOptions(context); const path = scopePath(context.filename); - if (!matchesGlobs(path, options.include)) return {}; - if ( - /\.d\.[cm]?ts$/u.test(path) || - /(?:^|\/)(?:dist(?:-[^/]+)?|build|\.output|node_modules)\//u.test(path) - ) - return {}; - if (matchesGlobs(path, options.ignore)) return {}; - if (!options.includeTests && isTestFile(path)) return {}; - if (!options.includeScripts && /(?:^|\/)scripts\//u.test(path)) return {}; + if (!isIncludedPath(path, options)) return {}; const program = context.sourceCode.ast; const direct = collectEffectBindings(program); diff --git a/app/tools/oxlint/effect-native/rules/prefer-match-over-tag-switch.ts b/app/tools/oxlint/effect-native/rules/prefer-match-over-tag-switch.ts index edd95a204..c85b8784a 100644 --- a/app/tools/oxlint/effect-native/rules/prefer-match-over-tag-switch.ts +++ b/app/tools/oxlint/effect-native/rules/prefer-match-over-tag-switch.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * effect-native/prefer-match-over-tag-switch * @@ -80,14 +81,9 @@ import type { Context, ESTree } from '@oxlint/plugins'; import { collectEffectBindings, effectMember } from '../shared/effect-imports.ts'; import type { EffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; - -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production `include` defaults instead of - * forcing the fixture config to pass loosened options (which `run-on-repo.mts` reuses). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; +import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; +import { stringArray, positiveInteger } from '../shared/options.ts'; +import { unwrapNode } from '../shared/ast.ts'; const DEFAULT_INCLUDE: readonly string[] = ['apps/**', 'verticals/**', 'packages/**', 'scripts/**']; @@ -131,22 +127,8 @@ interface RuleOptions { readonly ignoreTests: boolean; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - -function positiveInteger(value: unknown, fallback: number): number { - return typeof value === 'number' && Number.isInteger(value) && value >= 1 ? value : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { tagProperties: stringArray(record.tagProperties, DEFAULT_TAG_PROPERTIES), discriminantProperties: stringArray( @@ -163,44 +145,9 @@ function readOptions(context: Context): RuleOptions { }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -/** Strip the syntax that never changes what a switch actually dispatches on. */ +/** Keep the original bounded, sequence-last discriminant policy. */ function unwrapExpression(node: ESTree.Node): ESTree.Node { - let current = node; - for (let depth = 0; depth < MAX_UNWRAP_DEPTH; depth += 1) { - switch (current.type) { - case 'SequenceExpression': { - // A comma expression evaluates to its last operand; preceding work must be retained - // by a manual migration, but it does not change the selected discriminant. - const last = current.expressions.at(-1); - if (last === undefined) return current; - current = last; - break; - } - case 'ChainExpression': - current = current.expression; - break; - case 'ParenthesizedExpression': - case 'TSNonNullExpression': - case 'TSAsExpression': - case 'TSSatisfiesExpression': - case 'TSTypeAssertion': - case 'TSInstantiationExpression': - current = current.expression; - break; - default: - return current; - } - } - return current; + return unwrapNode(node, { maxDepth: MAX_UNWRAP_DEPTH, sequence: true }); } /** Static string value of a `case` test: `'ready'` and `` `ready` `` both yield `"ready"`. */ @@ -314,38 +261,89 @@ function isExhaustiveHelperCall( return name !== null && options.exhaustiveHelpers.includes(name); } +function statementExpression(statement: ESTree.Node): ESTree.Node | null | undefined { + if (statement.type === 'ReturnStatement' || statement.type === 'ThrowStatement') + return statement.argument; + return statement.type === 'ExpressionStatement' ? statement.expression : null; +} + +function statementIsExhaustive( + statement: ESTree.Node, + options: RuleOptions, + bindings: EffectBindings, +): boolean { + if (statement.type === 'VariableDeclaration') { + return statement.declarations.some( + (declarator) => + declarator.id.type === 'Identifier' && isNeverAnnotation(declarator.id.typeAnnotation), + ); + } + const expression = statementExpression(statement); + if (expression === null || expression === undefined) return false; + if ( + (expression.type === 'TSAsExpression' || expression.type === 'TSSatisfiesExpression') && + isNeverAnnotation(expression.typeAnnotation) + ) + return true; + return isExhaustiveHelperCall(unwrapExpression(expression), options, bindings); +} + /** `true` when the `default:` branch proves exhaustiveness to the compiler. */ function hasExhaustiveGuard( node: ESTree.SwitchCase, options: RuleOptions, bindings: EffectBindings, ): boolean { - for (const statement of defaultStatements(node)) { - if (statement.type === 'VariableDeclaration') { - for (const declarator of statement.declarations) { - if (declarator.id.type === 'Identifier' && isNeverAnnotation(declarator.id.typeAnnotation)) - return true; - } + return defaultStatements(node).some((statement) => + statementIsExhaustive(statement, options, bindings), + ); +} + +function summarizeCases(cases: readonly ESTree.SwitchCase[]) { + const literals: string[] = []; + let everyCaseIsLiteral = true; + let testCount = 0; + let numericTestCount = 0; + let defaultCase: ESTree.SwitchCase | null = null; + for (const switchCase of cases) { + if (switchCase.test === null || switchCase.test === undefined) { + defaultCase = switchCase; continue; } - const expression = - statement.type === 'ReturnStatement' - ? statement.argument - : statement.type === 'ThrowStatement' - ? statement.argument - : statement.type === 'ExpressionStatement' - ? statement.expression - : null; - if (expression === null || expression === undefined) continue; - if ( - (expression.type === 'TSAsExpression' || expression.type === 'TSSatisfiesExpression') && - isNeverAnnotation(expression.typeAnnotation) - ) { - return true; - } - if (isExhaustiveHelperCall(unwrapExpression(expression), options, bindings)) return true; + testCount += 1; + if (isNumericTest(switchCase.test)) numericTestCount += 1; + const literal = staticStringTest(switchCase.test); + if (literal === null) everyCaseIsLiteral = false; + else literals.push(literal); } - return false; + return { + literals, + everyCaseIsLiteral, + defaultCase, + allTestsNumeric: testCount > 0 && numericTestCount === testCount, + }; +} + +function reportSwitch( + context: Context, + node: ESTree.SwitchStatement, + options: RuleOptions, + literals: readonly string[], + property: string | null, +): void { + const adtTag = literals.find((literal) => options.adtTags.includes(literal)); + const messageId = + adtTag !== undefined ? 'adtSwitch' : property !== null ? 'tagSwitch' : 'literalSwitch'; + context.report({ + node: node.discriminant, + messageId, + data: { + count: String(literals.length), + discriminant: describeDiscriminant(context, node.discriminant), + property: property ?? 'this vocabulary', + tag: adtTag ?? '', + }, + }); } export const rule = defineRule({ @@ -424,27 +422,10 @@ export const rule = defineRule({ const discriminant = unwrapExpression(node.discriminant); const candidate = discriminantProperty(discriminant, options); - const literals: string[] = []; - let everyCaseIsLiteral = true; - let testCount = 0; - let numericTestCount = 0; - let defaultCase: ESTree.SwitchCase | null = null; - for (const switchCase of node.cases) { - if (switchCase.test === null || switchCase.test === undefined) { - defaultCase = switchCase; - continue; - } - testCount += 1; - if (isNumericTest(switchCase.test)) numericTestCount += 1; - const literal = staticStringTest(switchCase.test); - if (literal === null) everyCaseIsLiteral = false; - else literals.push(literal); - } - - // An open numeric protocol space (HTTP status, `ts.SyntaxKind`) is D-tier, not a tagged - // union — so a `discriminantProperties` name only reports once the case tests show it is - // not one. `_tag` is exempt: Effect discriminators are strings. - const allTestsNumeric = testCount > 0 && numericTestCount === testCount; + const { literals, everyCaseIsLiteral, defaultCase, allTestsNumeric } = summarizeCases( + node.cases, + ); + // Numeric protocol spaces are allowed; Effect tag properties remain string discriminators. const property = candidate !== null && (candidate.kind === 'tag' || !allTestsNumeric) ? candidate.name @@ -461,19 +442,7 @@ export const rule = defineRule({ return; } - const adtTag = literals.find((literal) => options.adtTags.includes(literal)); - const messageId = - adtTag !== undefined ? 'adtSwitch' : property !== null ? 'tagSwitch' : 'literalSwitch'; - context.report({ - node: node.discriminant, - messageId, - data: { - count: String(literals.length), - discriminant: describeDiscriminant(context, node.discriminant), - property: property ?? 'this vocabulary', - tag: adtTag ?? '', - }, - }); + reportSwitch(context, node, options, literals, property); }, }; }, diff --git a/app/tools/oxlint/effect-native/rules/require-concurrency-option.ts b/app/tools/oxlint/effect-native/rules/require-concurrency-option.ts index f6f29aed7..cd6454873 100644 --- a/app/tools/oxlint/effect-native/rules/require-concurrency-option.ts +++ b/app/tools/oxlint/effect-native/rules/require-concurrency-option.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * effect-native/require-concurrency-option * @@ -66,19 +67,26 @@ import { defineRule } from '@oxlint/plugins'; import type { Context, ESTree } from '@oxlint/plugins'; import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; -import { globToRegExp, isScriptFile, isTestFile, normalisePath } from '../shared/paths.ts'; +import { isScriptFile, isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; +import { + skipWrappers, + staticString, + unwrapNode, + memberName as staticMemberName, +} from '../shared/ast.ts'; +import { bindingPath } from '../shared/effect-identity.ts'; +import { stringArray, positiveInteger } from '../shared/options.ts'; +import { + collectRootNamespaces, + collectDirectMemberImports, + collectNamedImports, + importDeclarations, +} from '../shared/imports.ts'; const EFFECT_ROOT_MODULE = 'effect'; /** `effect/Effect`, `effect/Stream`, and any nested re-export path ending in those names. */ const EFFECT_SUBMODULE = /^effect\/(?:.*\/)?(Effect|Stream)$/u; -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production defaults instead of forcing the - * fixture config to loosen options (which `run-on-repo.mts` reuses against the real repo). - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; - /** B1 is about production workers, reads and route loaders. */ const DEFAULT_INCLUDE: readonly string[] = ['apps/**', 'verticals/**', 'packages/**']; const DEFAULT_IGNORE: readonly string[] = []; @@ -170,25 +178,11 @@ interface RuleOptions { readonly streamMembers: ReadonlySet; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; - const minItems = - typeof record.minItems === 'number' && Number.isInteger(record.minItems) - ? record.minItems - : DEFAULT_MIN_ITEMS; + const record = optionRecord(context.options?.[0]); return { allowUnbounded: record.allowUnbounded === true, - minItems: minItems < 0 ? DEFAULT_MIN_ITEMS : minItems, + minItems: positiveInteger(record.minItems, DEFAULT_MIN_ITEMS, 0), strictSpread: record.strictSpread === true, includeTests: record.includeTests === true, includeScripts: record.includeScripts === true, @@ -199,64 +193,21 @@ function readOptions(context: Context): RuleOptions { }; } -/** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -/** Top-level `scripts/` (via the shared helper) plus package-local `scripts/` directories. */ -function isScriptPath(path: string): boolean { - return isScriptFile(path) || /(?:^|\/)scripts\//u.test(path); -} +const CALL_WRAPPERS: ReadonlySet = new Set([ + 'ChainExpression', + 'TSNonNullExpression', + 'TSAsExpression', + 'TSSatisfiesExpression', + 'TSInstantiationExpression', + 'ParenthesizedExpression', +]); -/** Strip the wrappers that sit between an expression and its semantic value. */ function unwrap(node: ESTree.Node): ESTree.Node { - let current = node; - while ( - current.type === 'ChainExpression' || - current.type === 'TSNonNullExpression' || - current.type === 'TSAsExpression' || - current.type === 'TSSatisfiesExpression' || - current.type === 'TSInstantiationExpression' || - current.type === 'ParenthesizedExpression' - ) { - const inner: ESTree.Node | undefined = (current as unknown as { expression?: ESTree.Node }) - .expression; - if (inner === undefined) return current; - current = inner; - } - return current; + return unwrapNode(node, { wrappers: CALL_WRAPPERS }); } -/** Non-computed `.member`, or computed `["member"]`. */ function memberName(node: ESTree.MemberExpression): string | null { - if (!node.computed) return node.property.type === 'Identifier' ? node.property.name : null; - const property = node.property; - return staticString(property); -} - -/** - * Locals bound by `import * as EFX from "effect"` (or from a re-export barrel) — `EFX.Effect.forEach` - * must still be caught. - */ -function collectRootNamespaces( - program: ESTree.Program, - reexportModules: readonly string[], -): ReadonlySet { - const locals = new Set(); - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - const source = statement.source.value; - if (source !== EFFECT_ROOT_MODULE && !matchesGlobs(source, reexportModules)) continue; - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportNamespaceSpecifier') locals.add(specifier.local.name); - } - } - return locals; + return staticMemberName(node, { templates: true }); } /** @@ -268,64 +219,18 @@ function collectBindings( reexportModules: readonly string[], ): EffectBindings { const shared = collectEffectBindings(program); - const namespaces = new Map(shared.namespaces); - let importsEffect = shared.importsEffect; - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - if (!matchesGlobs(statement.source.value, reexportModules)) continue; - importsEffect = true; - for (const specifier of statement.specifiers) { - if (specifier.type !== 'ImportSpecifier') continue; - const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; - namespaces.set(specifier.local.name, imported); - } - } - return { namespaces, importsEffect }; -} - -/** Locals bound by `import { forEach as each } from "effect/Effect"` — bare calls must be caught. */ -function collectDirectMemberImports( - program: ESTree.Program, -): ReadonlyMap { - const locals = new Map(); - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - const namespace = EFFECT_SUBMODULE.exec(statement.source.value)?.[1]; - if (namespace === undefined) continue; - for (const specifier of statement.specifiers) { - if (specifier.type !== 'ImportSpecifier') continue; - const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; - locals.set(specifier.local.name, { namespace, member: imported }); - } - } - return locals; + const accepts = (source: string) => matchesGlobs(source, reexportModules); + const namespaces = new Map([...shared.namespaces, ...collectNamedImports(program, accepts)]); + return { + namespaces, + importsEffect: shared.importsEffect || importDeclarations(program, accepts).length > 0, + }; } /** A curried application `f(...)(...)`, or an operator slot in `pipe(subject, …)` / `subject.pipe(…)`. */ function isDataLastPosition(call: ESTree.CallExpression, context: Context): boolean { - let current: ESTree.Node = call; - let parent: ESTree.Node | null | undefined = call.parent; - // Skip the wrappers that a `as`/`!`/parenthesis introduces between the call and its parent. - while ( - parent !== null && - parent !== undefined && - (parent.type === 'ChainExpression' || - parent.type === 'TSNonNullExpression' || - parent.type === 'TSAsExpression' || - parent.type === 'TSSatisfiesExpression' || - parent.type === 'TSInstantiationExpression' || - parent.type === 'ParenthesizedExpression') - ) { - current = parent; - parent = parent.parent; - } - if (parent === null || parent === undefined || parent.type !== 'CallExpression') return false; + const { node: current, parent } = skipWrappers(call, CALL_WRAPPERS); + if (parent?.type !== 'CallExpression') return false; if (unwrap(parent.callee) === current) return true; // `Effect.forEach(f)(xs)` const index = parent.arguments.indexOf(current as ESTree.Argument); if (index === -1) return false; @@ -386,120 +291,67 @@ function inspectOptions(argument: ESTree.Node | undefined, options: RuleOptions) continue; } if (propertyName(property) !== CONCURRENCY_KEY) continue; - const setting = unwrap(property.value); - const literal = staticString(setting); - if (literal !== null && UNBOUNDED_VALUES.has(literal)) { - return options.allowUnbounded ? OK : { kind: 'unbounded', value: literal }; - } - return OK; + return inspectConcurrency(property.value, options.allowUnbounded); } if (sawSpread && !options.strictSpread) return UNKNOWN; return MISSING; } -/** B1: every fan-out must state its concurrency policy — bounded, or deliberately `1`. */ -// Resolve lexical value bindings, not identifier spellings. Only immutable local aliases are -// followed; arbitrary object mutation, re-export contents and dynamic keys need type/data-flow analysis. -function lexicalVariable(context: Context, node: Extract) { - let scope: import('@oxlint/plugins').Scope | null = context.sourceCode.getScope(node); - while (scope !== null) { - const variable = scope.set.get(node.name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; +function inspectConcurrency(value: ESTree.Node, allowUnbounded: boolean): Verdict { + const literal = staticString(unwrap(value)); + if (literal !== null && UNBOUNDED_VALUES.has(literal) && !allowUnbounded) + return { kind: 'unbounded', value: literal }; + return OK; } -function staticString(node: ESTree.Node): string | null { - if (node.type === 'Literal' && typeof node.value === 'string') return node.value; - if (node.type === 'TemplateLiteral' && node.expressions.length === 0) - return node.quasis[0]?.value.cooked ?? null; - return null; + +function memberShape( + namespace: string, + member: string, + options: RuleOptions, +): MemberShape | undefined { + if (namespace === 'Effect') return EFFECT_MEMBERS.get(member); + if (namespace === 'Stream' && options.streamMembers.has(member)) return STREAM; + return undefined; } -function identityUnwrap(node: ESTree.Node): ESTree.Node { - let current = node; - for (;;) { - if (current.type === 'SequenceExpression') { - const last = current.expressions.at(-1); - if (last === undefined) return current; - current = last; - } else if ( - [ - 'ChainExpression', - 'ParenthesizedExpression', - 'TSAsExpression', - 'TSTypeAssertion', - 'TSNonNullExpression', - 'TSSatisfiesExpression', - 'TSInstantiationExpression', - ].includes(current.type) - ) { - current = (current as unknown as { expression: ESTree.Node }).expression; - } else return current; - } + +function hasDataLastArguments(args: readonly ESTree.Argument[], shape: MemberShape): boolean { + if (!shape.callbackSecond) return false; + if (args.length === 1) return true; + if (args[0] !== undefined && isFunctionLike(unwrap(args[0]))) return true; + return args.length === 2 && unwrap(args[1]!).type === 'ObjectExpression'; } -function bindingPath( + +function isSmallCollection( + args: readonly ESTree.Argument[], + shape: MemberShape, + dataLast: boolean, + minItems: number, +): boolean { + if (dataLast || shape.collection === -1) return false; + const length = literalLength(args[shape.collection]); + return length !== null && length < minItems; +} + +function reportVerdict( context: Context, - expression: ESTree.Node, - extraModules: readonly string[] = [], - seen = new Set(), -): readonly string[] | null { - const node = identityUnwrap(expression); - if (node.type === 'MemberExpression') { - const key = - !node.computed && node.property.type === 'Identifier' - ? node.property.name - : staticString(node.property); - const root = bindingPath(context, node.object, extraModules, seen); - return root !== null && key !== null ? [...root, key] : null; + node: ESTree.Node, + callee: { readonly namespace: string; readonly member: string }, + verdict: Verdict, +): void { + if (verdict.kind === 'ok' || verdict.kind === 'unknown') return; + if (verdict.kind === 'missing') { + context.report({ + node, + messageId: 'missingConcurrency', + data: { namespace: callee.namespace, member: callee.member }, + }); + return; } - if (node.type !== 'Identifier') return null; - const variable = lexicalVariable(context, node); - if (variable === null || seen.has(variable)) return null; - seen.add(variable); - if (variable.defs.length !== 1) return null; - const definition = variable.defs[0]; - if (definition === undefined) return null; - if (definition.type === 'ImportBinding') { - const specifier = definition.node as - | ESTree.ImportSpecifier - | ESTree.ImportNamespaceSpecifier - | ESTree.ImportDefaultSpecifier; - const declaration = definition.parent as ESTree.ImportDeclaration; - if (declaration?.type !== 'ImportDeclaration' || declaration.importKind === 'type') return null; - if (specifier.type === 'ImportSpecifier' && specifier.importKind === 'type') return null; - const source = declaration.source.value; - if (source !== 'effect' && !source.startsWith('effect/') && !extraModules.includes(source)) - return null; - const last = source.split('/').at(-1) ?? ''; - const base = source.startsWith('effect/') && /^[A-Z]/u.test(last) ? [last] : []; - if (specifier.type === 'ImportNamespaceSpecifier') return base; - if (specifier.type !== 'ImportSpecifier') return null; - const imported = - specifier.imported.type === 'Identifier' ? specifier.imported.name : specifier.imported.value; - return [...base, imported]; - } - if (definition.type !== 'Variable') return null; - const declaration = definition.node as ESTree.VariableDeclarator; - const parent = definition.parent as ESTree.VariableDeclaration; - if (parent?.kind !== 'const' || declaration.init === null) return null; - const base = bindingPath(context, declaration.init, extraModules, seen); - if (base === null) return null; - if (declaration.id.type === 'Identifier') return base; - if (declaration.id.type !== 'ObjectPattern') return null; - for (const property of declaration.id.properties) { - if ( - property.type === 'RestElement' || - property.value.type !== 'Identifier' || - property.value.name !== node.name - ) - continue; - const key = - !property.computed && property.key.type === 'Identifier' - ? property.key.name - : staticString(property.key); - return key === null ? null : [...base, key]; - } - return null; + context.report({ + node, + messageId: 'unboundedConcurrency', + data: { namespace: callee.namespace, member: callee.member, value: verdict.value }, + }); } export const rule = defineRule({ @@ -569,14 +421,23 @@ export const rule = defineRule({ const path = scopePath(context.filename); if (matchesGlobs(path, resolved.ignore)) return false; if (!resolved.includeTests && isTestFile(path)) return false; - const script = isScriptPath(path); + const script = isScriptFile(path); if (script && !resolved.includeScripts) return false; if (!script && !matchesGlobs(path, resolved.include)) return false; const program = context.sourceCode.ast; bindings = collectBindings(program, resolved.reexportModules); - rootNamespaces = collectRootNamespaces(program, resolved.reexportModules); - directMembers = collectDirectMemberImports(program); + rootNamespaces = collectRootNamespaces( + program, + (source) => + source === EFFECT_ROOT_MODULE || matchesGlobs(source, resolved.reexportModules), + ); + directMembers = collectDirectMemberImports( + program, + undefined, + {}, + (source) => EFFECT_SUBMODULE.exec(source)?.[1] ?? null, + ); return bindings.importsEffect || rootNamespaces.size > 0 || directMembers.size > 0; }, after() { @@ -593,42 +454,17 @@ export const rule = defineRule({ if (identity?.length !== 2) return; const callee = { namespace: identity[0], member: identity[1] }; - let shape: MemberShape | undefined; - if (callee.namespace === 'Effect') shape = EFFECT_MEMBERS.get(callee.member); - else if (callee.namespace === 'Stream' && resolved.streamMembers.has(callee.member)) - shape = STREAM; + const shape = memberShape(callee.namespace, callee.member, resolved); if (shape === undefined) return; const args = node.arguments; - const dataLast = - isDataLastPosition(node, context) || - (shape.callbackSecond && - (args.length === 1 || - (args[0] !== undefined && isFunctionLike(unwrap(args[0]))) || - (args.length === 2 && unwrap(args[1]!).type === 'ObjectExpression'))); + const dataLast = isDataLastPosition(node, context) || hasDataLastArguments(args, shape); const optionsIndex = dataLast ? shape.dataLastOptions : shape.dataFirstOptions; - - // A literal collection shorter than `minItems` is not a fan-out at all. - if (!dataLast && shape.collection !== -1) { - const length = literalLength(args[shape.collection]); - if (length !== null && length < resolved.minItems) return; - } + // A literal collection shorter than minItems is not a fan-out. + if (isSmallCollection(args, shape, dataLast, resolved.minItems)) return; const verdict = inspectOptions(args[optionsIndex], resolved); - if (verdict.kind === 'ok' || verdict.kind === 'unknown') return; - if (verdict.kind === 'missing') { - context.report({ - node: node.callee, - messageId: 'missingConcurrency', - data: { namespace: callee.namespace, member: callee.member }, - }); - return; - } - context.report({ - node: node.callee, - messageId: 'unboundedConcurrency', - data: { namespace: callee.namespace, member: callee.member, value: verdict.value }, - }); + reportVerdict(context, node.callee, callee, verdict); }, }; }, diff --git a/app/tools/oxlint/effect-native/rules/require-context-service-for-service-interface.ts b/app/tools/oxlint/effect-native/rules/require-context-service-for-service-interface.ts index 86df005e6..183409bb8 100644 --- a/app/tools/oxlint/effect-native/rules/require-context-service-for-service-interface.ts +++ b/app/tools/oxlint/effect-native/rules/require-context-service-for-service-interface.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * effect-native/require-context-service-for-service-interface * @@ -16,10 +17,15 @@ import { defineRule } from '@oxlint/plugins'; import type { Context, ESTree } from '@oxlint/plugins'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; - -/** Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; +import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; +import { + booleanOption as boolean, + stringArray, + stringOption, + safeRegExp, +} from '../shared/options.ts'; +import { typeNameSegments } from '../shared/ast.ts'; +import { resolveVariable } from '../shared/bindings.ts'; const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**']; const DEFAULT_IGNORE = [ @@ -77,26 +83,8 @@ interface RuleOptions { readonly allowNames: readonly string[]; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - -function boolean(value: unknown, fallback: boolean): boolean { - return typeof value === 'boolean' ? value : fallback; -} - -function text(value: unknown, fallback: string): string { - return typeof value === 'string' && value.length > 0 ? value : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { include: stringArray(record.include, DEFAULT_INCLUDE), ignore: stringArray(record.ignore, DEFAULT_IGNORE), @@ -107,8 +95,12 @@ function readOptions(context: Context): RuleOptions { includePromiseMembers: boolean(record.includePromiseMembers, true), allowLayerConstruction: boolean(record.allowLayerConstruction, true), requireTagPerContract: boolean(record.requireTagPerContract, true), - serviceNamePattern: text(record.serviceNamePattern, DEFAULT_SERVICE_NAME_PATTERN), - dataTypePattern: text(record.dataTypePattern, DEFAULT_DATA_TYPE_PATTERN), + serviceNamePattern: stringOption( + record.serviceNamePattern, + DEFAULT_SERVICE_NAME_PATTERN, + false, + ), + dataTypePattern: stringOption(record.dataTypePattern, DEFAULT_DATA_TYPE_PATTERN, false), effectTypes: stringArray(record.effectTypes, DEFAULT_EFFECT_TYPES), promiseTypes: stringArray(record.promiseTypes, DEFAULT_PROMISE_TYPES), tagMembers: stringArray(record.tagMembers, DEFAULT_TAG_MEMBERS), @@ -118,32 +110,6 @@ function readOptions(context: Context): RuleOptions { }; } -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - -function safeRegExp(pattern: string, fallback: string): RegExp { - try { - return new RegExp(pattern, 'u'); - } catch { - return new RegExp(fallback, 'u'); - } -} - -/** Flatten `Effect.Effect` / `Promise` type names into their dotted segments. */ -function typeNameSegments(name: ESTree.TSTypeName): readonly string[] | null { - if (name.type === 'Identifier') return [name.name]; - if (name.type === 'TSQualifiedName') { - const left = typeNameSegments(name.left); - return left === null ? null : [...left, name.right.name]; - } - return null; -} - function unwrapType(type: ESTree.TSType): ESTree.TSType { let current = type; while (current.type === 'TSParenthesizedType') current = current.typeAnnotation; @@ -160,42 +126,51 @@ interface Candidate { readonly factory: ESTree.Node | null; } -/** - * The type arguments of the call expressions wrapping a tag callee (`Context.Service()(…)`), - * plus the name the construction is bound to — the self reference (`class X extends - * Context.Service`) names the tag, not the contract, so it must not count as wiring. - */ -function tagConstructionInfo(callee: AnyNode): { - readonly typeArgs: readonly unknown[]; - readonly owner: string | null; -} { +/** Type arguments from the two call layers surrounding a tag callee. */ +function tagConstructionArguments(callee: AnyNode): readonly unknown[] { const typeArgs: unknown[] = []; - let current: AnyNode | null = callee; - let outermost: AnyNode = callee; - // Two hops covers `Context.Service()('id')` and `Context.GenericTag('id')`. - for (let hop = 0; hop < 2 && current !== null; hop += 1) { - const parent: AnyNode | null = (current.parent as AnyNode | null | undefined) ?? null; - if (parent === null) break; - if (parent.type !== 'CallExpression' && parent.type !== 'NewExpression') break; + let current: AnyNode = callee; + for (let hop = 0; hop < 2; hop += 1) { + const parent = current.parent; + if (!parent || !['CallExpression', 'NewExpression'].includes(parent.type)) break; const args = (parent as { readonly typeArguments?: unknown }).typeArguments; - if (args !== null && args !== undefined) typeArgs.push(args); + if (args != null) typeArgs.push(args); current = parent; - outermost = parent; } - const holder = (outermost.parent as AnyNode | null | undefined) ?? null; - let owner: string | null = null; - if (holder !== null) { - if ( - (holder.type === 'ClassDeclaration' || holder.type === 'ClassExpression') && - holder.id !== null && - holder.id !== undefined - ) { - owner = holder.id.name; - } else if (holder.type === 'VariableDeclarator' && holder.id.type === 'Identifier') { - owner = holder.id.name; - } + return typeArgs; +} + +function firstResult(values: readonly T[], visit: (value: T) => string | null): string | null { + for (const value of values) { + const found = visit(value); + if (found !== null) return found; } - return { typeArgs, owner }; + return null; +} + +function immutableVariable(def: any, variable: any): boolean { + return ( + def.type === 'Variable' && + def.parent?.kind === 'const' && + !variable.references.some((reference: any) => reference.isWrite() && !reference.init) + ); +} + +function importDefinitionPath(def: any): string | null { + const source = def.parent?.source?.value; + if (!/^effect(?:\/|$)/u.test(source ?? '')) return null; + const name = def.node.imported?.name ?? def.node.imported?.value; + if (source === 'effect') return name ?? 'root'; + return `${source.split('/').at(-1)}${name ? `.${name}` : ''}`; +} + +function importedMemberKey(node: any): unknown { + const property = node.property ?? node.right; + if (!node.computed) return property.name; + if (property.type === 'Literal') return property.value; + if (property.type === 'TemplateLiteral' && !property.expressions.length) + return property.quasis[0]?.value.cooked; + return null; } export const rule = defineRule({ @@ -267,17 +242,7 @@ export const rule = defineRule({ if (!options.includeTsx && TSX_FILE.test(path)) return {}; const program = context.sourceCode.ast; - const variableFor = (node: any, name: string): any => { - for ( - let scope: import('@oxlint/plugins').Scope | null = context.sourceCode.getScope(node); - scope; - scope = scope.upper - ) { - const variable = scope.set.get(name); - if (variable) return variable; - } - return null; - }; + const variableFor = (node: any, name: string): any => resolveVariable(context, name, node); const localAlias = (node: any): any => node?.type === 'Identifier' ? variableFor(node, node.name)?.defs.find( @@ -298,44 +263,34 @@ export const rule = defineRule({ ) return imported(node.expression, seen); if (node.type === 'MemberExpression' || node.type === 'TSQualifiedName') { - const object = imported(node.object ?? node.left, seen); - const p = node.property ?? node.right; - const key = !node.computed - ? p.name - : p.type === 'Literal' - ? p.value - : p.type === 'TemplateLiteral' && !p.expressions.length - ? p.quasis[0]?.value.cooked - : null; - return object && typeof key === 'string' ? `${object}.${key}` : null; + return importedMember(node, seen); } if (node.type !== 'Identifier') return null; + return importedIdentifier(node, seen); + }; + const importedMember = (node: any, seen: Set): string | null => { + const object = imported(node.object ?? node.left, seen); + const key = importedMemberKey(node); + return object && typeof key === 'string' ? `${object}.${key}` : null; + }; + const importedIdentifier = (node: any, seen: Set): string | null => { const variable = variableFor(node, node.name); for (const def of variable?.defs ?? []) { - if (def.type === 'ImportBinding') { - const source = def.parent?.source?.value; - if (!/^effect(?:\/|$)/u.test(source ?? '')) return null; - const name = def.node.imported?.name ?? def.node.imported?.value; - return source === 'effect' - ? (name ?? 'root') - : `${source.split('/').at(-1)}${name ? `.${name}` : ''}`; - } - if ( - def.type === 'Variable' && - def.parent?.kind === 'const' && - !variable.references.some((r: any) => r.isWrite() && !r.init) - ) - return imported(def.node.init, seen); + if (def.type === 'ImportBinding') return importDefinitionPath(def); + if (immutableVariable(def, variable)) return imported(def.node.init, seen); } return null; }; const separateExports = new Set(); - for (const statement of program.body) - if (statement.type === 'ExportNamedDeclaration' && !statement.source) { - for (const spec of statement.specifiers) - if (spec.local.type === 'Identifier') - separateExports.add(variableFor(spec.local, spec.local.name)); - } + const collectSeparateExports = (): void => { + for (const statement of program.body) + if (statement.type === 'ExportNamedDeclaration' && !statement.source) { + for (const spec of statement.specifiers) + if (spec.local.type === 'Identifier') + separateExports.add(variableFor(spec.local, spec.local.name)); + } + }; + collectSeparateExports(); const servicePattern = safeRegExp(options.serviceNamePattern, DEFAULT_SERVICE_NAME_PATTERN); const dataPattern = safeRegExp(options.dataTypePattern, DEFAULT_DATA_TYPE_PATTERN); const promiseTypes = new Set(options.promiseTypes); @@ -359,13 +314,16 @@ export const rule = defineRule({ value.forEach((child) => collectContracts(child, depth + 1)); return; } + collectContractReferences(value); + for (const [key, child] of Object.entries(value)) + if (key !== 'parent') collectContracts(child, depth + 1); + }; + const collectContractReferences = (value: any): void => { if (value.type === 'TSTypeReference') for (const declaration of declarationsFor(value.typeName)) taggedDeclarations.add(declaration); if (value.type === 'TSTypeQuery') for (const declaration of declarationsFor(value.exprName)) taggedFactories.add(declaration); - for (const [key, child] of Object.entries(value)) - if (key !== 'parent') collectContracts(child, depth + 1); }; // ------------------------------------------------------------------ effect / promise types @@ -392,16 +350,11 @@ export const rule = defineRule({ /** Any effectful type reference anywhere inside a *return type* subtree (unions, arrays, generics). */ const returnTypeIsEffectful = (node: unknown, depth: number): string | null => { if (depth > MAX_TYPE_DEPTH || node === null || typeof node !== 'object') return null; - if (Array.isArray(node)) { - for (const entry of node) { - const found = returnTypeIsEffectful(entry, depth + 1); - if (found !== null) return found; - } - return null; - } + if (Array.isArray(node)) + return firstResult(node, (entry) => returnTypeIsEffectful(entry, depth + 1)); const record = node as Record; if (typeof record.type !== 'string') return null; - if (record.type === 'TSFunctionType' || record.type === 'TSConstructorType') + if (['TSFunctionType', 'TSConstructorType'].includes(record.type)) return returnTypeIsEffectful(record.returnType, depth + 1); if (record.type === 'TSTypeReference') { const found = effectfulReference(node as ESTree.TSTypeReference); @@ -409,33 +362,26 @@ export const rule = defineRule({ const alias = localAlias(record.typeName); if (alias) return returnTypeIsEffectful(alias, depth + 1); } - for (const [key, value] of Object.entries(record)) { - // `parent` back-references would make this walk cyclic. - if (key === 'parent' || key === 'type' || value === null || typeof value !== 'object') - continue; - const found = returnTypeIsEffectful(value, depth + 1); - if (found !== null) return found; - } - return null; + return returnChildrenAreEffectful(record, depth); }; + const returnChildrenAreEffectful = ( + record: Record, + depth: number, + ): string | null => + firstResult(Object.entries(record), ([key, value]) => { + if (key === 'parent' || key === 'type' || value === null || typeof value !== 'object') + return null; + return returnTypeIsEffectful(value, depth + 1); + }); /** Effectfulness of a *member annotation*: function types are judged by their return type only. */ const annotationIsEffectful = (type: ESTree.TSType, depth: number): string | null => { if (depth > MAX_TYPE_DEPTH) return null; const current = unwrapType(type); - if (current.type === 'TSFunctionType' || current.type === 'TSConstructorType') { - const returnType = (current as { readonly returnType?: ESTree.TSTypeAnnotation | null }) - .returnType; - return returnType === null || returnType === undefined - ? null - : returnTypeIsEffectful(returnType.typeAnnotation, 0); - } + if (['TSFunctionType', 'TSConstructorType'].includes(current.type)) + return signatureEffect(current); if (current.type === 'TSUnionType' || current.type === 'TSIntersectionType') { - for (const member of current.types) { - const found = annotationIsEffectful(member, depth + 1); - if (found !== null) return found; - } - return null; + return firstResult(current.types, (member) => annotationIsEffectful(member, depth + 1)); } if (current.type === 'TSTypeLiteral') return firstEffectfulMember(current.members, depth + 1); if (current.type === 'TSTypeReference') { @@ -458,31 +404,26 @@ export const rule = defineRule({ /** The first effectful member of an interface body / type literal, described for the message. */ const firstEffectfulMember = (members: readonly ESTree.Node[], depth = 0): string | null => { if (depth > MAX_TYPE_DEPTH) return null; - for (const member of members) { - if (member.type === 'TSMethodSignature') { - const returnType = (member as ESTree.TSMethodSignature).returnType; - if (returnType === null || returnType === undefined) continue; - const wrapper = returnTypeIsEffectful(returnType.typeAnnotation, 0); - if (wrapper !== null) return `${memberKeyName(member as never)}(): ${wrapper}`; - continue; - } - if (member.type === 'TSPropertySignature' || member.type === 'TSIndexSignature') { - const annotation = (member as ESTree.TSPropertySignature).typeAnnotation; - if (annotation === null || annotation === undefined) continue; - const wrapper = annotationIsEffectful(annotation.typeAnnotation, depth + 1); - if (wrapper !== null) return `${memberKeyName(member as never)}: ${wrapper}`; - continue; - } - if ( - member.type === 'TSCallSignatureDeclaration' || - member.type === 'TSConstructSignatureDeclaration' - ) { - const returnType = (member as { readonly returnType?: ESTree.TSTypeAnnotation | null }) - .returnType; - if (returnType === null || returnType === undefined) continue; - const wrapper = returnTypeIsEffectful(returnType.typeAnnotation, 0); - if (wrapper !== null) return `the call signature returning ${wrapper}`; - } + return firstResult(members, (member) => effectfulMember(member, depth)); + }; + const signatureEffect = (member: any): string | null => { + const annotation = member.returnType; + return annotation == null ? null : returnTypeIsEffectful(annotation.typeAnnotation, 0); + }; + const effectfulMember = (member: ESTree.Node, depth: number): string | null => { + if (member.type === 'TSMethodSignature') { + const wrapper = signatureEffect(member); + return wrapper === null ? null : `${memberKeyName(member as never)}(): ${wrapper}`; + } + if (member.type === 'TSPropertySignature' || member.type === 'TSIndexSignature') { + const annotation = (member as ESTree.TSPropertySignature).typeAnnotation; + if (annotation == null) return null; + const wrapper = annotationIsEffectful(annotation.typeAnnotation, depth + 1); + return wrapper === null ? null : `${memberKeyName(member as never)}: ${wrapper}`; + } + if (['TSCallSignatureDeclaration', 'TSConstructSignatureDeclaration'].includes(member.type)) { + const wrapper = signatureEffect(member); + return wrapper === null ? null : `the call signature returning ${wrapper}`; } return null; }; @@ -507,28 +448,32 @@ export const rule = defineRule({ ? name.slice(0, -'Service'.length) : `${name}Tag`; + const isUtilityReference = (name: ESTree.TSTypeName): boolean => + name.type === 'Identifier' && + ['Awaited', 'Readonly', 'NonNullable'].includes(name.name) && + !variableFor(name, name.name)?.defs.length; + + const isReturnTypeReference = (name: ESTree.TSTypeName): boolean => { + const segments = typeNameSegments(name); + return ( + segments !== null && + segments.length === 1 && + segments[0] === 'ReturnType' && + !variableFor(name, 'ReturnType')?.defs.length + ); + }; + /** `ReturnType` — a factory-derived service contract. */ const returnTypeAlias = ( type: ESTree.TSType, ): { label: string; factory: ESTree.Node | null } | null => { const current = unwrapType(type); if (current.type !== 'TSTypeReference') return null; - if ( - current.typeName.type === 'Identifier' && - ['Awaited', 'Readonly', 'NonNullable'].includes(current.typeName.name) && - !variableFor(current.typeName, current.typeName.name)?.defs.length - ) { + if (isUtilityReference(current.typeName)) { const inner = current.typeArguments?.params[0]; return inner ? returnTypeAlias(inner) : null; } - const segments = typeNameSegments(current.typeName); - if ( - segments === null || - segments.length !== 1 || - segments[0] !== 'ReturnType' || - variableFor(current.typeName, 'ReturnType')?.defs.length - ) - return null; + if (!isReturnTypeReference(current.typeName)) return null; const argument = current.typeArguments?.params?.[0]; if (argument === undefined) return null; const inner = unwrapType(argument); @@ -541,23 +486,24 @@ export const rule = defineRule({ /** Only tag type arguments and explicitly supplied values identify a contract. */ const recordTagConstruction = (callee: AnyNode): void => { - for (const args of tagConstructionInfo(callee).typeArgs) collectContracts(args); + for (const args of tagConstructionArguments(callee)) collectContracts(args); + }; + const collectDeclarationContract = (declaration: any): void => { + if (declaration.type === 'TSTypeAliasDeclaration') + collectContracts(declaration.typeAnnotation); + }; + const collectFactoryContract = (declaration: any): void => { + if (declaration.type === 'FunctionDeclaration') collectContracts(declaration.returnType); + if (declaration.type !== 'VariableDeclarator') return; + const type = declaration.id.typeAnnotation?.typeAnnotation; + if (type?.type === 'TSFunctionType') collectContracts(type.returnType); + collectContracts(declaration.init?.returnType); }; const resolveFactoryReturnContracts = (): void => { for (let round = 0; round < MAX_TYPE_DEPTH; round++) { const before = taggedDeclarations.size + taggedFactories.size; - for (const declaration of taggedDeclarations) - if (declaration.type === 'TSTypeAliasDeclaration') - collectContracts(declaration.typeAnnotation); - for (const declaration of taggedFactories) { - if (declaration.type === 'FunctionDeclaration') collectContracts(declaration.returnType); - if (declaration.type === 'VariableDeclarator') { - // Only the function's return annotation, not its collaborator parameters. - const type = declaration.id.typeAnnotation?.typeAnnotation; - if (type?.type === 'TSFunctionType') collectContracts(type.returnType); - collectContracts(declaration.init?.returnType); - } - } + for (const declaration of taggedDeclarations) collectDeclarationContract(declaration); + for (const declaration of taggedFactories) collectFactoryContract(declaration); if (before === taggedDeclarations.size + taggedFactories.size) break; } }; @@ -577,29 +523,47 @@ export const rule = defineRule({ ) { providedContract(value.expression, seen); } else if (value.type === 'Identifier') { - const variable = variableFor(value, value.name); - for (const def of variable?.defs ?? []) - if ( - def.type === 'Variable' && - def.parent?.kind === 'const' && - !variable.references.some((ref: any) => ref.isWrite() && !ref.init) - ) { - collectContracts(def.node.id.typeAnnotation); - providedContract(def.node.init, seen); - } + providedIdentifierContract(value, seen); } else if ( value.type === 'CallExpression' && /^(?:root\.)?Effect\.(?:succeed|sync)$/u.test(imported(value.callee) ?? '') ) { providedContract(value.arguments[0], seen); } else if (['ArrowFunctionExpression', 'FunctionExpression'].includes(value.type)) { - collectContracts(value.returnType); - if (value.body.type === 'BlockStatement') { - for (const statement of value.body.body) - if (statement.type === 'ReturnStatement') providedContract(statement.argument, seen); - } else providedContract(value.body, seen); + providedFunctionContract(value, seen); } }; + const providedIdentifierContract = (value: any, seen: Set): void => { + const variable = variableFor(value, value.name); + for (const def of variable?.defs ?? []) { + if (!immutableVariable(def, variable)) continue; + collectContracts(def.node.id.typeAnnotation); + providedContract(def.node.init, seen); + } + }; + const providedFunctionContract = (value: any, seen: Set): void => { + collectContracts(value.returnType); + if (value.body.type !== 'BlockStatement') { + providedContract(value.body, seen); + return; + } + for (const statement of value.body.body) + if (statement.type === 'ReturnStatement') providedContract(statement.argument, seen); + }; + const providedConfiguration = (config: any): void => { + if (config?.type !== 'ObjectExpression') return; + for (const property of config.properties) { + const key = property.computed ? property.key?.value : property.key?.name; + if (property.type === 'Property' && ['effect', 'defaultValue'].includes(key)) + providedContract(property.value); + } + }; + const outerCall = (node: any): any => { + let outer = node; + while (outer.parent?.type === 'CallExpression' && outer.parent.callee === outer) + outer = outer.parent; + return outer; + }; return { CallExpression(node) { @@ -615,19 +579,9 @@ export const rule = defineRule({ if (!isTag && !isLayer) return; moduleHasTag = true; if (isTag) recordTagConstruction(node.callee as AnyNode); - let outer: any = node; - while (outer.parent?.type === 'CallExpression' && outer.parent.callee === outer) - outer = outer.parent; + const outer = outerCall(node); if (isLayer) providedContract(outer.arguments[1]); - else { - const config = outer.arguments[1]; - if (config?.type === 'ObjectExpression') - for (const property of config.properties) { - const key = property.computed ? property.key?.value : property.key?.name; - if (property.type === 'Property' && ['effect', 'defaultValue'].includes(key)) - providedContract(property.value); - } - } + else providedConfiguration(outer.arguments[1]); }, TSInterfaceDeclaration(node) { const name = node.id.name; diff --git a/app/tools/oxlint/effect-native/rules/require-observability-layers-at-runtime-root.ts b/app/tools/oxlint/effect-native/rules/require-observability-layers-at-runtime-root.ts index dd9726c7a..b2760ea2c 100644 --- a/app/tools/oxlint/effect-native/rules/require-observability-layers-at-runtime-root.ts +++ b/app/tools/oxlint/effect-native/rules/require-observability-layers-at-runtime-root.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * Audit A6 (`docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`) asks for Logger, * Tracer/OpenTelemetry and minimum-level Layers at runtime roots. @@ -22,9 +23,14 @@ import { defineRule } from '@oxlint/plugins'; import { fileURLToPath } from 'node:url'; -import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import type { Context, ESTree, Variable } from '@oxlint/plugins'; -import { globToRegExp, isTestFile, normalisePath } from '../shared/paths.ts'; +import { isTestFile, normalisePath, matchesGlobs } from '../shared/paths.ts'; +import { stringArray, booleanOption as boolOption } from '../shared/options.ts'; +import { unwrapNode as unwrap, memberName as sharedMemberName, keyName } from '../shared/ast.ts'; +import { lookupVariable, resolvesToImport } from '../shared/bindings.ts'; +import { importedName } from '../shared/imports.ts'; +import { isNonReferencePosition as sharedNonReferencePosition } from '../shared/reference-positions.ts'; const EFFECT_MODULE = /^effect(?:\/.*)?$/u; const EFFECT_ROOT_MODULE = 'effect'; @@ -80,7 +86,6 @@ const DEFAULT_MINIMUM_LOG_LEVEL_MEMBERS = [ /** Effect namespaces whose use is recognized local observability evidence. */ const LOGGER_NAMESPACE = 'Logger'; const TRACER_NAMESPACE = 'Tracer'; -const EFFECT_NAMESPACE = 'Effect'; interface RequireOptions { readonly logger: boolean; @@ -102,22 +107,8 @@ interface RuleOptions { readonly require: RequireOptions; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - -function boolOption(value: unknown, fallback: boolean): boolean { - return typeof value === 'boolean' ? value : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); const rawRequire = record.require; const requireRecord: Record = typeof rawRequire === 'object' && rawRequire !== null && !Array.isArray(rawRequire) @@ -157,10 +148,6 @@ function scopePath(filename: string): string { : normalisePath(unified).replace(FIXTURE_PREFIX, ''); } -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); -} - /** `["ManagedRuntime.make"]` → `Set{"ManagedRuntime.make"}`, ignoring malformed entries. */ function qualifiedSet(entries: readonly string[]): ReadonlySet { const set = new Set(); @@ -172,12 +159,6 @@ function qualifiedSet(entries: readonly string[]): ReadonlySet { return set; } -function importedName(specifier: ESTree.ImportSpecifier): string { - return specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; -} - function isTypeOnly( declaration: ESTree.ImportDeclaration, specifier: ESTree.ImportDeclarationSpecifier, @@ -205,182 +186,136 @@ interface FileBindings { readonly otelValueImport: boolean; } -function collectFileBindings(program: ESTree.Program, options: RuleOptions): FileBindings { - const namespaces = new Map(); - const directMembers = new Map(); - const barrels = new Set(); - const otelLocals = new Map(); - const runtimeTypeLocals = new Set(); - const runtimeTypeNamespaces = new Set(); - const runtimeTypeNames = new Set(options.runtimeTypeNames); - let importsEffect = false; - let loggerModuleImport = false; - let tracerModuleImport = false; - let otelValueImport = false; - - for (const statement of program.body) { - if (statement.type !== 'ImportDeclaration') continue; - const source = statement.source.value; - - // Runtime type identities are accepted only from configured framework barrels. - if (matchesGlobs(source, options.reexportModules)) { - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportNamespaceSpecifier') - runtimeTypeNamespaces.add(specifier.local.name); - if (specifier.type === 'ImportSpecifier' && runtimeTypeNames.has(importedName(specifier))) - runtimeTypeLocals.add(specifier.local.name); - } - } - - if (EFFECT_MODULE.test(source)) { - const submodule = source.split('/').at(-1); - for (const specifier of statement.specifiers) { - if (isTypeOnly(statement, specifier)) continue; - importsEffect = true; - if (specifier.type === 'ImportSpecifier') { - if (source !== EFFECT_ROOT_MODULE && submodule && /^[A-Z]/u.test(submodule)) - directMembers.set(specifier.local.name, `${submodule}.${importedName(specifier)}`); - else namespaces.set(specifier.local.name, importedName(specifier)); - } else if (specifier.type === 'ImportNamespaceSpecifier') { - if (source === EFFECT_ROOT_MODULE) barrels.add(specifier.local.name); - else if (submodule !== undefined) namespaces.set(specifier.local.name, submodule); - } - } - if ( - source !== EFFECT_ROOT_MODULE && - statement.specifiers.some((specifier) => !isTypeOnly(statement, specifier)) - ) { - if (submodule === LOGGER_NAMESPACE) loggerModuleImport = true; - if (submodule === TRACER_NAMESPACE) tracerModuleImport = true; - } - continue; - } - - if (matchesGlobs(source, options.otelModules)) { - for (const specifier of statement.specifiers) { - if (isTypeOnly(statement, specifier)) continue; - otelValueImport = true; - const imported = - specifier.type === 'ImportSpecifier' ? importedName(specifier) : specifier.local.name; - otelLocals.set(specifier.local.name, imported); - } - continue; - } - - if (matchesGlobs(source, options.reexportModules)) { - for (const specifier of statement.specifiers) { - if (isTypeOnly(statement, specifier)) continue; - importsEffect = true; - if (specifier.type === 'ImportSpecifier') { - if (importedName(specifier) === 'OpenTelemetry') { - otelLocals.set(specifier.local.name, 'OpenTelemetry'); - otelValueImport = true; - } else namespaces.set(specifier.local.name, importedName(specifier)); - } else if (specifier.type === 'ImportNamespaceSpecifier') barrels.add(specifier.local.name); - } - } +type CollectedBindings = { + namespaces: Map; + directMembers: Map; + barrels: Set; + otelLocals: Map; + runtimeTypeLocals: Set; + runtimeTypeNamespaces: Set; + importsEffect: boolean; + loggerModuleImport: boolean; + tracerModuleImport: boolean; + otelValueImport: boolean; +}; + +function collectRuntimeTypes( + statement: ESTree.ImportDeclaration, + names: ReadonlySet, + bindings: CollectedBindings, +): void { + for (const specifier of statement.specifiers) { + if (specifier.type === 'ImportNamespaceSpecifier') + bindings.runtimeTypeNamespaces.add(specifier.local.name); + if (specifier.type === 'ImportSpecifier' && names.has(importedName(specifier))) + bindings.runtimeTypeLocals.add(specifier.local.name); } - - return { - namespaces, - directMembers, - barrels, - otelLocals, - runtimeTypeLocals, - runtimeTypeNamespaces, - importsEffect, - loggerModuleImport, - tracerModuleImport, - otelValueImport, - }; } -function unwrap(node: ESTree.Node): ESTree.Node { - while ( - [ - 'TSAsExpression', - 'TSSatisfiesExpression', - 'TSTypeAssertion', - 'TSNonNullExpression', - 'TSInstantiationExpression', - 'ChainExpression', - 'ParenthesizedExpression', - ].includes(node.type) - ) { - node = (node as unknown as { expression: ESTree.Node }).expression; +function collectEffectSpecifier( + specifier: ESTree.ImportDeclarationSpecifier, + source: string, + bindings: CollectedBindings, +): void { + const submodule = source.split('/').at(-1); + const local = specifier.local.name; + if (specifier.type === 'ImportSpecifier') { + if (source !== EFFECT_ROOT_MODULE && submodule && /^[A-Z]/u.test(submodule)) + bindings.directMembers.set(local, `${submodule}.${importedName(specifier)}`); + else bindings.namespaces.set(local, importedName(specifier)); + return; } - return node; + if (specifier.type !== 'ImportNamespaceSpecifier') return; + if (source === EFFECT_ROOT_MODULE) bindings.barrels.add(local); + else if (submodule !== undefined) bindings.namespaces.set(local, submodule); } -/** Non-computed `.make`, or computed `["make"]`. */ -function memberName(node: ESTree.MemberExpression): string | null { - if (!node.computed) return node.property.type === 'Identifier' ? node.property.name : null; - const property = unwrap(node.property); - if (property.type === 'Literal' && typeof property.value === 'string') return property.value; - if (property.type === 'TemplateLiteral' && property.expressions.length === 0) - return property.quasis[0]?.value.cooked ?? null; - return null; +function collectBarrelSpecifier( + specifier: ESTree.ImportDeclarationSpecifier, + bindings: CollectedBindings, +): void { + if (specifier.type === 'ImportNamespaceSpecifier') bindings.barrels.add(specifier.local.name); + if (specifier.type !== 'ImportSpecifier') return; + const imported = importedName(specifier); + if (imported === 'OpenTelemetry') { + bindings.otelLocals.set(specifier.local.name, imported); + bindings.otelValueImport = true; + } else bindings.namespaces.set(specifier.local.name, imported); } -function lookupVariable( - context: Context, - identifier: Extract, -): Variable | null { - let scope: Scope | null = context.sourceCode.getScope(identifier); - while (scope !== null) { - const variable = scope.set.get(identifier.name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; +function collectEffectImport( + specifiers: readonly ESTree.ImportDeclarationSpecifier[], + source: string, + bindings: CollectedBindings, +): void { + if (specifiers.length === 0) return; + bindings.importsEffect = true; + const submodule = source.split('/').at(-1); + if (submodule === LOGGER_NAMESPACE) bindings.loggerModuleImport = true; + if (submodule === TRACER_NAMESPACE) bindings.tracerModuleImport = true; + for (const specifier of specifiers) collectEffectSpecifier(specifier, source, bindings); } -/** - * `true` when the identifier still resolves to an `import` binding. Unresolved names fall back to - * `true` because the module-level import declaration already proved the binding exists; only a local - * shadow (parameter, `const`, catch clause, …) rejects the match. - */ -function resolvesToImport( - context: Context, - identifier: Extract, -): boolean { - const variable = lookupVariable(context, identifier); - if (variable === null) return true; - if (variable.defs.length === 0) return true; - return variable.defs.some((definition) => definition.type === 'ImportBinding'); +function collectValueImport( + statement: ESTree.ImportDeclaration, + options: RuleOptions, + bindings: CollectedBindings, +): void { + const source = statement.source.value; + const specifiers = statement.specifiers.filter((specifier) => !isTypeOnly(statement, specifier)); + if (EFFECT_MODULE.test(source)) { + collectEffectImport(specifiers, source, bindings); + return; + } + if (matchesGlobs(source, options.otelModules)) { + for (const specifier of specifiers) { + bindings.otelValueImport = true; + bindings.otelLocals.set( + specifier.local.name, + specifier.type === 'ImportSpecifier' ? importedName(specifier) : specifier.local.name, + ); + } + return; + } + if (!matchesGlobs(source, options.reexportModules)) return; + for (const specifier of specifiers) { + bindings.importsEffect = true; + collectBarrelSpecifier(specifier, bindings); + } } -/** Identifier positions that are declarations or property keys, never references to the import. */ -function isNonReferencePosition(node: Extract): boolean { - const parent = node.parent; - if (parent === null || parent === undefined) return true; - switch (parent.type) { - case 'ImportSpecifier': - case 'ImportDefaultSpecifier': - case 'ImportNamespaceSpecifier': - case 'ExportSpecifier': { - return true; - } - case 'VariableDeclarator': { - return parent.id === node; - } - case 'TSTypeQuery': - case 'TSTypeReference': - case 'TSQualifiedName': { - return true; - } - case 'MemberExpression': { - return parent.property === node && !parent.computed; - } - case 'Property': - case 'PropertyDefinition': - case 'MethodDefinition': { - return parent.key === node && !parent.computed; - } - default: { - return false; - } +function collectFileBindings(program: ESTree.Program, options: RuleOptions): FileBindings { + const bindings: CollectedBindings = { + namespaces: new Map(), + directMembers: new Map(), + barrels: new Set(), + otelLocals: new Map(), + runtimeTypeLocals: new Set(), + runtimeTypeNamespaces: new Set(), + importsEffect: false, + loggerModuleImport: false, + tracerModuleImport: false, + otelValueImport: false, + }; + const names = new Set(options.runtimeTypeNames); + for (const statement of program.body) { + if (statement.type !== 'ImportDeclaration') continue; + if (matchesGlobs(statement.source.value, options.reexportModules)) + collectRuntimeTypes(statement, names, bindings); + collectValueImport(statement, options, bindings); } + return bindings; +} + +function memberName(node: ESTree.MemberExpression): string | null { + return sharedMemberName(node, { templates: true, unwrap: {} }); +} +const NON_REFERENCE_TYPES = new Set(['TSTypeQuery', 'TSTypeReference', 'TSQualifiedName']); +function isNonReferencePosition(node: ESTree.Node): boolean { + return sharedNonReferencePosition(node, { + variableBindings: true, + nonReferenceParents: NON_REFERENCE_TYPES, + }); } const FUNCTION_TYPES = new Set([ @@ -419,36 +354,103 @@ const RETURN_TYPE_WRAPPERS = new Set([ * through `&`, `|` and parentheses only, so a runtime type used as a *type argument* * (`Layer.Layer>`) or in a plain alias is not mistaken for a root. */ +function initializedFunctionType(owner: ESTree.Node): ESTree.Node | null { + if (owner.type !== 'TSFunctionType') return null; + const annotation = owner.parent; + if (annotation?.type !== 'TSTypeAnnotation') return null; + const binding = annotation.parent; + const declaration = binding?.parent; + if (declaration?.type !== 'VariableDeclarator' || declaration.id !== binding || !declaration.init) + return null; + return declaration; +} + +function returnAnnotationOwner(annotation: ESTree.Node): ESTree.Node | null { + const owner = annotation.parent as + | (ESTree.Node & { returnType?: unknown; body?: unknown }) + | null + | undefined; + if (!owner || owner.returnType !== annotation) return null; + if (FUNCTION_TYPES.has(owner.type) && owner.body) return owner; + return initializedFunctionType(owner); +} + function functionOwningReturnType(node: ESTree.Node): ESTree.Node | null { - let current: ESTree.Node | null | undefined = node.parent; - while (current !== null && current !== undefined) { - if (current.type === 'TSTypeAnnotation') { - const owner = current.parent as (ESTree.Node & { returnType?: unknown }) | null | undefined; - if (owner === null || owner === undefined) return null; - if (owner.returnType !== current) return null; - if (FUNCTION_TYPES.has(owner.type) && (owner as { body?: unknown }).body) return owner; - // A function type annotating an initialized variable is executable; a type alias, - // interface member, ambient declaration or abstract signature is not. - if (owner.type === 'TSFunctionType') { - const annotation = owner.parent; - if (annotation?.type !== 'TSTypeAnnotation') return null; - const binding = annotation.parent; - const declaration = binding?.parent; - if ( - declaration?.type === 'VariableDeclarator' && - declaration.id === binding && - declaration.init - ) - return declaration; - } - return null; - } + let current = node.parent; + while (current) { + if (current.type === 'TSTypeAnnotation') return returnAnnotationOwner(current); if (!RETURN_TYPE_WRAPPERS.has(current.type)) return null; current = current.parent; } return null; } +function excludedPath(path: string, options: RuleOptions): boolean { + if ( + /\.d\.[cm]?ts$/u.test(path) || + /(?:^|\/)(?:dist(?:-[^/]+)?|build|\.output|node_modules)\//u.test(path) + ) + return true; + if (matchesGlobs(path, options.ignore)) return true; + if (!options.includeTests && isTestFile(path)) return true; + return /(?:^|\/)scripts\//u.test(path) + ? !options.includeScripts + : !matchesGlobs(path, options.include); +} + +function qualifyValue(base: string | null, key: string | null): string | null { + if (base === null || key === null) return null; + return base === '$root' ? key : `${base}.${key}`; +} + +function destructuredValue( + declaration: ESTree.VariableDeclarator, + name: string, + base: string | null, +): string | null { + if (declaration.id.type === 'Identifier') return base; + if (declaration.id.type !== 'ObjectPattern' || base === null) return null; + for (const property of declaration.id.properties) { + if ( + property.type !== 'Property' || + property.value.type !== 'Identifier' || + property.value.name !== name + ) + continue; + return qualifyValue(base, keyName(property.key, property.computed, { templates: false })); + } + return null; +} + +function aliasDeclaration( + variable: Variable | null, + seen: Set, +): ESTree.VariableDeclarator | null { + if (!variable || seen.has(variable)) return null; + if (variable.references.some((reference) => reference.isWrite() && !reference.init)) return null; + seen.add(variable); + const definition = variable.defs[0]; + if ( + definition?.type !== 'Variable' || + definition.node.type !== 'VariableDeclarator' || + !definition.node.init + ) + return null; + return definition.node; +} + +function rootAnchor( + roots: RootHit[], + program: ESTree.Program, +): { node: ESTree.Node; kind: string } { + roots.sort((left, right) => left.start - right.start); + const first = roots[0]; + return { + node: first?.node ?? program.body[0] ?? program, + kind: first?.kind ?? 'declared host entry point', + }; +} + interface RootHit { readonly node: ESTree.Node; readonly kind: string; @@ -523,16 +525,7 @@ export const rule = defineRule({ create(context) { const options = readOptions(context); const path = scopePath(context.filename); - if ( - /\.d\.[cm]?ts$/u.test(path) || - /(?:^|\/)(?:dist(?:-[^/]+)?|build|\.output|node_modules)\//u.test(path) - ) - return {}; - if (matchesGlobs(path, options.ignore)) return {}; - if (!options.includeTests && isTestFile(path)) return {}; - const script = /(?:^|\/)scripts\//u.test(path); - if (script && !options.includeScripts) return {}; - if (!script && !matchesGlobs(path, options.include)) return {}; + if (excludedPath(path, options)) return {}; const program = context.sourceCode.ast; const bindings = collectFileBindings(program, options); @@ -554,7 +547,7 @@ export const rule = defineRule({ if (node.type === 'MemberExpression') { const base = resolveValue(node.object, seen); const key = memberName(node); - return base === null || key === null ? null : base === '$root' ? key : `${base}.${key}`; + return qualifyValue(base, key); } if (node.type !== 'Identifier') return null; const variable = lookupVariable(context, node); @@ -564,40 +557,48 @@ export const rule = defineRule({ bindings.namespaces.get(node.name) ?? (bindings.barrels.has(node.name) ? '$root' : null) ); - if ( - !variable || - seen.has(variable) || - variable.references.some((reference) => reference.isWrite() && !reference.init) - ) - return null; - seen.add(variable); - const definition = variable.defs[0]; - if ( - definition?.type !== 'Variable' || - definition.node.type !== 'VariableDeclarator' || - !definition.node.init - ) - return null; - const declaration = definition.node; - const base = resolveValue(declaration.init!, seen); - if (declaration.id.type === 'Identifier') return base; - if (declaration.id.type !== 'ObjectPattern' || base === null) return null; - for (const property of declaration.id.properties) { - if ( - property.type !== 'Property' || - property.value.type !== 'Identifier' || - property.value.name !== node.name - ) - continue; - const key = - !property.computed && property.key.type === 'Identifier' - ? property.key.name - : property.key.type === 'Literal' && typeof property.key.value === 'string' - ? property.key.value - : null; - return key === null ? null : base === '$root' ? key : `${base}.${key}`; + const declaration = aliasDeclaration(variable, seen); + if (!declaration?.init) return null; + return destructuredValue(declaration, node.name, resolveValue(declaration.init, seen)); + }; + + const recordEvidence = (qualified: string): void => { + if (qualified.startsWith('Logger.')) hasLogger = true; + if (qualified.startsWith('Tracer.')) hasTracer = true; + if (minimumLogLevelMembers.has(qualified)) hasMinimumLogLevel = true; + }; + const recordQualifiedRoot = ( + node: ESTree.Node, + qualified: string, + moduleRun = qualified.startsWith('Effect.run'), + ): void => { + if (runtimeMembers.has(qualified)) recordRoot(node, qualified); + else if (moduleRun && !insideFunction(node)) recordRoot(node, `module-level ${qualified}`); + }; + const recordOtel = (node: ESTree.Node, name: string): void => { + if (!resolvesToImport(context, node)) return; + hasTracer = true; + if (name.includes(LOGGER_NAMESPACE)) hasLogger = true; + }; + const runtimeTypeName = (typeName: ESTree.TSTypeName): string | null => { + if (typeName.type === 'Identifier') { + return bindings.runtimeTypeLocals.has(typeName.name) && resolvesToImport(context, typeName) + ? typeName.name + : null; } - return null; + if (typeName.type !== 'TSQualifiedName' || typeName.left.type !== 'Identifier') return null; + const name = typeName.right.name; + if (!runtimeTypeNameSet.has(name) || !bindings.runtimeTypeNamespaces.has(typeName.left.name)) + return null; + return resolvesToImport(context, typeName.left) ? name : null; + }; + const missingEvidence = (): string[] => { + const missing: string[] = []; + if (options.require.logger && !hasLogger) missing.push('missingLogger'); + if (options.require.tracer && !hasTracer) missing.push('missingTracer'); + if (options.require.minimumLogLevel && !hasMinimumLogLevel) + missing.push('missingMinimumLogLevel'); + return missing; }; return { @@ -606,70 +607,32 @@ export const rule = defineRule({ if (member === null) return; const namespace = resolveValue(node.object); if (namespace === null) { - // `Otel.OtelLogger` / `NodeSdkNs.layer` — namespace import of the OTel package. - if (node.object.type !== 'Identifier') return; - if (!bindings.otelLocals.has(node.object.name)) return; - if (!resolvesToImport(context, node.object)) return; - hasTracer = true; - if (member.includes(LOGGER_NAMESPACE)) hasLogger = true; + if (node.object.type === 'Identifier' && bindings.otelLocals.has(node.object.name)) + recordOtel(node.object, member); return; } const qualified = `${namespace}.${member}`; - - if (runtimeMembers.has(qualified)) recordRoot(node, qualified); - else if ( - namespace === EFFECT_NAMESPACE && - member.startsWith('run') && - !insideFunction(node) - ) { - recordRoot(node, `module-level ${qualified}`); - } - + recordQualifiedRoot(node, qualified, namespace === 'Effect' && member.startsWith('run')); + // Namespace evidence is intentionally limited to the immediate namespace. if (namespace === LOGGER_NAMESPACE) hasLogger = true; if (namespace === TRACER_NAMESPACE) hasTracer = true; if (minimumLogLevelMembers.has(qualified)) hasMinimumLogLevel = true; }, - - // Bare reference to an `@effect/opentelemetry` binding: `NodeSdk.layer` is caught above, but - // `Layer.provide(otelLayer, NodeSdk)` / point-free hand-offs must count as evidence too. Identifier(node) { if (isNonReferencePosition(node)) return; const direct = resolveValue(node); if (direct) { - if (runtimeMembers.has(direct)) recordRoot(node, direct); - else if (direct.startsWith('Effect.run') && !insideFunction(node)) - recordRoot(node, `module-level ${direct}`); - if (direct.startsWith('Logger.')) hasLogger = true; - if (direct.startsWith('Tracer.')) hasTracer = true; - if (minimumLogLevelMembers.has(direct)) hasMinimumLogLevel = true; + recordQualifiedRoot(node, direct); + recordEvidence(direct); } const imported = bindings.otelLocals.get(node.name); - if (imported === undefined) return; - if (isNonReferencePosition(node)) return; - if (!resolvesToImport(context, node)) return; - hasTracer = true; - if (imported.includes(LOGGER_NAMESPACE)) hasLogger = true; + if (imported !== undefined) recordOtel(node, imported); }, // `(...): EffectBffDefinition & EffectBffRuntime => { … }` — the BFF composition root. TSTypeReference(node) { - const typeName = node.typeName; - const name = - typeName.type === 'Identifier' - ? typeName.name - : typeName.type === 'TSQualifiedName' && typeName.right.type === 'Identifier' - ? typeName.right.name - : null; + const name = runtimeTypeName(node.typeName); if (name === null) return; - const known = - typeName.type === 'Identifier' - ? bindings.runtimeTypeLocals.has(name) && resolvesToImport(context, typeName) - : typeName.type === 'TSQualifiedName' && - typeName.left.type === 'Identifier' && - runtimeTypeNameSet.has(name) && - bindings.runtimeTypeNamespaces.has(typeName.left.name) && - resolvesToImport(context, typeName.left); - if (!known) return; const owner = functionOwningReturnType(node); if (owner === null) return; recordRoot(owner, `${name} factory`); @@ -679,23 +642,13 @@ export const rule = defineRule({ const isDeclaredRoot = matchesGlobs(path, options.rootFiles) && bindings.importsEffect; if (roots.length === 0 && !isDeclaredRoot) return; - const missing: Array<{ readonly messageId: string; readonly label: string }> = []; - if (options.require.logger && !hasLogger) - missing.push({ messageId: 'missingLogger', label: 'Logger' }); - if (options.require.tracer && !hasTracer) - missing.push({ messageId: 'missingTracer', label: 'Tracer' }); - if (options.require.minimumLogLevel && !hasMinimumLogLevel) { - missing.push({ messageId: 'missingMinimumLogLevel', label: 'MinimumLogLevel' }); - } + const missing = missingEvidence(); if (missing.length === 0) return; - roots.sort((left, right) => left.start - right.start); - const first = roots[0]; - const anchor = first?.node ?? node.body[0] ?? node; - const kind = first?.kind ?? 'declared host entry point'; + const { node: anchor, kind } = rootAnchor(roots, node); for (const entry of missing) { - context.report({ node: anchor, messageId: entry.messageId, data: { root: path, kind } }); + context.report({ node: anchor, messageId: entry, data: { root: path, kind } }); } }, }; diff --git a/app/tools/oxlint/effect-native/rules/require-timeout-on-external-effect.ts b/app/tools/oxlint/effect-native/rules/require-timeout-on-external-effect.ts index 31d8f65c0..d639b0e35 100644 --- a/app/tools/oxlint/effect-native/rules/require-timeout-on-external-effect.ts +++ b/app/tools/oxlint/effect-native/rules/require-timeout-on-external-effect.ts @@ -1,3 +1,4 @@ +import { optionRecord } from '../shared/options.ts'; /** * effect-native/require-timeout-on-external-effect * @@ -83,10 +84,12 @@ import { defineRule } from '@oxlint/plugins'; import type { Context, ESTree } from '@oxlint/plugins'; -import { globToRegExp, isScriptFile, isTestFile, normalisePath } from '../shared/paths.ts'; - -/** Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; +import { isScriptFile, isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; +import { identityUnwrap, staticString, FUNCTION_TYPES } from '../shared/ast.ts'; +import { lookupVariable as lexicalVariable } from '../shared/bindings.ts'; +import { bindingPath } from '../shared/effect-identity.ts'; +import { importedName } from '../shared/imports.ts'; +import { stringArray, booleanOption as boolean, stringOption, compile } from '../shared/options.ts'; const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**']; const DEFAULT_IGNORE = [ @@ -107,24 +110,11 @@ const DEFAULT_PROMISE_BRIDGES = ['promise', 'tryPromise', 'tryMapPromise']; /** `HttpClient` request-execution members (Effect v4 `effect/unstable/http`). */ const DEFAULT_HTTP_METHODS = ['execute', 'get', 'post', 'put', 'patch', 'del', 'head', 'options']; -const EFFECT_NAMESPACE = 'Effect'; -const EFFECT_ROOT_MODULE = 'effect'; -const EFFECT_SUBMODULE = 'effect/Effect'; -const HTTP_CLIENT_NAMESPACE = 'HttpClient'; -/** Namespaces whose `make`/`layer` produce an `HttpClient` value. */ -const HTTP_CLIENT_FACTORY_NAMESPACES = new Set(['HttpClient', 'FetchHttpClient']); - const TIMEOUT_MEMBERS = new Set(['timeout', 'timeoutOption', 'timeoutOrElse', 'timeoutFail']); const RETRY_MEMBERS = new Set(['retry', 'retryOrElse']); /** `Effect.gen(function* () { … })` — the one function boundary a policy legitimately spans. */ const EFFECT_PROGRAM_WRAPPERS = new Set(['gen', 'fn', 'fnUntraced']); -const FUNCTION_TYPES = new Set([ - 'FunctionDeclaration', - 'FunctionExpression', - 'ArrowFunctionExpression', -]); - interface Policy { timeout: boolean; retry: boolean; @@ -145,32 +135,18 @@ interface RuleOptions { readonly crossEffectGen: boolean; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - -function boolean(value: unknown, fallback: boolean): boolean { - return typeof value === 'boolean' ? value : fallback; -} - -function text(value: unknown, fallback: string): string { - return typeof value === 'string' && value.length > 0 ? value : fallback; -} - function readOptions(context: Context): RuleOptions { - const raw = context.options?.[0]; - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(context.options?.[0]); return { requireTimeout: boolean(record.requireTimeout, true), requireRetry: boolean(record.requireRetry, false), portFiles: stringArray(record.portFiles, DEFAULT_PORT_FILES), trustPorts: boolean(record.trustPorts, false), - policyHelperPattern: text(record.policyHelperPattern, DEFAULT_POLICY_HELPER_PATTERN), + policyHelperPattern: stringOption( + record.policyHelperPattern, + DEFAULT_POLICY_HELPER_PATTERN, + false, + ), includeTests: boolean(record.includeTests, false), includeScripts: boolean(record.includeScripts, false), include: stringArray(record.include, DEFAULT_INCLUDE), @@ -181,120 +157,93 @@ function readOptions(context: Context): RuleOptions { }; } -function scopePath(filename: string): string { - return normalisePath(filename).replace(FIXTURE_PREFIX, ''); -} - -function matchesGlobs(path: string, globs: readonly string[]): boolean { - return globs.some((glob) => globToRegExp(glob).test(path)); +function inScope(path: string, options: RuleOptions): boolean { + if (!options.requireTimeout && !options.requireRetry) return false; + if (matchesGlobs(path, options.ignore) || (!options.includeTests && isTestFile(path))) + return false; + if (isScriptFile(path) ? !options.includeScripts : !matchesGlobs(path, options.include)) + return false; + return !(options.trustPorts && matchesGlobs(path, options.portFiles)); } -function isScriptPath(path: string): boolean { - return isScriptFile(path) || path.includes('/scripts/'); +type Definition = import('@oxlint/plugins').Variable['defs'][number]; +function unseenDefinition( + context: Context, + node: ESTree.Node, + seen: Set, +): Definition | undefined { + const variable = lexicalVariable(context, node); + if (variable === null || seen.has(variable) || variable.defs.length !== 1) return undefined; + seen.add(variable); + return variable.defs[0]; } - -// Resolve lexical value bindings, not identifier spellings. Only immutable local aliases are -// followed; arbitrary object mutation, re-export contents and dynamic keys need type/data-flow analysis. -function lexicalVariable(context: Context, node: Extract) { - let scope: import('@oxlint/plugins').Scope | null = context.sourceCode.getScope(node); - while (scope !== null) { - const variable = scope.set.get(node.name); - if (variable !== undefined) return variable; - scope = scope.upper; - } - return null; +function memberPolicy(member: string | null): Policy | null { + if (member === null) return null; + if (TIMEOUT_MEMBERS.has(member)) return { timeout: true, retry: false }; + return RETRY_MEMBERS.has(member) ? { timeout: false, retry: true } : null; } -function staticString(node: ESTree.Node): string | null { - if (node.type === 'Literal' && typeof node.value === 'string') return node.value; - if (node.type === 'TemplateLiteral' && node.expressions.length === 0) - return node.quasis[0]?.value.cooked ?? null; - return null; +function constantInitializer(def: Definition | undefined): ESTree.Expression | null { + if (def?.type !== 'Variable' || (def.parent as ESTree.VariableDeclaration)?.kind !== 'const') + return null; + return (def.node as ESTree.VariableDeclarator).init; } -function identityUnwrap(node: ESTree.Node): ESTree.Node { - let current = node; - for (;;) { - if (current.type === 'SequenceExpression') { - const last = current.expressions.at(-1); - if (last === undefined) return current; - current = last; - } else if ( - [ - 'ChainExpression', - 'ParenthesizedExpression', - 'TSAsExpression', - 'TSTypeAssertion', - 'TSNonNullExpression', - 'TSSatisfiesExpression', - 'TSInstantiationExpression', - ].includes(current.type) - ) { - current = (current as unknown as { expression: ESTree.Node }).expression; - } else return current; - } +function importedPolicy(def: Definition, localName: string, pattern: RegExp): Policy | null { + const declaration = def.parent as ESTree.ImportDeclaration; + const specifier = def.node as ESTree.ImportSpecifier; + if (declaration.importKind === 'type' || specifier.importKind === 'type') return null; + const name = specifier.type === 'ImportSpecifier' ? importedName(specifier) : localName; + return pattern.test(name) ? { timeout: true, retry: true } : null; } -function bindingPath( +function isHttpNamespace( context: Context, - expression: ESTree.Node, - extraModules: readonly string[] = [], - seen = new Set(), -): readonly string[] | null { - const node = identityUnwrap(expression); - if (node.type === 'MemberExpression') { - const key = - !node.computed && node.property.type === 'Identifier' - ? node.property.name - : staticString(node.property); - const root = bindingPath(context, node.object, extraModules, seen); - return root !== null && key !== null ? [...root, key] : null; - } - if (node.type !== 'Identifier') return null; - const variable = lexicalVariable(context, node); - if (variable === null || seen.has(variable)) return null; - seen.add(variable); - if (variable.defs.length !== 1) return null; - const definition = variable.defs[0]; - if (definition === undefined) return null; - if (definition.type === 'ImportBinding') { - const specifier = definition.node as - | ESTree.ImportSpecifier - | ESTree.ImportNamespaceSpecifier - | ESTree.ImportDefaultSpecifier; - const declaration = definition.parent as ESTree.ImportDeclaration; - if (declaration?.type !== 'ImportDeclaration' || declaration.importKind === 'type') return null; - if (specifier.type === 'ImportSpecifier' && specifier.importKind === 'type') return null; - const source = declaration.source.value; - if (source !== 'effect' && !source.startsWith('effect/') && !extraModules.includes(source)) - return null; - const last = source.split('/').at(-1) ?? ''; - const base = source.startsWith('effect/') && /^[A-Z]/u.test(last) ? [last] : []; - if (specifier.type === 'ImportNamespaceSpecifier') return base; - if (specifier.type !== 'ImportSpecifier') return null; - const imported = - specifier.imported.type === 'Identifier' ? specifier.imported.name : specifier.imported.value; - return [...base, imported]; - } - if (definition.type !== 'Variable') return null; - const declaration = definition.node as ESTree.VariableDeclarator; - const parent = definition.parent as ESTree.VariableDeclaration; - if (parent?.kind !== 'const' || declaration.init === null) return null; - const base = bindingPath(context, declaration.init, extraModules, seen); - if (base === null) return null; - if (declaration.id.type === 'Identifier') return base; - if (declaration.id.type !== 'ObjectPattern') return null; - for (const property of declaration.id.properties) { - if ( - property.type === 'RestElement' || - property.value.type !== 'Identifier' || - property.value.name !== node.name - ) - continue; - const key = - !property.computed && property.key.type === 'Identifier' - ? property.key.name - : staticString(property.key); - return key === null ? null : [...base, key]; - } - return null; + name: Extract, +): boolean { + const imported = lexicalVariable(context, name)?.defs[0]; + if (imported?.type !== 'ImportBinding') return false; + const source = (imported.parent as ESTree.ImportDeclaration).source.value; + const specifier = imported.node as ESTree.ImportSpecifier; + if (!source.startsWith('effect/')) return false; + return ( + source.endsWith('/HttpClient') || + (specifier.type === 'ImportSpecifier' && + specifier.imported.type === 'Identifier' && + specifier.imported.name === 'HttpClient') + ); +} +function hasHttpAnnotation(context: Context, binding: ESTree.Node | undefined): boolean { + if (binding?.type !== 'Identifier') return false; + const annotation = binding.typeAnnotation?.typeAnnotation; + if (annotation?.type !== 'TSTypeReference' || annotation.typeName.type !== 'TSQualifiedName') + return false; + const name = annotation.typeName; + return ( + name.left.type === 'Identifier' && + name.right.name === 'HttpClient' && + isHttpNamespace(context, name.left) + ); +} +function tryProperty(property: ESTree.ObjectExpression['properties'][number]): boolean { + if (property.type !== 'Property') return false; + return ( + (!property.computed && property.key.type === 'Identifier' && property.key.name === 'try') || + staticString(property.key) === 'try' + ); +} +function bridgeThunk(call: ESTree.CallExpression): ESTree.Node | null { + const argument = call.arguments[0]; + if (argument === undefined) return null; + const thunk = identityUnwrap(argument); + if (thunk.type !== 'ObjectExpression') return thunk; + const property = thunk.properties.find(tryProperty); + return property?.type === 'Property' ? property.value : null; +} +function returnedBody(thunk: ESTree.Node | null): ESTree.Node | null { + if (thunk?.type !== 'ArrowFunctionExpression' && thunk?.type !== 'FunctionExpression') + return null; + const body = thunk.body; + if (body?.type !== 'BlockStatement') return body; + if (body.body.length !== 1 || body.body[0]?.type !== 'ReturnStatement') return null; + return body.body[0].argument; } export const rule = defineRule({ @@ -361,19 +310,9 @@ export const rule = defineRule({ }, create(context) { const options = readOptions(context); - if (!options.requireTimeout && !options.requireRetry) return {}; const path = scopePath(context.filename); - if (matchesGlobs(path, options.ignore) || (!options.includeTests && isTestFile(path))) - return {}; - const script = isScriptPath(path); - if (script ? !options.includeScripts : !matchesGlobs(path, options.include)) return {}; - if (options.trustPorts && matchesGlobs(path, options.portFiles)) return {}; - let policyHelper: RegExp; - try { - policyHelper = new RegExp(options.policyHelperPattern, 'u'); - } catch { - policyHelper = new RegExp(DEFAULT_POLICY_HELPER_PATTERN, 'u'); - } + if (!inScope(path, options)) return {}; + const policyHelper = compile(options.policyHelperPattern, DEFAULT_POLICY_HELPER_PATTERN); const effectMemberOf = (node: ESTree.Node): string | null => { const identity = bindingPath(context, node); return identity?.length === 2 && identity[0] === 'Effect' ? (identity[1] ?? null) : null; @@ -390,32 +329,15 @@ export const rule = defineRule({ const policyOf = (value: ESTree.Node, seen = new Set()): Policy | null => { const node = identityUnwrap(value); if (node.type === 'CallExpression') { - const member = effectMemberOf(node.callee); - if (member !== null && TIMEOUT_MEMBERS.has(member)) return { timeout: true, retry: false }; - if (member !== null && RETRY_MEMBERS.has(member)) return { timeout: false, retry: true }; + const policy = memberPolicy(effectMemberOf(node.callee)); + if (policy !== null) return policy; // Shared policy helpers are explicitly trusted only through real imports. return policyOf(node.callee, seen); } if (node.type !== 'Identifier') return null; - const variable = lexicalVariable(context, node); - if (variable === null || seen.has(variable) || variable.defs.length !== 1) return null; - seen.add(variable); - const def = variable.defs[0]; - if (def?.type === 'ImportBinding') { - const declaration = def.parent as ESTree.ImportDeclaration; - const specifier = def.node as ESTree.ImportSpecifier; - if (declaration.importKind === 'type' || specifier.importKind === 'type') return null; - const imported = - specifier.type === 'ImportSpecifier' - ? specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value - : node.name; - return policyHelper.test(imported) ? { timeout: true, retry: true } : null; - } - if (def?.type !== 'Variable' || (def.parent as ESTree.VariableDeclaration)?.kind !== 'const') - return null; - const init = (def.node as ESTree.VariableDeclarator).init; + const def = unseenDefinition(context, node, seen); + if (def?.type === 'ImportBinding') return importedPolicy(def, node.name, policyHelper); + const init = constantInitializer(def); return init === null ? null : policyOf(init, seen); }; const pipeKind = (call: ESTree.CallExpression): 'function' | 'member' | null => { @@ -461,6 +383,80 @@ export const rule = defineRule({ index === call.arguments.length - 1) ); }; + type AncestorStep = 'continue' | 'stop' | 'finalizer'; + const inspectFunction = (current: ESTree.Node): AncestorStep => { + const outer = outerExpression(current); + const owner = outer.parent; + if (owner?.type !== 'CallExpression') return 'stop'; + if (finalizerArgument(owner, outer)) return 'finalizer'; + const member = effectCallee(owner); + if (member === null) return 'stop'; + return effectCallbacks.has(member) || + (options.crossEffectGen && EFFECT_PROGRAM_WRAPPERS.has(member)) + ? 'continue' + : 'stop'; + }; + const canCrossCall = ( + call: ESTree.CallExpression, + member: string | null, + index: number, + ): boolean => { + const separateLifetime = [ + 'map', + 'sync', + 'succeed', + 'as', + 'forkChild', + 'forkScoped', + 'forkDaemon', + 'cached', + ]; + if (member !== null && !separateLifetime.includes(member)) return true; + // Native Array.map builds the Effect collection; do not infer arbitrary helpers. + const callee = identityUnwrap(call.callee); + return callee.type === 'MemberExpression' && memberKey(callee) === 'map' && index >= 0; + }; + const mergeFollowing = ( + call: ESTree.CallExpression, + start: number, + merge: (value: ESTree.Node) => void, + ): void => { + for (const argument of call.arguments.slice(start)) merge(argument); + }; + const isPolicyMember = (member: string | null): boolean => + member !== null && (TIMEOUT_MEMBERS.has(member) || RETRY_MEMBERS.has(member)); + const inspectCall = ( + call: ESTree.CallExpression, + child: ESTree.Node, + merge: (value: ESTree.Node) => void, + ): AncestorStep => { + const kind = pipeKind(call); + const index = call.arguments.indexOf(child as ESTree.Argument); + const member = effectCallee(call); + if (finalizerArgument(call, child)) return 'finalizer'; + if (kind !== null) { + // Only later operators bound work newly added to a pipe. + mergeFollowing(call, Math.max(index + 1, kind === 'function' ? 1 : 0), merge); + return 'continue'; + } + if (member === 'fn' || member === 'fnUntraced') { + mergeFollowing(call, index + 1, merge); + return 'continue'; + } + if (isPolicyMember(member)) { + if (index === 0 && call.arguments.length >= 2) merge(call); + return 'continue'; + } + return canCrossCall(call, member, index) ? 'continue' : 'stop'; + }; + const inspectAncestor = ( + current: ESTree.Node, + child: ESTree.Node, + merge: (value: ESTree.Node) => void, + ): AncestorStep => { + if (FUNCTION_TYPES.has(current.type)) return inspectFunction(current); + return current.type === 'CallExpression' ? inspectCall(current, child, merge) : 'continue'; + }; const inspectAncestors = (site: ESTree.Node): { policy: Policy; finalizer: boolean } => { const policy: Policy = { timeout: false, retry: false }; const merge = (value: ESTree.Node): void => { @@ -470,64 +466,12 @@ export const rule = defineRule({ policy.retry ||= found.retry; } }; - let child = site, - current = site.parent; + let child = site; + let current = site.parent; while (current !== null && current !== undefined) { - if (FUNCTION_TYPES.has(current.type)) { - const outer = outerExpression(current), - owner = outer.parent; - if (owner?.type !== 'CallExpression') break; - if (finalizerArgument(owner, outer)) return { policy, finalizer: true }; - const member = effectCallee(owner); - if ( - !( - member !== null && - (effectCallbacks.has(member) || - (options.crossEffectGen && EFFECT_PROGRAM_WRAPPERS.has(member))) - ) - ) - break; - } else if (current.type === 'CallExpression') { - const kind = pipeKind(current), - index = current.arguments.indexOf(child as ESTree.Argument); - const member = effectCallee(current); - if (finalizerArgument(current, child)) return { policy, finalizer: true }; - if (kind !== null) { - // A timeout BEFORE flatMap does not bound work added by that flatMap. - for ( - let i = Math.max(index + 1, kind === 'function' ? 1 : 0); - i < current.arguments.length; - i++ - ) { - const argument = current.arguments[i]; - if (argument !== undefined) merge(argument); - } - } else if (member === 'fn' || member === 'fnUntraced') { - for (const argument of current.arguments.slice(index + 1)) merge(argument); - } else if ( - member !== null && - (TIMEOUT_MEMBERS.has(member) || RETRY_MEMBERS.has(member)) - ) { - if (index === 0 && current.arguments.length >= 2) merge(current); - } else if ( - member === null || - [ - 'map', - 'sync', - 'succeed', - 'as', - 'forkChild', - 'forkScoped', - 'forkDaemon', - 'cached', - ].includes(member) - ) { - // Native Array.map builds the Effect collection; do not infer arbitrary helpers. - const callee = identityUnwrap(current.callee); - if (!(callee.type === 'MemberExpression' && memberKey(callee) === 'map' && index >= 0)) - break; - } - } + const step = inspectAncestor(current, child, merge); + if (step === 'finalizer') return { policy, finalizer: true }; + if (step === 'stop') break; child = current; current = current.parent; } @@ -541,40 +485,14 @@ export const rule = defineRule({ const node = identityUnwrap(value); if (bindingPath(context, node)?.join('.') === 'HttpClient') return true; if (node.type !== 'Identifier') return false; - const variable = lexicalVariable(context, node); - if (variable === null || seen.has(variable) || variable.defs.length !== 1) return false; - seen.add(variable); - const def = variable.defs[0]; + const def = unseenDefinition(context, node, seen); if (def === undefined) return false; - const binding = def.name; - if (binding?.type === 'Identifier') { - const annotation = binding.typeAnnotation?.typeAnnotation; - if ( - annotation?.type === 'TSTypeReference' && - annotation.typeName.type === 'TSQualifiedName' - ) { - const name = annotation.typeName; - if (name.left.type === 'Identifier' && name.right.name === 'HttpClient') { - const imported = lexicalVariable(context, name.left)?.defs[0]; - if (imported?.type === 'ImportBinding') { - const source = (imported.parent as ESTree.ImportDeclaration).source.value; - const specifier = imported.node as ESTree.ImportSpecifier; - if ( - source.startsWith('effect/') && - ((specifier.type === 'ImportSpecifier' && - specifier.imported.type === 'Identifier' && - specifier.imported.name === 'HttpClient') || - source.endsWith('/HttpClient')) - ) - return true; - } - } - } - } - if (def.type !== 'Variable' || (def.parent as ESTree.VariableDeclaration)?.kind !== 'const') - return false; - const init = (def.node as ESTree.VariableDeclarator).init; + if (hasHttpAnnotation(context, def.name)) return true; + const init = constantInitializer(def); if (init === null) return false; + return initializedHttpClient(init, seen); + }; + const initializedHttpClient = (init: ESTree.Node, seen: Set): boolean => { const actual = identityUnwrap(init); if (actual.type === 'YieldExpression' && actual.delegate && actual.argument !== null) return bindingPath(context, actual.argument)?.join('.') === 'HttpClient.HttpClient'; @@ -588,32 +506,8 @@ export const rule = defineRule({ // D-tier server module-loading adapters, not browser chunk/network imports. This is a // boundary exemption, not a claim that imported modules cannot perform async work. if (!/(?:^packages\/core-runtime\/|\/api\/|\/server\/)/u.test(path)) return false; - let thunk = call.arguments[0]; - if (thunk === undefined) return false; - thunk = identityUnwrap(thunk) as ESTree.Argument; - if (thunk.type === 'ObjectExpression') { - const property = thunk.properties.find( - (p) => - p.type === 'Property' && - ((!p.computed && p.key.type === 'Identifier' && p.key.name === 'try') || - staticString(p.key) === 'try'), - ); - if (property?.type !== 'Property') return false; - thunk = property.value as ESTree.Argument; - } - if (thunk.type !== 'ArrowFunctionExpression' && thunk.type !== 'FunctionExpression') - return false; - if (thunk.body === null) return false; - let body: ESTree.Node = thunk.body; - if (body.type === 'BlockStatement') { - if ( - body.body.length !== 1 || - body.body[0]?.type !== 'ReturnStatement' || - body.body[0].argument === null - ) - return false; - body = body.body[0].argument; - } + let body = returnedBody(bridgeThunk(call)); + if (body === null) return false; body = identityUnwrap(body); if (body.type === 'AwaitExpression') body = identityUnwrap(body.argument); return ( diff --git a/app/tools/oxlint/effect-native/shared/ast.ts b/app/tools/oxlint/effect-native/shared/ast.ts new file mode 100644 index 000000000..a8b747e3d --- /dev/null +++ b/app/tools/oxlint/effect-native/shared/ast.ts @@ -0,0 +1,291 @@ +import type { ESTree } from '@oxlint/plugins'; + +/** The permissive ESTree view used by the lexical rules (including parser extensions). */ +export type Syntax = ESTree.Node & Record; + +export const EXPRESSION_WRAPPERS: ReadonlySet = new Set([ + 'ParenthesizedExpression', + 'ChainExpression', + 'TSAsExpression', + 'TSSatisfiesExpression', + 'TSNonNullExpression', + 'TSInstantiationExpression', + 'TSTypeAssertion', +]); +export const FUNCTION_TYPES: ReadonlySet = new Set([ + 'ArrowFunctionExpression', + 'FunctionDeclaration', + 'FunctionExpression', +]); + +export function isNode(value: unknown): value is Syntax { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { type?: unknown }).type === 'string' + ); +} + +/** requireStart preserves the stricter generator-walker node guard. */ +export function asNode(value: unknown, requireStart = false): Syntax | null { + if (!isNode(value)) return null; + return requireStart && typeof value.start !== 'number' ? null : value; +} + +export function parentOf(node: unknown): Syntax | null { + return asNode(asNode(node)?.parent); +} + +export interface UnwrapOptions { + /** Override the wrapper kinds; callers with intentionally narrower syntax keep their set. */ + readonly wrappers?: ReadonlySet; + readonly maxDepth?: number; + /** Generator walker copies validate spans on both the input and every wrapper child. */ + readonly requireStart?: boolean; + /** Await is transparent only for the script provenance family. */ + readonly await?: boolean; + /** Sequence-last semantics are enabled only for identity/JSON rules. */ + readonly sequence?: boolean; + /** Some unknown-node walkers accept argument as a fallback for expression. */ + readonly argumentFallback?: boolean; +} + +function innerExpression(node: Syntax, options: UnwrapOptions): Syntax | null { + if (options.sequence && node.type === 'SequenceExpression') + return asNode(node.expressions.at(-1), options.requireStart); + if (options.await && node.type === 'AwaitExpression') + return asNode(node.argument, options.requireStart); + if (!(options.wrappers ?? EXPRESSION_WRAPPERS).has(node.type)) return null; + return asNode(options.argumentFallback ? (node.expression ?? node.argument) : node.expression); +} + +/** Returns the last valid node if a malformed wrapper has no expression. */ +export function unwrapNode(node: ESTree.Node, options: UnwrapOptions = {}): Syntax { + let current = node as Syntax; + for (let depth = 0; depth < (options.maxDepth ?? Infinity); depth += 1) { + const inner = innerExpression(current, options); + if (inner === null) return current; + current = inner; + } + return current; +} + +/** Nullable/unknown input variant of unwrapNode; malformed wrapper children retain the last valid node. */ +export function unwrap(value: unknown, options: UnwrapOptions = {}): Syntax | null { + const node = asNode(value, options.requireStart); + return node === null ? null : unwrapNode(node, options); +} + +/** Unknown-input variant; malformed wrappers return null, matching the script syntax helper. */ +export function syntax(value: unknown): Syntax | null { + let node = asNode(value); + while (node !== null && (EXPRESSION_WRAPPERS.has(node.type) || node.type === 'AwaitExpression')) { + node = asNode(node.expression ?? node.argument); + } + return node; +} + +export function identityUnwrap(node: ESTree.Node): ESTree.Node { + return unwrapNode(node, { sequence: true }); +} + +export function skipWrappers( + node: ESTree.Node, + wrappers = EXPRESSION_WRAPPERS, +): { + readonly node: Syntax; + readonly parent: Syntax | null; +} { + let current = node as Syntax; + let parent = parentOf(current); + while (parent !== null && wrappers.has(parent.type)) { + current = parent; + parent = parentOf(current); + } + return { node: current, parent }; +} + +export interface StringOptions { + readonly templates?: boolean; + readonly babelStrings?: boolean; + readonly rawTemplates?: boolean; + readonly singleQuasi?: boolean; + readonly unwrap?: UnwrapOptions; +} + +function stringNode(value: unknown, options: StringOptions): Syntax | null { + const node = asNode(value); + return node && options.unwrap ? unwrapNode(node, options.unwrap) : node; +} +function isStringLiteral(node: Syntax, options: StringOptions): boolean { + return ( + node.type === 'Literal' || + (options.babelStrings === true && (node.type as string) === 'StringLiteral') + ); +} + +/** String literals and, by default, interpolation-free cooked templates; never dynamic keys. */ +export function staticString(value: unknown, options: StringOptions = {}): string | null { + const node = stringNode(value, options); + if (!node) return null; + if (isStringLiteral(node, options)) { + return typeof node.value === 'string' ? node.value : null; + } + if ( + options.templates !== false && + node.type === 'TemplateLiteral' && + node.expressions.length === 0 + ) { + return templateText(node, options.rawTemplates === true, options.singleQuasi === true); + } + return null; +} + +export function literalText(value: unknown): string | null { + return staticString(syntax(value)); +} + +export function keyName( + value: unknown, + computed = false, + options: StringOptions = {}, +): string | null { + const input = asNode(value); + const key = input && options.unwrap ? unwrapNode(input, options.unwrap) : input; + if (!computed && key?.type === 'Identifier') return key.name; + return staticString(key, options); +} + +/** Defaults to literal-only computed keys; opt into templates/unwrap to preserve wider copies. */ +export function memberName( + node: unknown, + options: StringOptions = { templates: false }, +): string | null { + const member = asNode(node); + return member ? keyName(member.property, member.computed === true, options) : null; +} + +/** Script provenance permits keys and properties, awaited wrappers and cooked templates. */ +export function propertyText(value: unknown): string | null { + const node = asNode(value); + if (!node) return null; + return keyName(syntax(node.property ?? node.key), node.computed === true); +} + +export function nearestFunction(node: unknown, kinds = FUNCTION_TYPES): Syntax | null { + let current = parentOf(node); + while (current !== null) { + if (kinds.has(current.type)) return current; + current = parentOf(current); + } + return null; +} + +/** Unwrap parameter binding wrappers only; the original copies stop after four steps. */ +export function unwrapBinding(node: ESTree.Node, maxDepth = 4): Syntax { + let current = node as Syntax; + const kinds = new Set(['AssignmentPattern', 'RestElement', 'TSParameterProperty']); + for (let depth = 0; depth < maxDepth; depth += 1) { + if (!kinds.has(current.type)) return current; + const inner = asNode(current.left ?? current.argument ?? current.parameter); + if (!inner) return current; + current = inner; + } + return current; +} + +export function childrenOf( + node: ESTree.Node, + visitorKeys: Readonly>, + requireStart = true, +): Syntax[] { + const record = node as Syntax; + const names = + visitorKeys[node.type] ?? Object.keys(node).filter((key) => key !== 'parent' && key !== 'type'); + return names.flatMap((name) => { + const value = record[name]; + const values: unknown[] = Array.isArray(value) ? value : [value]; + return values.map((entry) => asNode(entry, requireStart)).filter((entry) => entry !== null); + }); +} + +/** Pre-order walk; false skips children. requireStart matches the generator walkers by default. */ +export function walk( + node: ESTree.Node, + visitorKeys: Readonly>, + visit: (node: Syntax) => boolean | void, + requireStart = true, +): void { + const stack: Syntax[] = [node as Syntax]; + while (stack.length > 0) { + const current = stack.pop()!; + if (visit(current) === false) continue; + stack.push(...childrenOf(current, visitorKeys, requireStart).reverse()); + } +} + +/** Dotted TS names, without expression evaluation. */ +export function typeNameSegments(name: ESTree.TSTypeName): readonly string[] | null { + if (name.type === 'Identifier') return [name.name]; + if (name.type !== 'TSQualifiedName') return null; + const left = typeNameSegments(name.left); + return left === null ? null : [...left, name.right.name]; +} + +export function isFunctionNode(node: unknown, kinds = FUNCTION_TYPES): boolean { + const candidate = asNode(node); + return candidate !== null && kinds.has(candidate.type); +} + +export interface TypeUnwrapOptions { + readonly maxDepth?: number; + /** Parens only by default; broader dependency wrappers must be passed explicitly. */ + readonly wrappers?: ReadonlySet; + readonly readonlyOperator?: boolean; + readonly elementTypeFallback?: boolean; +} +function innerType(node: Syntax, options: TypeUnwrapOptions): Syntax | null { + const readonly = + options.readonlyOperator && node.type === 'TSTypeOperator' && node.operator === 'readonly'; + if (!readonly && !(options.wrappers ?? TYPE_WRAPPERS).has(node.type)) return null; + return asNode( + options.elementTypeFallback ? (node.typeAnnotation ?? node.elementType) : node.typeAnnotation, + ); +} +const TYPE_WRAPPERS: ReadonlySet = new Set(['TSParenthesizedType']); +export function unwrapType(node: ESTree.Node, options: TypeUnwrapOptions = {}): Syntax { + let current = node as Syntax; + for (let depth = 0; depth < (options.maxDepth ?? 8); depth += 1) { + const inner = innerType(current, options); + if (!inner) return current; + current = inner; + } + return current; +} + +/** Raw fallback/single-quasi defaults preserve the two tag-inspection copies; staticString opts out. */ +export function templateText( + node: ESTree.TemplateLiteral, + rawFallback = true, + requireSingleQuasi = true, +): string | null { + if (requireSingleQuasi && node.quasis.length !== 1) return null; + const quasi = node.quasis[0]; + if (!quasi) return null; + return quasi.value.cooked ?? (rawFallback ? quasi.value.raw : null); +} + +/** Static member matching with an explicit computed-key resolver for lexical constant aliases. */ +export function asNamedMember( + input: ESTree.Node, + name: string, + resolveComputed: (key: ESTree.Node) => string | null, + options: UnwrapOptions = {}, +): ESTree.MemberExpression | null { + const node = unwrapNode(input, options); + if (node.type !== 'MemberExpression') return null; + if (!node.computed) + return node.property.type === 'Identifier' && node.property.name === name ? node : null; + if ((node.property.type as string) === 'PrivateIdentifier') return null; + return resolveComputed(node.property) === name ? node : null; +} diff --git a/app/tools/oxlint/effect-native/shared/bindings.ts b/app/tools/oxlint/effect-native/shared/bindings.ts new file mode 100644 index 000000000..3485b363f --- /dev/null +++ b/app/tools/oxlint/effect-native/shared/bindings.ts @@ -0,0 +1,83 @@ +import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import { asNode } from './ast.ts'; + +type Definition = Variable['defs'][number]; + +export function resolveVariable( + context: Context, + name: string, + from: ESTree.Node, +): Variable | null { + let scope: Scope | null = context.sourceCode.getScope(from); + while (scope !== null) { + const variable = scope.set.get(name); + if (variable !== undefined) return variable; + scope = scope.upper; + } + return null; +} +export function lookupVariable(context: Context, identifier: ESTree.Node): Variable | null { + return identifier.type === 'Identifier' + ? resolveVariable(context, identifier.name, identifier) + : null; +} +function isValueImport(definition: Definition): boolean { + if (definition.type !== 'ImportBinding') return false; + return ( + definition.parent?.type === 'ImportDeclaration' && + definition.parent.importKind !== 'type' && + (definition.node.type !== 'ImportSpecifier' || definition.node.importKind !== 'type') + ); +} +function isValueDefinition(definition: Definition): boolean { + if ((definition.type as string) === 'Type') return false; + if (definition.type !== 'ImportBinding') return true; + return ( + asNode(definition.node)?.importKind !== 'type' && + asNode(definition.parent)?.importKind !== 'type' + ); +} +/** ignoreTypeOnly=false preserves the older all-definitions shadow check; JSON rules use true. */ +export function isUnshadowedGlobal( + context: Context, + node: ESTree.Node, + name: string, + ignoreTypeOnly = false, +): boolean { + if (node.type !== 'Identifier' || node.name !== name) return false; + if (!ignoreTypeOnly) { + const variable = resolveVariable(context, name, node); + return variable === null || variable.defs.length === 0; + } + let scope: Scope | null = context.sourceCode.getScope(node); + while (scope !== null) { + if (scope.set.get(name)?.defs.some(isValueDefinition)) return false; + scope = scope.upper; + } + return true; +} +/** Unresolved identifiers remain true: callers must already have established a module import. */ +export function resolvesToImport( + context: Context, + identifier: ESTree.Node, + valueOnly = false, +): boolean { + const variable = lookupVariable(context, identifier); + if (variable === null || variable.defs.length === 0) return true; + return variable.defs.some( + valueOnly ? isValueImport : (definition) => definition.type === 'ImportBinding', + ); +} + +/** Declaration-based identity. A caller chooses object identity or span equality explicitly. */ +export function isTrackedReference( + context: Context, + identifier: ESTree.Node, + declaration: ESTree.Node, + sameDeclaration: (left: ESTree.Node, right: ESTree.Node) => boolean = Object.is, +): boolean { + if (identifier.type !== 'Identifier') return false; + const variable = lookupVariable(context, identifier); + if (!variable) return true; + return variable.defs.some((definition) => sameDeclaration(definition.name, declaration)); +} diff --git a/app/tools/oxlint/effect-native/shared/effect-identity.ts b/app/tools/oxlint/effect-native/shared/effect-identity.ts new file mode 100644 index 000000000..3eaf6502f --- /dev/null +++ b/app/tools/oxlint/effect-native/shared/effect-identity.ts @@ -0,0 +1,197 @@ +import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; +import { asNode, identityUnwrap, keyName, unwrapNode, type Syntax } from './ast.ts'; +import { lookupVariable } from './bindings.ts'; +import { matchesGlobs } from './paths.ts'; + +type Definition = Variable['defs'][number]; +interface OriginPolicy { + readonly barrels: readonly string[]; + readonly legacyOrigin: boolean; +} +interface OriginState { + readonly policy: OriginPolicy; + readonly seen: Set; + readonly depth: number; +} +const TYPE_DECLARATIONS = new Set([ + 'TSInterfaceDeclaration', + 'TSTypeAliasDeclaration', + 'TSTypeParameter', +]); +function valueDefinitions(variable: Variable): Definition[] { + return variable.defs.filter((definition) => !TYPE_DECLARATIONS.has(definition.node.type)); +} +function originVariable( + context: Context, + node: ESTree.Node, + legacy: boolean, +): { variable: Variable; definitions: readonly Definition[] } | null { + if (!legacy) { + const variable = lookupVariable(context, node); + return variable ? { variable, definitions: variable.defs } : null; + } + if (node.type !== 'Identifier') return null; + let scope: Scope | null = context.sourceCode.getScope(node); + while (scope) { + const variable = scope.set.get(node.name); + const definitions = variable ? valueDefinitions(variable) : []; + if (variable && definitions.length > 0) return { variable, definitions }; + scope = scope.upper; + } + return null; +} +function moduleBase(source: string, policy: OriginPolicy): string[] | null { + const root = + source === 'effect' || + (policy.legacyOrigin ? matchesGlobs(source, policy.barrels) : policy.barrels.includes(source)); + if (!root && !source.startsWith('effect/')) return null; + if (policy.legacyOrigin && root) return []; + if (!source.startsWith('effect/')) return []; + const last = source.split('/').at(-1)!; + return policy.legacyOrigin || /^[A-Z]/u.test(last) ? [last] : []; +} +function importPath(definition: Definition, policy: OriginPolicy): readonly string[] | null { + const spec = definition.node; + const parent = definition.parent; + const declaration = + policy.legacyOrigin && parent?.type !== 'ImportDeclaration' ? spec.parent : parent; + if (declaration?.type !== 'ImportDeclaration' || declaration.importKind === 'type') return null; + if (asNode(spec)?.importKind === 'type') return null; + const base = moduleBase(declaration.source.value, policy); + if (base === null) return null; + return importedPath(spec, base, policy.legacyOrigin); +} +function importedPath( + spec: ESTree.Node, + base: readonly string[], + legacy: boolean, +): readonly string[] | null { + if (spec.type === 'ImportNamespaceSpecifier') return base; + if (spec.type === 'ImportDefaultSpecifier') return legacy ? base : null; + if (spec.type !== 'ImportSpecifier') return null; + return [...base, spec.imported.type === 'Identifier' ? spec.imported.name : spec.imported.value]; +} + +/** Flat identifier destructuring only: defaults/nested patterns are intentionally not inferred. */ +function flatBindingKey(pattern: ESTree.Node, name: string): string | null { + if (pattern.type !== 'ObjectPattern') return null; + for (const property of pattern.properties) { + if ( + property.type !== 'Property' || + property.value.type !== 'Identifier' || + property.value.name !== name + ) + continue; + return keyName(property.key, property.computed); + } + return null; +} +function aliasDeclaration( + definition: Definition, + variable: Variable, + legacy: boolean, +): ESTree.VariableDeclarator | null { + if (!legacy && definition.type !== 'Variable') return null; + if (definition.node.type !== 'VariableDeclarator' || !definition.node.init) return null; + const declaration = definition.node; + const parent = legacy ? declaration.parent : definition.parent; + if (parent?.type !== 'VariableDeclaration' || parent.kind !== 'const') return null; + if (hasDisallowedWrites(variable, legacy)) return null; + return declaration; +} +function hasDisallowedWrites(variable: Variable, legacy: boolean): boolean { + return legacy && variable.references.some((reference) => reference.isWrite() && !reference.init); +} +function nextState(state: OriginState): OriginState { + return { ...state, depth: state.depth + 1 }; +} +function aliasPath( + context: Context, + node: Syntax, + definition: Definition, + variable: Variable, + state: OriginState, +): readonly string[] | null { + const declaration = aliasDeclaration(definition, variable, state.policy.legacyOrigin); + if (!declaration?.init) return null; + const base = resolveOrigin(context, declaration.init, nextState(state)); + if (base === null) return null; + if (declaration.id.type === 'Identifier') return base; + const key = flatBindingKey(declaration.id, node.name); + return key === null ? null : [...base, key]; +} +function identifierPath( + context: Context, + node: Syntax, + state: OriginState, +): readonly string[] | null { + const found = originVariable(context, node, state.policy.legacyOrigin); + if (!found || found.definitions.length !== 1) return null; + if (!state.policy.legacyOrigin && state.seen.has(found.variable)) return null; + state.seen.add(found.variable); + const definition = found.definitions[0]!; + return definition.type === 'ImportBinding' + ? importPath(definition, state.policy) + : aliasPath(context, node, definition, found.variable, state); +} +function resolveOrigin( + context: Context, + input: ESTree.Node, + state: OriginState, +): readonly string[] | null { + if (state.policy.legacyOrigin && state.depth > 24) return null; + const node = state.policy.legacyOrigin ? unwrapNode(input) : identityUnwrap(input); + if (node.type === 'MemberExpression') { + const key = keyName(node.property, node.computed); + const base = resolveOrigin(context, node.object, nextState(state)); + return base !== null && key !== null ? [...base, key] : null; + } + return node.type === 'Identifier' ? identifierPath(context, node as Syntax, state) : null; +} + +/** Generator/concurrency identity: sequence-last; exact extra modules; uppercase Effect submodules; + * namespace/named imports; const aliases. Preserves the original shared seen-set behavior. + */ +export function bindingPath( + context: Context, + expression: ESTree.Node, + extraModules: readonly string[] = [], + seen = new Set(), +): readonly string[] | null { + return resolveOrigin(context, expression, { + policy: { barrels: extraModules, legacyOrigin: false }, + seen, + depth: 0, + }); +} + +/** Failure-rule identity: glob barrels; default imports; value-namespace lookup; reject writes; + * no sequence-last; bounded at 24 alias/member hops. Deliberately distinct from bindingPath. + */ +export function effectOrigin( + context: Context, + input: ESTree.Node, + barrels: readonly string[], + depth = 0, +): readonly string[] | null { + return resolveOrigin(context, input, { + policy: { barrels, legacyOrigin: true }, + seen: new Set(), + depth, + }); +} + +/** Curried Effect.fn/gen callees peel calls, then use generator-family runtime identity. */ +export function isGenCallee( + context: Context, + input: ESTree.Node | null, + members: readonly string[], + extraModules: readonly string[] = [], +): boolean { + if (input === null) return false; + const target = identityUnwrap(input); + if (target.type === 'CallExpression') + return isGenCallee(context, target.callee, members, extraModules); + const path = bindingPath(context, target, extraModules); + return path?.length === 2 && path[0] === 'Effect' && members.includes(path[1] ?? ''); +} diff --git a/app/tools/oxlint/effect-native/shared/effect-imports.ts b/app/tools/oxlint/effect-native/shared/effect-imports.ts index 9b7a95906..7119283ba 100644 --- a/app/tools/oxlint/effect-native/shared/effect-imports.ts +++ b/app/tools/oxlint/effect-native/shared/effect-imports.ts @@ -22,26 +22,33 @@ export function collectEffectBindings(program: ESTree.Program): EffectBindings { if (!EFFECT_MODULE.test(statement.source.value)) continue; importsEffect = true; const submodule = statement.source.value.split('/').at(-1); - for (const specifier of statement.specifiers) { - if (specifier.type === 'ImportSpecifier') { - const imported = - specifier.imported.type === 'Identifier' - ? specifier.imported.name - : specifier.imported.value; - namespaces.set(specifier.local.name, imported); - } else if ( - specifier.type === 'ImportNamespaceSpecifier' && - submodule !== undefined && - submodule !== 'effect' - ) { - // `import * as Schema from "effect/Schema"` binds the whole submodule as a namespace. - namespaces.set(specifier.local.name, submodule); - } - } + addEffectSpecifiers(namespaces, statement.specifiers, submodule); } return { namespaces, importsEffect }; } +function addEffectSpecifiers( + namespaces: Map, + specifiers: ESTree.ImportDeclaration['specifiers'], + submodule: string | undefined, +): void { + for (const specifier of specifiers) { + if (specifier.type === 'ImportSpecifier') { + const imported = + specifier.imported.type === 'Identifier' + ? specifier.imported.name + : specifier.imported.value; + namespaces.set(specifier.local.name, imported); + } else if ( + specifier.type === 'ImportNamespaceSpecifier' && + submodule !== undefined && + submodule !== 'effect' + ) { + namespaces.set(specifier.local.name, submodule); + } + } +} + /** `Effect.runPromise` → `{ namespace: "Effect", member: "runPromise" }` when `Effect` is an effect import. */ export function effectMember( node: ESTree.Node, diff --git a/app/tools/oxlint/effect-native/shared/imports.ts b/app/tools/oxlint/effect-native/shared/imports.ts new file mode 100644 index 000000000..0a058c7d5 --- /dev/null +++ b/app/tools/oxlint/effect-native/shared/imports.ts @@ -0,0 +1,198 @@ +import type { ESTree } from '@oxlint/plugins'; +import { collectEffectBindings, type EffectBindings } from './effect-imports.ts'; +import { matchesGlobs } from './paths.ts'; + +export function importedName(specifier: ESTree.ImportSpecifier): string { + return specifier.imported.type === 'Identifier' + ? specifier.imported.name + : specifier.imported.value; +} + +export interface ImportPolicy { + /** Older syntax-only collectors include type imports; runtime collectors explicitly opt out. */ + readonly valueOnly?: boolean; + /** Runtime construction tracks inline type-only locals separately. */ + readonly excludedLocals?: ReadonlySet; +} + +/** Import filtering is caller-selected: exact modules, globs and submodule regexes are not equivalent. */ +export function importDeclarations( + program: ESTree.Program, + accepts: (source: string) => boolean, + policy: ImportPolicy = {}, +): ESTree.ImportDeclaration[] { + return program.body.filter( + (statement): statement is ESTree.ImportDeclaration => + statement.type === 'ImportDeclaration' && + !(policy.valueOnly && statement.importKind === 'type') && + accepts(statement.source.value), + ); +} + +function allowedSpecifier( + specifier: ESTree.ImportDeclaration['specifiers'][number], + policy: ImportPolicy, +): boolean { + if (policy.excludedLocals?.has(specifier.local.name)) return false; + return !( + policy.valueOnly && + specifier.type === 'ImportSpecifier' && + specifier.importKind === 'type' + ); +} + +/** Namespace locals for caller-selected root sources; no default imports or automatic submodule matching. */ +export function collectRootNamespaces( + program: ESTree.Program, + accepts: (source: string) => boolean = (source) => source === 'effect', + policy: ImportPolicy = {}, +): Set { + const locals = new Set(); + for (const declaration of importDeclarations(program, accepts, policy)) { + for (const specifier of declaration.specifiers) { + if (specifier.type === 'ImportNamespaceSpecifier' && allowedSpecifier(specifier, policy)) + locals.add(specifier.local.name); + } + } + return locals; +} + +/** Named import map with caller-selected source and exported-member filters. */ +export function collectNamedImports( + program: ESTree.Program, + accepts: (source: string) => boolean, + members?: ReadonlySet, + policy: ImportPolicy = {}, +): Map { + const locals = new Map(); + for (const declaration of importDeclarations(program, accepts, policy)) { + for (const specifier of declaration.specifiers) { + if (specifier.type !== 'ImportSpecifier' || !allowedSpecifier(specifier, policy)) continue; + const name = importedName(specifier); + if (!members || members.has(name)) locals.set(specifier.local.name, name); + } + } + return locals; +} + +export interface NamespaceMember { + readonly namespace: string; + readonly member: string; +} + +/** Returns the trailing Effect submodule identifier, including unstable nested submodules. */ +function effectSubmodule(source: string): string | null { + return /^effect\/(?:.*\/)?([A-Za-z][A-Za-z0-9_]*)$/u.exec(source)?.[1] ?? null; +} + +function addDirectMembers( + locals: Map, + declaration: ESTree.ImportDeclaration, + namespace: string, + members: ReadonlySet | undefined, + policy: ImportPolicy, +): void { + for (const specifier of declaration.specifiers) { + if (specifier.type !== 'ImportSpecifier' || !allowedSpecifier(specifier, policy)) continue; + const member = importedName(specifier); + if (!members || members.has(member)) locals.set(specifier.local.name, { namespace, member }); + } +} + +/** Typed member records; map values to `${namespace}.${member}`, member alone, or keys as needed. + * Supplying byNamespace excludes unlisted namespaces. resolveNamespace preserves narrower regex copies. + */ +export function collectDirectMemberImports( + program: ESTree.Program, + byNamespace?: ReadonlyMap>, + policy: ImportPolicy = {}, + resolveNamespace = effectSubmodule, +): Map { + const locals = new Map(); + for (const declaration of importDeclarations(program, () => true, policy)) { + const namespace = resolveNamespace(declaration.source.value); + if (namespace === null) continue; + const members = byNamespace?.get(namespace); + if (byNamespace && !members) continue; + addDirectMembers(locals, declaration, namespace, members, policy); + } + return locals; +} + +export function splitMembers(members: readonly string[]): { + byNamespace: Map>; + namespaces: Set; +} { + const byNamespace = new Map>(); + for (const entry of members) { + const dot = entry.indexOf('.'); + if (dot <= 0 || dot === entry.length - 1) continue; + const namespace = entry.slice(0, dot); + const bucket = byNamespace.get(namespace) ?? new Set(); + bucket.add(entry.slice(dot + 1)); + byNamespace.set(namespace, bucket); + } + return { byNamespace, namespaces: new Set(byNamespace.keys()) }; +} + +/** Existing Effect namespaces plus watched named exports and namespace imports of root/glob barrels. */ +export function collectNamespaceLocals( + program: ESTree.Program, + bindings: EffectBindings, + watched: ReadonlySet, + reexportModules: readonly string[], + policy: ImportPolicy = {}, +): { namespaced: Map; barrel: Set } { + const namespaced = new Map( + [...bindings.namespaces].filter(([, namespace]) => watched.has(namespace)), + ); + const accepts = (source: string) => source === 'effect' || matchesGlobs(source, reexportModules); + for (const [local, name] of collectNamedImports(program, accepts, watched, policy)) + namespaced.set(local, name); + return { namespaced, barrel: collectRootNamespaces(program, accepts, policy) }; +} + +/** Generator-family exact barrels add only named Effect exports; retains original type-import policy. */ +export function bindingsWithExtraModules( + program: ESTree.Program, + modules: readonly string[], +): EffectBindings { + const base = collectEffectBindings(program); + if (modules.length === 0) return base; + const extra = collectNamedImports( + program, + (source) => modules.includes(source), + new Set(['Effect']), + ); + return { + namespaces: new Map([...base.namespaces, ...extra]), + importsEffect: base.importsEffect || extra.size > 0, + }; +} + +/** Schema locals for the literal-vocabulary/interface-codec family. Submodule imports take priority + * over overlapping barrel globs; type imports remain enabled unless policy opts out. + */ +export function collectSchemaLocals( + program: ESTree.Program, + bindings: EffectBindings, + reexportModules: readonly string[] = [], + policy: ImportPolicy = {}, +): { schema: Set; barrel: Set; direct: Map } { + const isSchema = (source: string) => /^effect\/(?:.*\/)?Schema$/u.test(source); + const isRoot = (source: string) => + !isSchema(source) && (source === 'effect' || matchesGlobs(source, reexportModules)); + const schema = new Set( + [...bindings.namespaces] + .filter(([, namespace]) => namespace === 'Schema') + .map(([local]) => local), + ); + for (const local of collectRootNamespaces(program, isSchema, policy)) schema.add(local); + for (const local of collectNamedImports(program, isRoot, new Set(['Schema']), policy).keys()) + schema.add(local); + return { + schema, + barrel: collectRootNamespaces(program, isRoot, policy), + direct: collectNamedImports(program, isSchema, undefined, policy), + }; +} diff --git a/app/tools/oxlint/effect-native/shared/json-globals.ts b/app/tools/oxlint/effect-native/shared/json-globals.ts new file mode 100644 index 000000000..47b1ed48f --- /dev/null +++ b/app/tools/oxlint/effect-native/shared/json-globals.ts @@ -0,0 +1,28 @@ +import type { Context, ESTree } from '@oxlint/plugins'; + +import { isUnshadowedGlobal } from './bindings.ts'; + +interface JsonHostOptions { + readonly containers: ReadonlySet; + readonly unwrap: (node: ESTree.Node) => ESTree.Node; + readonly memberName: (node: ESTree.MemberExpression) => string | null; +} + +/** Preserve each JSON rule's supported wrappers, keys, and global containers. */ +export function isJsonHost(context: Context, node: ESTree.Node, options: JsonHostOptions): boolean { + const host = options.unwrap(node); + if (host.type === 'Identifier') return isUnshadowedGlobal(context, host, 'JSON', true); + if (host.type !== 'MemberExpression' || options.memberName(host) !== 'JSON') return false; + const container = options.unwrap(host.object); + return ( + container.type === 'Identifier' && + options.containers.has(container.name) && + isUnshadowedGlobal(context, container, container.name, true) + ); +} + +/** JSON diagnostics retain their original 72-character budget and ASCII suffix. */ +export function jsonExpressionSnippet(text: string): string { + const flat = text.replace(/\s+/gu, ' ').trim(); + return flat.length > 72 ? `${flat.slice(0, 69)}...` : flat; +} diff --git a/app/tools/oxlint/effect-native/shared/json-rule-scope.ts b/app/tools/oxlint/effect-native/shared/json-rule-scope.ts new file mode 100644 index 000000000..f7545f27a --- /dev/null +++ b/app/tools/oxlint/effect-native/shared/json-rule-scope.ts @@ -0,0 +1,21 @@ +import { booleanOption, stringList } from './options.ts'; +import { isTestFile, matchesAny, workspacePath } from './paths.ts'; + +/** Both native JSON rules share option semantics but retain their own default include paths. */ +export function inJsonRuleScope( + filename: string, + raw: unknown, + defaultIncludePaths: readonly string[], +): boolean { + const given = (raw ?? {}) as Partial<{ + includePaths: unknown; + allowPaths: unknown; + ignoreTestFiles: unknown; + }>; + const configuredPaths = stringList(given.includePaths, defaultIncludePaths); + const includePaths = configuredPaths.length > 0 ? configuredPaths : defaultIncludePaths; + const path = workspacePath(filename); + if (!matchesAny(path, includePaths)) return false; + if (matchesAny(path, stringList(given.allowPaths, []))) return false; + return !booleanOption(given.ignoreTestFiles, true) || !isTestFile(path); +} diff --git a/app/tools/oxlint/effect-native/shared/options.ts b/app/tools/oxlint/effect-native/shared/options.ts new file mode 100644 index 000000000..88c50575d --- /dev/null +++ b/app/tools/oxlint/effect-native/shared/options.ts @@ -0,0 +1,50 @@ +/** Option parsers deliberately reject mixed arrays instead of silently dropping invalid entries. */ +export function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { + if (!Array.isArray(value)) return fallback; + const entries = value.filter((entry): entry is string => typeof entry === 'string'); + return entries.length === value.length ? entries : fallback; +} + +/** every-mode preserves the old stringList helper's treatment of sparse arrays. */ +export function stringList(value: unknown, fallback: readonly string[]): readonly string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === 'string') + ? value + : fallback; +} + +export function optionRecord(value: unknown): Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} +export function booleanOption(value: unknown, fallback: boolean): boolean { + return typeof value === 'boolean' ? value : fallback; +} +export function positiveInteger(value: unknown, fallback: number, minimum = 1): number { + return typeof value === 'number' && Number.isInteger(value) && value >= minimum + ? value + : fallback; +} +export function stringOption(value: unknown, fallback: string, allowEmpty = true): string { + return typeof value === 'string' && (allowEmpty || value.length > 0) ? value : fallback; +} +/** Invalid configured regexes use the caller's known-good fallback, retaining its flags. + * compile treats empty input as missing; safeRegExp preserves the valid empty expression. + */ +export function compile(value: unknown, fallback: string, flags = 'u'): RegExp { + return safeRegExp(stringOption(value, fallback, false), fallback, flags); +} +function tryRegExp(source: string, flags: string): RegExp | null { + try { + return new RegExp(source, flags); + } catch { + return null; + } +} +export function safeRegExp(source: string, fallback: string, flags = 'u'): RegExp { + return tryRegExp(source, flags) ?? new RegExp(fallback, flags); +} +/** Invalid user patterns are omitted, not replaced with a match-all expression. */ +export function compilePatterns(sources: readonly string[], flags = 'gu'): readonly RegExp[] { + return sources.map((source) => tryRegExp(source, flags)).filter((pattern) => pattern !== null); +} diff --git a/app/tools/oxlint/effect-native/shared/paths.ts b/app/tools/oxlint/effect-native/shared/paths.ts index 3e8d534bf..0c26fb5ee 100644 --- a/app/tools/oxlint/effect-native/shared/paths.ts +++ b/app/tools/oxlint/effect-native/shared/paths.ts @@ -10,26 +10,26 @@ export function globToRegExp(glob: string): RegExp { index += slashAfter ? 3 : 2; continue; } - if (char === '*') pattern += '[^/]*'; - else if (char === '?') pattern += '[^/]'; - else if (char === '{') { - const close = glob.indexOf('}', index); - if (close === -1) pattern += '\\{'; - else { - const options = glob - .slice(index + 1, close) - .split(',') - .map(escapeRegExp); - pattern += `(?:${options.join('|')})`; - index = close + 1; - continue; - } - } else pattern += escapeRegExp(char); - index += 1; + const part = globPart(glob, index, char); + pattern += part.pattern; + index = part.next; } return new RegExp(`^${pattern}$`, 'u'); } +function globPart(glob: string, index: number, char: string): { pattern: string; next: number } { + if (char === '*') return { pattern: '[^/]*', next: index + 1 }; + if (char === '?') return { pattern: '[^/]', next: index + 1 }; + if (char !== '{') return { pattern: escapeRegExp(char), next: index + 1 }; + const close = glob.indexOf('}', index); + if (close === -1) return { pattern: '\\{', next: index + 1 }; + const options = glob + .slice(index + 1, close) + .split(',') + .map(escapeRegExp); + return { pattern: `(?:${options.join('|')})`, next: close + 1 }; +} + function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'); } @@ -60,3 +60,72 @@ export function isTestFile(filename: string): boolean { export function isScriptFile(filename: string): boolean { return /(?:^|\/)scripts\//u.test(normalisePath(filename)); } + +const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; + +/** Legacy source-rule normalization; scriptScope intentionally has different nested-path semantics. */ +export function scopePath(filename: string): string { + return normalisePath(filename).replace(FIXTURE_PREFIX, ''); +} + +/** Match an already-normalized path without applying normalisePath a second time. */ +export function matchesGlobs(path: string, globs: readonly string[]): boolean { + return globs.some((glob) => globToRegExp(glob).test(path)); +} + +/** Strip fixture scaffolding first; never renormalize a relative script path around inner markers. */ +export function scriptScope(filename: string): string { + const unified = filename.replaceAll('\\', '/'); + const fixture = unified.match( + /(?:^|\/)tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\/(.*)$/u, + ); + if (fixture) return fixture[1]; + if (!unified.startsWith('/') && !/^[A-Za-z]:\//u.test(unified)) + return unified.replace(/^\.\//u, ''); + const match = unified.match(/(?:^|\/)((?:apps|packages|verticals|scripts|tools)\/.*)$/u); + return match?.[1] ?? unified; +} + +export function inScriptScope(path: string): boolean { + return /(?:^|\/)scripts\//u.test(path) && !TEST_PATH.test(path); +} + +/** Last workspace marker wins (unlike normalisePath/scopePath); callers can preserve their marker list. */ +export function workspacePath( + filename: string, + markers: readonly string[] = ['/apps/', '/verticals/', '/packages/', '/scripts/'], +): string { + const unified = filename.replaceAll('\\', '/'); + let best = -1; + for (const marker of markers) best = Math.max(best, unified.lastIndexOf(marker)); + return best === -1 ? normalisePath(unified) : unified.slice(best + 1); +} + +/** Fixture-first normalization with an explicit repository root, retaining nested markers. */ +export function rootedScopePath(filename: string, root: string): string { + const unified = filename.replaceAll('\\', '/'); + const fixture = + /(?:^|\/)tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\/(.*)$/u.exec(unified); + if (fixture?.[1]) return fixture[1]; + const normalizedRoot = root.replaceAll('\\', '/'); + return unified.startsWith(normalizedRoot) + ? unified.slice(normalizedRoot.length) + : scopePath(unified); +} + +/** Common source-rule policy; retain fixture-aware and legacy glob normalization. */ +export function includesRuleFile( + filename: string, + options: { + includePaths: readonly string[]; + allowPaths: readonly string[]; + ignoreTestFiles: boolean; + }, +): boolean { + const path = `/${scopePath(filename)}`; + return ( + matchesAny(path, options.includePaths) && + !matchesAny(path, options.allowPaths) && + !(options.ignoreTestFiles && isTestFile(path)) + ); +} diff --git a/app/tools/oxlint/effect-native/shared/provenance.ts b/app/tools/oxlint/effect-native/shared/provenance.ts new file mode 100644 index 000000000..66a55daf7 --- /dev/null +++ b/app/tools/oxlint/effect-native/shared/provenance.ts @@ -0,0 +1,200 @@ +import type { Context, ESTree, Variable } from '@oxlint/plugins'; +import { asNode, literalText, parentOf, propertyText, syntax, type Syntax } from './ast.ts'; +import { lookupVariable } from './bindings.ts'; + +const GLOBALS = new Set([ + 'process', + 'console', + 'Bun', + 'globalThis', + 'global', + 'window', + 'self', + 'require', + 'Array', + 'Set', +]); +const CONTAINERS = new Set(['globalThis', 'global', 'window', 'self']); +const CONTAINER_MEMBERS = new Set(['process', 'console', 'Bun']); +const DEFAULT_MODULES = new Set(['process', 'console', 'util', 'module']); + +function moduleIdentity(source: string): string { + if (/^(?:node:)?(?:process|console|util|module)$/u.test(source)) + return source.replace(/^node:/u, ''); + if (source === 'effect/Effect') return 'Effect'; + if (source === 'effect/ManagedRuntime') return 'ManagedRuntime'; + return source; +} + +/** Nested object/assignment destructuring path, excluding rest and dynamic keys. */ +function destructuringPath(pattern: ESTree.Node, name: string): string[] | null { + if (pattern.type === 'Identifier') return pattern.name === name ? [] : null; + if (pattern.type === 'AssignmentPattern') return destructuringPath(pattern.left, name); + if (pattern.type !== 'ObjectPattern') return null; + for (const property of pattern.properties) { + if (property.type !== 'Property') continue; + const key = propertyText(property); + const tail = destructuringPath(property.value, name); + if (key !== null && tail !== null) return [key, ...tail]; + } + return null; +} + +function importOrigin(definition: Variable['defs'][number]): string | null { + const spec = asNode(definition.node)!; + const declaration = asNode(definition.parent ?? spec.parent); + if (!declaration || declaration.importKind === 'type' || spec.importKind === 'type') return null; + const source = literalText(declaration.source); + if (!source) return null; + const base = moduleIdentity(source); + return importedOrigin(spec, base); +} +function importedOrigin(spec: Syntax, base: string): string | null { + if (spec.type === 'ImportNamespaceSpecifier' || spec.type === 'ImportDefaultSpecifier') + return base; + const name = spec.imported?.name ?? spec.imported?.value; + if (name === 'default') return base; + return base === 'effect' ? name : `${base}.${name}`; +} + +function variableOrigin( + context: Context, + node: Syntax, + seen: ReadonlySet, +): string | null { + const variable = lookupVariable(context, node); + if (!variable || variable.defs.length === 0) return GLOBALS.has(node.name) ? node.name : null; + if (seen.has(variable) || variable.defs.length !== 1) return null; + const next = new Set(seen).add(variable); + const definition = variable.defs[0]!; + if (definition.type === 'ImportBinding') return importOrigin(definition); + return aliasOrigin(context, node.name, variable, definition, next); +} +function aliasOrigin( + context: Context, + name: string, + variable: Variable, + definition: Variable['defs'][number], + seen: ReadonlySet, +): string | null { + if (definition.type !== 'Variable' || definition.node.type !== 'VariableDeclarator') return null; + if (variable.references.some((reference) => reference.init !== true && reference.isWrite())) + return null; + const base = provenance(context, definition.node.init, seen); + const path = destructuringPath(definition.node.id, name); + return base !== null && path !== null ? [base, ...path].join('.') : null; +} + +function memberOrigin(context: Context, node: Syntax, seen: ReadonlySet): string | null { + const base = provenance(context, node.object, seen); + const key = propertyText(node); + if (base === null || key === null) return null; + if (CONTAINERS.has(base) && CONTAINER_MEMBERS.has(key)) return key; + if (DEFAULT_MODULES.has(base) && key === 'default') return base; + return base === 'effect' ? key : `${base}.${key}`; +} +function moduleSource(value: unknown): string | null { + const text = literalText(value); + return text === null ? null : moduleIdentity(text); +} +function callOrigin(context: Context, node: Syntax, seen: ReadonlySet): string | null { + const callee = provenance(context, node.callee, seen); + if (callee === 'require') return moduleSource(node.arguments[0]); + if (callee === 'module.createRequire') return 'require'; + return callee === 'ManagedRuntime.make' ? 'Runtime' : null; +} + +/** Script runtime identity: imports, require/createRequire, globals, immutable aliases and destructuring. + * Unlike Effect bindingPath, await is transparent and unwritten let aliases are accepted. + */ +export function provenance( + context: Context, + input: unknown, + seen: ReadonlySet = new Set(), +): string | null { + const node = syntax(input); + if (!node) return null; + switch (node.type) { + case 'Identifier': + return variableOrigin(context, node, seen); + case 'MemberExpression': + return memberOrigin(context, node, seen); + case 'ImportExpression': + return moduleSource(node.source); + case 'CallExpression': + return callOrigin(context, node, seen); + default: + return null; + } +} + +const KEY_PARENTS = new Set([ + 'Property', + 'PropertyDefinition', + 'MethodDefinition', + 'TSPropertySignature', + 'TSMethodSignature', +]); +const LABEL_PARENTS = new Set(['LabeledStatement', 'BreakStatement', 'ContinueStatement']); +const TS_VALUES = new Set([ + 'TSAsExpression', + 'TSSatisfiesExpression', + 'TSNonNullExpression', + 'TSTypeAssertion', + 'TSInstantiationExpression', +]); +function nonReferenceName(node: Syntax, parent: Syntax): boolean { + if (parent.type.startsWith('Import') || parent.type === 'ExportSpecifier') return true; + if (parent.type === 'MemberExpression' && parent.property === node && !parent.computed) + return true; + if (LABEL_PARENTS.has(parent.type)) return true; + return nonReferenceKey(node, parent); +} +function nonReferenceKey(node: Syntax, parent: Syntax): boolean { + return ( + KEY_PARENTS.has(parent.type) && + parent.key === node && + !parent.computed && + !(parent.shorthand && parent.value === node) + ); +} +function typePosition(node: Syntax, parent: Syntax): boolean { + let child = node; + let current: Syntax | null = parent; + while (current) { + if ( + current.type.startsWith('TS') && + !(TS_VALUES.has(current.type) && current.expression === child) + ) + return true; + if ( + current.type.endsWith('Statement') || + current.type.endsWith('Declaration') || + current.type.includes('Function') + ) + break; + child = current; + current = parentOf(current); + } + return false; +} + +/** Only lexical value reads; excludes property names, bindings and TS-only identifiers. */ +export function valueReference(context: Context, input: unknown): boolean { + const node = asNode(input); + const parent = parentOf(node); + if (!node || !parent || nonReferenceName(node, parent) || typePosition(node, parent)) + return false; + const variable = lookupVariable(context, node); + return ( + !variable || + variable.references.some((reference) => { + const value = reference as typeof reference & { isValueReference?: () => boolean }; + return ( + reference.identifier === node && + reference.isRead() && + (typeof value.isValueReference !== 'function' || value.isValueReference()) + ); + }) + ); +} diff --git a/app/tools/oxlint/effect-native/shared/reference-positions.ts b/app/tools/oxlint/effect-native/shared/reference-positions.ts new file mode 100644 index 000000000..fbd20f888 --- /dev/null +++ b/app/tools/oxlint/effect-native/shared/reference-positions.ts @@ -0,0 +1,60 @@ +import type { ESTree } from '@oxlint/plugins'; +import { asNode, parentOf, type Syntax } from './ast.ts'; + +const IMPORT_NAMES = new Set([ + 'ImportSpecifier', + 'ImportDefaultSpecifier', + 'ImportNamespaceSpecifier', + 'ExportSpecifier', +]); +const PROPERTY_KEYS = new Set(['Property', 'PropertyDefinition', 'MethodDefinition']); +export interface ReferencePositionPolicy { + /** Import/name-only copies return true for detached nodes; declaration walkers use false. */ + readonly detached?: boolean; + /** Includes labels or rule-specific TS parents without imposing one rule's syntax policy on others. */ + readonly nonReferenceParents?: ReadonlySet; + readonly keyParents?: ReadonlySet; + readonly variableBindings?: boolean; + /** Some legacy key tests use !== true rather than falsiness; the default retains falsiness. */ + readonly strictComputed?: boolean; +} +function isPropertyKey( + node: ESTree.Node, + parent: Syntax, + policy: ReferencePositionPolicy, +): boolean { + if (!(policy.keyParents ?? PROPERTY_KEYS).has(parent.type) || parent.key !== node) return false; + return policy.strictComputed ? parent.computed !== true : !parent.computed; +} +/** Immediate-parent name/binding test only; type ancestry is a separate, explicitly configured test. */ +export function isNonReferencePosition( + node: ESTree.Node, + policy: ReferencePositionPolicy = {}, +): boolean { + const parent = parentOf(node); + if (!parent) return policy.detached ?? true; + if (IMPORT_NAMES.has(parent.type) || policy.nonReferenceParents?.has(parent.type)) return true; + if (policy.variableBindings && parent.type === 'VariableDeclarator') return parent.id === node; + if (parent.type === 'MemberExpression') return parent.property === node && !parent.computed; + return isPropertyKey(node, parent, policy); +} +/** TS ancestry walk through caller-listed runtime TS kinds; stops at Program. */ +export function isInTypePosition(node: ESTree.Node, expressionTypes: ReadonlySet): boolean { + let current = parentOf(node); + while (current && current.type !== 'Program') { + if (current.type.startsWith('TS') && !expressionTypes.has(current.type)) return true; + current = parentOf(current); + } + return false; +} +/** Schema-codec variant: a TS ancestor is transparent only along its expression child edge. */ +export function isInErasedTypePosition(node: ESTree.Node): boolean { + let child = node; + let current = parentOf(node); + while (current) { + if (current.type.startsWith('TS') && asNode(current.expression) !== child) return true; + child = current; + current = parentOf(current); + } + return false; +} diff --git a/app/tools/oxlint/effect-native/shared/reporting.ts b/app/tools/oxlint/effect-native/shared/reporting.ts new file mode 100644 index 000000000..b9245af58 --- /dev/null +++ b/app/tools/oxlint/effect-native/shared/reporting.ts @@ -0,0 +1,31 @@ +import type { ESTree } from '@oxlint/plugins'; + +/** Independent threshold and slice length preserve the differing existing diagnostic budgets. */ +export function snippet( + text: string, + limit: number, + sliceLength = limit - 1, + ellipsis = '…', +): string { + const flat = text.replace(/\s+/gu, ' ').trim(); + return flat.length > limit ? `${flat.slice(0, sliceLength)}${ellipsis}` : flat; +} +export function sameNode( + left: ESTree.Node | null | undefined, + right: ESTree.Node | null | undefined, +): boolean { + if (!left || !right) return false; + return left.type === right.type && left.start === right.start && left.end === right.end; +} +/** Default key omits the node kind; typed copies pass their original kind separator (':' or '@'). */ +export function nodeKey(node: ESTree.Node, kindSeparator?: ':' | '@'): string { + const span = `${node.start}:${node.end}`; + return kindSeparator === undefined ? span : `${node.type}${kindSeparator}${span}`; +} +/** Use node.start/end directly when null checking is not required. */ +export function spanOf( + node: ESTree.Node | null | undefined, +): { readonly start: number; readonly end: number } | null { + if (!node || typeof node.start !== 'number' || typeof node.end !== 'number') return null; + return { start: node.start, end: node.end }; +} diff --git a/app/tools/oxlint/effect-native/shared/rule-file-policy.ts b/app/tools/oxlint/effect-native/shared/rule-file-policy.ts new file mode 100644 index 000000000..b17ab6a9d --- /dev/null +++ b/app/tools/oxlint/effect-native/shared/rule-file-policy.ts @@ -0,0 +1,22 @@ +import { isTestFile, matchesGlobs, scopePath } from './paths.ts'; + +interface RuleFilePolicy { + readonly include: readonly string[]; + readonly ignore: readonly string[]; + readonly ignoreTests: boolean; +} + +/** Shared option shape for rules using source-rule path and test filtering. */ +export const ruleFilePolicyProperties = { + ignore: { items: { type: 'string' }, type: 'array' }, + ignoreTests: { type: 'boolean' }, + include: { items: { type: 'string' }, type: 'array' }, +} as const; + +/** Preserve source-rule fixture normalization and ignore-before-include precedence. */ +export function acceptsRuleFile(filename: string, policy: RuleFilePolicy): boolean { + const path = scopePath(filename); + if (matchesGlobs(path, policy.ignore)) return false; + if (!matchesGlobs(path, policy.include)) return false; + return !policy.ignoreTests || !isTestFile(path); +} diff --git a/app/tools/oxlint/effect-native/shared/scaffold-text.ts b/app/tools/oxlint/effect-native/shared/scaffold-text.ts new file mode 100644 index 000000000..d7ebf80cc --- /dev/null +++ b/app/tools/oxlint/effect-native/shared/scaffold-text.ts @@ -0,0 +1,66 @@ +import type { ESTree } from '@oxlint/plugins'; + +export type StringNode = Extract; +const MODULE_PARENTS = new Set([ + 'ImportDeclaration', + 'ImportExpression', + 'ExportNamedDeclaration', + 'ExportAllDeclaration', +]); +const DRIVER_BOUNDARIES = new Set([ + 'VariableDeclarator', + 'ReturnStatement', + 'TemplateLiteral', + 'Program', +]); + +/** Lexical masking keeps offsets/newlines; regex literals and dynamic fragments remain opaque. */ +export function maskText(text: string, strings = false): string { + return text.replace( + /\/\*[\s\S]*?\*\/|\/\/[^\r\n]*|'(?:\\[\s\S]|[^'\\])*'|"(?:\\[\s\S]|[^"\\])*"|`(?:\\[\s\S]|[^`\\])*`/gu, + (value) => + value.startsWith('/') || strings + ? value.replace(/[^\r\n]+/gu, (segment) => ' '.repeat(segment.length)) + : value, + ); +} +function driverCallee(callee: ESTree.Node): boolean { + if (callee.type === 'Identifier') + return /^(?:Error|TypeError|exec|execSync|execFile|execFileSync|spawn|spawnSync)$/u.test( + callee.name, + ); + return ( + callee.type === 'MemberExpression' && + callee.object.type === 'Identifier' && + callee.object.name === 'console' + ); +} +/** Excludes generator-driver prose/logging/shell arguments, not text emitted into source files. */ +export function driverText(node: ESTree.Node): boolean { + if (node.parent && MODULE_PARENTS.has(node.parent.type)) return true; + let current = node.parent; + while (current) { + if (current.type === 'CallExpression' || current.type === 'NewExpression') + return driverCallee(current.callee); + if (DRIVER_BOUNDARIES.has(current.type)) return false; + current = current.parent; + } + return false; +} +/** Interpolations are opaque one-character placeholders, never evaluated. */ +export function emittedText(node: StringNode): string { + if (node.type === 'TemplateLiteral') + return node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join('_'); + return typeof node.value === 'string' ? node.value : ''; +} +/** Cooked offsets locate whole quasis, not guessed raw-source character ranges. */ +export function reportNode(node: StringNode, start: number, end: number): ESTree.Node { + if (node.type !== 'TemplateLiteral') return node; + let offset = 0; + for (const quasi of node.quasis) { + const length = (quasi.value.cooked ?? quasi.value.raw).length; + if (start >= offset && end <= offset + length) return quasi; + offset += length + 1; + } + return node; +} diff --git a/app/tools/oxlint/effect-native/shared/schema-constructor.ts b/app/tools/oxlint/effect-native/shared/schema-constructor.ts new file mode 100644 index 000000000..3ebf7a6b6 --- /dev/null +++ b/app/tools/oxlint/effect-native/shared/schema-constructor.ts @@ -0,0 +1,30 @@ +import type { ESTree } from '@oxlint/plugins'; + +/** Recognize field-bag arguments, including curried Schema constructors. */ +export function isSchemaConstructorArgument( + node: ESTree.Node, + resolveMember: (node: ESTree.Node) => string | null, + constructors: ReadonlySet, + unwrap: (node: ESTree.Node) => ESTree.Node, +): boolean { + const parent = node.parent; + if (parent === null || parent === undefined || parent.type !== 'CallExpression') return false; + if (!parent.arguments.some((argument) => argument === node)) return false; + return isConstructorCallee(parent.callee, resolveMember, constructors, unwrap); +} + +function isConstructorCallee( + node: ESTree.Node, + resolveMember: (node: ESTree.Node) => string | null, + constructors: ReadonlySet, + unwrap: (node: ESTree.Node) => ESTree.Node, +): boolean { + let callee = unwrap(node); + for (let depth = 0; depth < 8; depth += 1) { + const member = resolveMember(callee); + if (member !== null) return constructors.has(member); + if (callee.type !== 'CallExpression') return false; + callee = unwrap(callee.callee); + } + return false; +} diff --git a/app/tools/oxlint/effect-native/shared/schema-identity.ts b/app/tools/oxlint/effect-native/shared/schema-identity.ts new file mode 100644 index 000000000..0fbb0f336 --- /dev/null +++ b/app/tools/oxlint/effect-native/shared/schema-identity.ts @@ -0,0 +1,117 @@ +import type { Context, ESTree, Variable } from '@oxlint/plugins'; +import { keyName, memberName, unwrapNode } from './ast.ts'; +import { lookupVariable } from './bindings.ts'; +import { importedName } from './imports.ts'; +import { matchesGlobs } from './paths.ts'; + +type Definition = Variable['defs'][number]; +const SCHEMA_MODULE = /^effect\/(?:.*\/)?Schema$/u; + +function schemaMember(host: string | null, member: string | null): string | null { + if (host === '@schema') return member; + return host === '@effect' && member === 'Schema' ? '@schema' : null; +} +function submoduleIdentity( + specifier: ESTree.ImportDeclaration['specifiers'][number], +): string | null { + if (specifier.type === 'ImportNamespaceSpecifier') return '@schema'; + return specifier.type === 'ImportSpecifier' ? importedName(specifier) : null; +} +function rootIdentity(specifier: ESTree.ImportDeclaration['specifiers'][number]): string | null { + if (specifier.type === 'ImportNamespaceSpecifier') return '@effect'; + return specifier.type === 'ImportSpecifier' && importedName(specifier) === 'Schema' + ? '@schema' + : null; +} +function isImportSpecifier( + node: ESTree.Node, +): node is ESTree.ImportDeclaration['specifiers'][number] { + return ( + node.type === 'ImportSpecifier' || + node.type === 'ImportNamespaceSpecifier' || + node.type === 'ImportDefaultSpecifier' + ); +} +function importIdentity(definition: Definition, reexports: readonly string[]): string | null { + const specifier = definition.node; + const declaration = definition.parent; + if (declaration?.type !== 'ImportDeclaration' || declaration.importKind === 'type') return null; + if (specifier.type === 'ImportSpecifier' && specifier.importKind === 'type') return null; + if (!isImportSpecifier(specifier)) return null; + const source = declaration.source.value; + if (SCHEMA_MODULE.test(source)) return submoduleIdentity(specifier); + return source === 'effect' || matchesGlobs(source, reexports) ? rootIdentity(specifier) : null; +} +export function constSchemaAlias(definition: Definition): ESTree.VariableDeclarator | null { + if ( + definition.type !== 'Variable' || + definition.node.type !== 'VariableDeclarator' || + definition.node.init === null + ) + return null; + const declarator = definition.node; + return declarator.parent?.type === 'VariableDeclaration' && declarator.parent.kind === 'const' + ? declarator + : null; +} +export function destructuredSchemaIdentity( + pattern: ESTree.ObjectPattern, + name: string, + host: string | null, +): string | null | undefined { + for (const property of pattern.properties) { + if ( + property.type !== 'Property' || + property.value.type !== 'Identifier' || + property.value.name !== name + ) + continue; + const identity = schemaMember(host, keyName(property.key, property.computed)); + // A matching schema property returns even an unknown key, preserving the original first match. + if (host === '@schema' || identity !== null) return identity; + } + return undefined; +} +function identifierIdentity( + context: Context, + node: ESTree.IdentifierReference | ESTree.IdentifierName | ESTree.BindingIdentifier, + reexports: readonly string[], + depth: number, +): string | null { + const variable = lookupVariable(context, node); + if (!variable) return null; + for (const definition of variable.defs) { + if (definition.type === 'ImportBinding') { + const identity = importIdentity(definition, reexports); + if (identity !== null) return identity; + } + const alias = constSchemaAlias(definition); + if (!alias?.init) continue; + if (alias.id.type === 'Identifier') + return schemaIdentity(context, alias.init, reexports, depth + 1); + if (alias.id.type !== 'ObjectPattern') continue; + const host = schemaIdentity(context, alias.init, reexports, depth + 1); + const identity = destructuredSchemaIdentity(alias.id, node.name, host); + if (identity !== undefined) return identity; + } + return null; +} + +/** Schema lexical identity: @effect root, @schema namespace, or direct member; const-only aliases, + * glob barrels, no type-only imports, no write/typechecker inference, bounded to 16 hops. + */ +export function schemaIdentity( + context: Context, + input: ESTree.Node, + reexports: readonly string[] = [], + depth = 0, +): string | null { + if (depth > 16) return null; + const node = unwrapNode(input); + if (node.type === 'MemberExpression') + return schemaMember( + schemaIdentity(context, node.object, reexports, depth + 1), + memberName(node), + ); + return node.type === 'Identifier' ? identifierIdentity(context, node, reexports, depth) : null; +} diff --git a/app/tools/oxlint/effect-native/shared/schema-rule-support.ts b/app/tools/oxlint/effect-native/shared/schema-rule-support.ts new file mode 100644 index 000000000..e9fcfaee7 --- /dev/null +++ b/app/tools/oxlint/effect-native/shared/schema-rule-support.ts @@ -0,0 +1,43 @@ +import type { ESTree } from '@oxlint/plugins'; + +import { isTestFile, matchesGlobs, scopePath } from './paths.ts'; + +interface SchemaRuleScope { + readonly include: readonly string[]; + readonly ignore: readonly string[]; + readonly ignoreTests: boolean; +} + +/** Shared source-schema rule scope, including the legacy fixture-path normalization. */ +export function isSchemaRuleInScope(filename: string, options: SchemaRuleScope): boolean { + const path = scopePath(filename); + return ( + !matchesGlobs(path, options.ignore) && + matchesGlobs(path, options.include) && + !(options.ignoreTests && isTestFile(path)) + ); +} + +interface ConstructorArgumentOptions { + readonly resolveMember: (node: ESTree.Node) => string | null; + readonly constructors: ReadonlySet; + readonly unwrap: (node: ESTree.Node) => ESTree.Node; +} + +/** Recognize direct and curried Schema constructor arguments, inspecting at most eight callees. */ +export function isSchemaConstructorArgument( + node: ESTree.Node, + { resolveMember, constructors, unwrap }: ConstructorArgumentOptions, +): boolean { + const parent = node.parent; + if (parent?.type !== 'CallExpression') return false; + if (!parent.arguments.some((argument) => argument === node)) return false; + let callee = unwrap(parent.callee); + for (let guard = 0; guard < 8; guard += 1) { + const member = resolveMember(callee); + if (member !== null) return constructors.has(member); + if (callee.type !== 'CallExpression') return false; + callee = unwrap(callee.callee); + } + return false; +} diff --git a/app/tools/oxlint/effect-native/shared/script-entry.ts b/app/tools/oxlint/effect-native/shared/script-entry.ts new file mode 100644 index 000000000..a2773aa95 --- /dev/null +++ b/app/tools/oxlint/effect-native/shared/script-entry.ts @@ -0,0 +1,65 @@ +import type { Context, ESTree } from '@oxlint/plugins'; +import { + FUNCTION_TYPES, + nearestFunction as nearest, + parentOf, + skipWrappers, + type Syntax, +} from './ast.ts'; +import { resolveVariable } from './bindings.ts'; + +const ENTRY_FUNCTION_TYPES = new Set([...FUNCTION_TYPES, 'StaticBlock']); + +/** Script entry rules treat a static block as a function boundary, unlike generator walkers. */ +export function nearestFunction(node: ESTree.Node): Syntax | null { + return nearest(node, ENTRY_FUNCTION_TYPES); +} +export function isTopLevel(node: ESTree.Node): boolean { + return nearestFunction(node) === null; +} +function isProgramLevelStatement(node: ESTree.Node): boolean { + const parent = parentOf(node); + if (parent?.type === 'Program') return true; + if (parent?.type !== 'ExportNamedDeclaration' && parent?.type !== 'ExportDefaultDeclaration') + return false; + return parentOf(parent)?.type === 'Program'; +} +function programDeclarator(fn: ESTree.Node): ESTree.VariableDeclarator | null { + const declarator = parentOf(fn); + if (declarator?.type !== 'VariableDeclarator' || declarator.init !== fn) return null; + const declaration = parentOf(declarator); + return declaration?.type === 'VariableDeclaration' && isProgramLevelStatement(declaration) + ? declarator + : null; +} +export function programLevelFunctionName(fn: ESTree.Node): string | null { + if (fn.type === 'FunctionDeclaration') + return isProgramLevelStatement(fn) ? (fn.id?.name ?? null) : null; + if (fn.type !== 'FunctionExpression' && fn.type !== 'ArrowFunctionExpression') return null; + const declarator = programDeclarator(fn); + return declarator?.id.type === 'Identifier' ? declarator.id.name : null; +} +function isTopLevelImmediatelyInvoked(fn: ESTree.Node): boolean { + const { node, parent } = skipWrappers(fn); + return parent?.type === 'CallExpression' && parent.callee === node && isTopLevel(parent); +} +function isOnlyCalledFromTopLevel(context: Context, fn: ESTree.Node, name: string): boolean { + const variable = resolveVariable(context, name, fn); + if (!variable) return false; + const offsets = new Set(variable.identifiers.map((identifier) => identifier.start)); + const uses = variable.references.filter( + (reference) => reference.init !== true && !offsets.has(reference.identifier.start), + ); + return ( + uses.length > 0 && uses.every((reference) => isTopLevelImmediatelyInvoked(reference.identifier)) + ); +} +/** Module evaluation, top-level IIFEs, or named Program functions used only by top-level calls. */ +export function isEntryPosition(context: Context, site: ESTree.Node): boolean { + const fn = nearestFunction(site); + if (!fn) return true; + if (nearestFunction(fn)) return false; + if (isTopLevelImmediatelyInvoked(fn)) return true; + const name = programLevelFunctionName(fn); + return name !== null && isOnlyCalledFromTopLevel(context, fn, name); +} diff --git a/app/tools/oxlint/effect-native/shared/source-rule-scope.ts b/app/tools/oxlint/effect-native/shared/source-rule-scope.ts new file mode 100644 index 000000000..eb14cbca2 --- /dev/null +++ b/app/tools/oxlint/effect-native/shared/source-rule-scope.ts @@ -0,0 +1,17 @@ +import { isScriptFile, isTestFile, matchesGlobs, scopePath } from './paths.ts'; + +interface SourceRuleScope { + readonly includePaths: readonly string[]; + readonly ignore: readonly string[]; + readonly includeScripts: boolean; + readonly includeTests: boolean; +} + +/** Shared dependency/factory audit scope; preserve legacy fixture normalization and gate order. */ +export function isSourceRuleInScope(filename: string, options: SourceRuleScope): boolean { + const path = scopePath(filename); + if (!matchesGlobs(path, options.includePaths)) return false; + if (matchesGlobs(path, options.ignore)) return false; + if (!options.includeScripts && isScriptFile(path)) return false; + return options.includeTests || !isTestFile(path); +} diff --git a/app/tools/oxlint/effect-native/tests/fixtures.test.mts b/app/tools/oxlint/effect-native/tests/fixtures.test.mts index de08e95ac..57671b675 100644 --- a/app/tools/oxlint/effect-native/tests/fixtures.test.mts +++ b/app/tools/oxlint/effect-native/tests/fixtures.test.mts @@ -19,6 +19,42 @@ if (rules.length === 0) { assert.fail(`No fixture directories found${onlyRule ? ` for ${onlyRule}` : ''}.`)); } +function appendValidFailures( + fixtureDirectory: string, + valid: readonly string[], + byFile: ReadonlyMap, + failures: string[], +): void { + for (const file of valid) { + const key = relative(fixtureDirectory, file).replaceAll('\\', '/'); + const count = byFile.get(key) ?? 0; + if (count !== 0) failures.push(`${key} must not report (false positive: ${count})`); + } +} + +function fixtureFailures( + fixtureDirectory: string, + invalid: readonly string[], + valid: readonly string[], + byFile: ReadonlyMap, +): string[] { + const failures: string[] = []; + for (const file of invalid) { + const key = relative(fixtureDirectory, file).replaceAll('\\', '/'); + const count = byFile.get(key) ?? 0; + const expected = /^\/\/\s*expect-count:\s*(\d+)/u.exec(readFileSync(file, 'utf8'))?.[1]; + if (expected !== undefined) { + if (Number(expected) <= 0 || count !== Number(expected)) { + failures.push(`${key} expected ${expected} positive diagnostics, got ${count}`); + } + } else if (count === 0) { + failures.push(`${key} expected at least one diagnostic`); + } + } + appendValidFailures(fixtureDirectory, valid, byFile, failures); + return failures; +} + for (const rule of rules) { test(`effect-native/${rule} fixtures`, () => { const fixtureDirectory = join(fixturesDirectory, rule); @@ -49,24 +85,7 @@ for (const rule of rules) { invalid.length + valid.length, `${rule}: not every fixture was linted`, ); - const failures: string[] = []; - for (const file of invalid) { - const key = relative(fixtureDirectory, file).replaceAll('\\', '/'); - const count = byFile.get(key) ?? 0; - const expected = /^\/\/\s*expect-count:\s*(\d+)/u.exec(readFileSync(file, 'utf8'))?.[1]; - if (expected !== undefined) { - if (Number(expected) <= 0 || count !== Number(expected)) { - failures.push(`${key} expected ${expected} positive diagnostics, got ${count}`); - } - } else if (count === 0) { - failures.push(`${key} expected at least one diagnostic`); - } - } - for (const file of valid) { - const key = relative(fixtureDirectory, file).replaceAll('\\', '/'); - const count = byFile.get(key) ?? 0; - if (count !== 0) failures.push(`${key} must not report (false positive: ${count})`); - } + const failures = fixtureFailures(fixtureDirectory, invalid, valid, byFile); assert.deepEqual(failures, [], `${rule}:\n${failures.join('\n')}`); }); } diff --git a/app/tools/oxlint/effect-native/tests/fixtures/no-hand-rolled-tagged-union/invalid/packages/static-property-key-controls.ts b/app/tools/oxlint/effect-native/tests/fixtures/no-hand-rolled-tagged-union/invalid/packages/static-property-key-controls.ts new file mode 100644 index 000000000..0b077365b --- /dev/null +++ b/app/tools/oxlint/effect-native/tests/fixtures/no-hand-rolled-tagged-union/invalid/packages/static-property-key-controls.ts @@ -0,0 +1,10 @@ +// expect-count: 3 +export interface IdentifierPropertyKey { + readonly _tag: 'IdentifierPropertyKey'; +} +export interface LiteralPropertyKey { + readonly '_tag': 'LiteralPropertyKey'; +} +export interface ComputedLiteralPropertyKey { + readonly ['_tag']: 'ComputedLiteralPropertyKey'; +} diff --git a/app/tools/oxlint/effect-native/tests/fixtures/no-hand-rolled-tagged-union/valid/packages/template-property-key.ts b/app/tools/oxlint/effect-native/tests/fixtures/no-hand-rolled-tagged-union/valid/packages/template-property-key.ts new file mode 100644 index 000000000..1b00e17d6 --- /dev/null +++ b/app/tools/oxlint/effect-native/tests/fixtures/no-hand-rolled-tagged-union/valid/packages/template-property-key.ts @@ -0,0 +1,4 @@ +// Computed template keys were never recognized as property-signature discriminants. +export interface TemplatePropertyKey { + readonly [`_tag`]: 'TemplatePropertyKey'; +} diff --git a/app/tools/oxlint/effect-native/tests/fixtures/no-manual-error-handling-in-scaffold-templates/invalid/scripts/scaffolding/unicode-offsets.mts b/app/tools/oxlint/effect-native/tests/fixtures/no-manual-error-handling-in-scaffold-templates/invalid/scripts/scaffolding/unicode-offsets.mts new file mode 100644 index 000000000..a447d556d --- /dev/null +++ b/app/tools/oxlint/effect-native/tests/fixtures/no-manual-error-handling-in-scaffold-templates/invalid/scripts/scaffolding/unicode-offsets.mts @@ -0,0 +1,2 @@ +// expect-count: 1 +export const template = `const icon = "😀😀😀😀😀"; promise.catch((error) => { if (error) recover(); });`; diff --git a/app/tools/oxlint/effect-native/tests/fixtures/no-sync-schema-codec/invalid/packages/core-runtime/src/computed-schema-identity.ts b/app/tools/oxlint/effect-native/tests/fixtures/no-sync-schema-codec/invalid/packages/core-runtime/src/computed-schema-identity.ts new file mode 100644 index 000000000..47335bc02 --- /dev/null +++ b/app/tools/oxlint/effect-native/tests/fixtures/no-sync-schema-codec/invalid/packages/core-runtime/src/computed-schema-identity.ts @@ -0,0 +1,12 @@ +// expect-count: 6 +import * as Effect from 'effect'; +import { Schema } from 'effect'; + +Schema[`decodeUnknownSync`]; +Schema[('encodeSync' as const)]; +Effect[`Schema`].validateSync; +Effect[('Schema' as const)].decodeSync; +const TemplateSchema = Effect[`Schema`]; +const WrappedSchema = Effect[('Schema' as const)]; +TemplateSchema.decodeUnknownSync; +WrappedSchema.encodeUnknownSync; diff --git a/app/tools/oxlint/effect-native/tests/fixtures/no-sync-schema-codec/valid/packages/core-runtime/src/computed-schema-identity.ts b/app/tools/oxlint/effect-native/tests/fixtures/no-sync-schema-codec/valid/packages/core-runtime/src/computed-schema-identity.ts new file mode 100644 index 000000000..29f658f7b --- /dev/null +++ b/app/tools/oxlint/effect-native/tests/fixtures/no-sync-schema-codec/valid/packages/core-runtime/src/computed-schema-identity.ts @@ -0,0 +1,14 @@ +import * as Effect from 'effect'; +import { Schema } from 'effect'; + +Schema[`decodeUnknownEffect`]; +Schema[('encodeResult' as const)]; +const TemplateSchema = Effect[`Schema`]; +TemplateSchema.validateEffect; +let MutableSchema = Effect[('Schema' as const)]; +MutableSchema.decodeSync; +const local = { decodeSync: () => undefined }; +local[`decodeSync`]; +declare const key: string; +Schema[key]; +Schema[`decode${key}Sync`]; diff --git a/app/tools/oxlint/effect-native/tests/fixtures/no-unmanaged-mutable-state/invalid/packages/wrapped-mutation-boundary.ts b/app/tools/oxlint/effect-native/tests/fixtures/no-unmanaged-mutable-state/invalid/packages/wrapped-mutation-boundary.ts new file mode 100644 index 000000000..9d2699d31 --- /dev/null +++ b/app/tools/oxlint/effect-native/tests/fixtures/no-unmanaged-mutable-state/invalid/packages/wrapped-mutation-boundary.ts @@ -0,0 +1,6 @@ +// expect-count: 2 +// Computed mutation names unwrap at most ten expression wrappers. +const literalMap = new Map(); +literalMap['set' as 'set' as 'set' as 'set' as 'set' as 'set' as 'set' as 'set' as 'set' as 'set' as 'set']('key', 'value'); +const templateMap = new Map(); +templateMap[`set` as 'set' as 'set' as 'set' as 'set' as 'set' as 'set' as 'set' as 'set' as 'set' as 'set']('key', 'value'); diff --git a/app/tools/oxlint/effect-native/tests/fixtures/no-unmanaged-mutable-state/valid/packages/wrapped-mutation-boundary.ts b/app/tools/oxlint/effect-native/tests/fixtures/no-unmanaged-mutable-state/valid/packages/wrapped-mutation-boundary.ts new file mode 100644 index 000000000..700abbb42 --- /dev/null +++ b/app/tools/oxlint/effect-native/tests/fixtures/no-unmanaged-mutable-state/valid/packages/wrapped-mutation-boundary.ts @@ -0,0 +1,5 @@ +// Computed mutation names unwrap at most ten expression wrappers. +const literalMap = new Map(); +literalMap['set' as 'set' as 'set' as 'set' as 'set' as 'set' as 'set' as 'set' as 'set' as 'set' as 'set' as 'set']('key', 'value'); +const templateMap = new Map(); +templateMap[`set` as 'set' as 'set' as 'set' as 'set' as 'set' as 'set' as 'set' as 'set' as 'set' as 'set' as 'set']('key', 'value'); diff --git a/app/tools/oxlint/effect-native/tests/json-rule-scope.test.mts b/app/tools/oxlint/effect-native/tests/json-rule-scope.test.mts new file mode 100644 index 000000000..716c54af7 --- /dev/null +++ b/app/tools/oxlint/effect-native/tests/json-rule-scope.test.mts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { jsonExpressionSnippet } from '../shared/json-globals.ts'; +import { inJsonRuleScope } from '../shared/json-rule-scope.ts'; + +const includePaths = ['apps/**', 'scripts/**']; + +test('JSON rule scope preserves defaults, overrides, and exclusions', () => { + assert.equal(inJsonRuleScope('/workspace/apps/example/main.ts', undefined, includePaths), true); + assert.equal(inJsonRuleScope('packages/example/main.ts', undefined, includePaths), false); + assert.equal(inJsonRuleScope('apps/example/main.test.ts', undefined, includePaths), false); + assert.equal( + inJsonRuleScope('apps/example/main.test.ts', { ignoreTestFiles: false }, includePaths), + true, + ); + assert.equal( + inJsonRuleScope('apps/example/main.ts', { allowPaths: ['apps/**'] }, includePaths), + false, + ); + assert.equal(inJsonRuleScope('apps/example/main.ts', { includePaths: [] }, includePaths), true); + assert.equal(inJsonRuleScope('apps/example/main.ts', { includePaths: [1] }, includePaths), true); + assert.equal( + inJsonRuleScope('packages/example/main.ts', { includePaths: ['packages/**'] }, includePaths), + true, + ); + assert.equal( + inJsonRuleScope('apps/example/main.test.ts', { ignoreTestFiles: 'false' }, includePaths), + false, + ); +}); + +test('JSON snippets retain whitespace normalization, boundary, and ASCII truncation', () => { + assert.equal(jsonExpressionSnippet(' JSON.stringify(\n value ) '), 'JSON.stringify( value )'); + assert.equal(jsonExpressionSnippet('x'.repeat(72)), 'x'.repeat(72)); + assert.equal(jsonExpressionSnippet('x'.repeat(73)), `${'x'.repeat(69)}...`); +}); diff --git a/app/tools/oxlint/effect-native/tests/oxlint.mts b/app/tools/oxlint/effect-native/tests/oxlint.mts index 4be57463c..0666b7927 100644 --- a/app/tools/oxlint/effect-native/tests/oxlint.mts +++ b/app/tools/oxlint/effect-native/tests/oxlint.mts @@ -1,6 +1,6 @@ import { spawnSync } from 'node:child_process'; import { existsSync, readdirSync, statSync } from 'node:fs'; -import { basename, dirname, join, relative, resolve } from 'node:path'; +import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; export const testsDirectory = dirname(fileURLToPath(import.meta.url)); @@ -11,7 +11,7 @@ const oxlintEntryPoint = fileURLToPath( new URL('bin/oxlint', import.meta.resolve('oxlint/package.json')), ); -export interface Diagnostic { +interface Diagnostic { readonly code: string; readonly filename: string; readonly message: string; @@ -28,6 +28,33 @@ export interface LintRun { readonly numberOfFiles: number; } +function validateDiagnostic(diagnostic: Diagnostic): void { + if ( + diagnostic === null || + typeof diagnostic.code !== 'string' || + typeof diagnostic.filename !== 'string' || + typeof diagnostic.message !== 'string' || + !Array.isArray(diagnostic.labels) || + !['error', 'warning'].includes(diagnostic.severity) + ) { + throw new Error(`Oxlint returned a malformed diagnostic: ${JSON.stringify(diagnostic)}`); + } +} + +function validateReport( + parsed: { diagnostics?: Diagnostic[]; number_of_files?: number }, + stdout: string, +): asserts parsed is { diagnostics: Diagnostic[]; number_of_files: number } { + if ( + parsed === null || + !Array.isArray(parsed.diagnostics) || + !Number.isInteger(parsed.number_of_files) || + (parsed.number_of_files ?? 0) <= 0 + ) { + throw new Error(`Oxlint returned an incomplete or empty-file report:\n${stdout}`); + } +} + /** A crashed loader, empty run, or malformed output must never look like zero violations. */ export function parseOxlintOutput(stdout: string, stderr: string, status: number | null): LintRun { if (status !== 0 && status !== 1) { @@ -40,26 +67,8 @@ export function parseOxlintOutput(stdout: string, stderr: string, status: number } catch (cause) { throw new Error(`Oxlint did not return a JSON report:\n${stdout}`, { cause }); } - if ( - parsed === null || - !Array.isArray(parsed.diagnostics) || - !Number.isInteger(parsed.number_of_files) || - (parsed.number_of_files ?? 0) <= 0 - ) { - throw new Error(`Oxlint returned an incomplete or empty-file report:\n${stdout}`); - } - for (const diagnostic of parsed.diagnostics) { - if ( - diagnostic === null || - typeof diagnostic.code !== 'string' || - typeof diagnostic.filename !== 'string' || - typeof diagnostic.message !== 'string' || - !Array.isArray(diagnostic.labels) || - !['error', 'warning'].includes(diagnostic.severity) - ) { - throw new Error(`Oxlint returned a malformed diagnostic: ${JSON.stringify(diagnostic)}`); - } - } + validateReport(parsed, stdout); + for (const diagnostic of parsed.diagnostics) validateDiagnostic(diagnostic); const hasErrors = parsed.diagnostics.some((diagnostic) => diagnostic.severity === 'error'); if ((status === 0 && hasErrors) || (status === 1 && parsed.diagnostics.length === 0)) { throw new Error(`Oxlint exit status ${status} contradicts its diagnostics.`); @@ -121,7 +130,3 @@ export function listFilesRecursively(directory: string): readonly string[] { export function fixtureConfigPath(rule: string): string { return join(fixturesDirectory, rule, '.oxlintrc.json'); } - -export function relativeToApp(path: string): string { - return relative(appRoot, path).replaceAll('\\', '/'); -} diff --git a/app/tools/oxlint/effect-native/tests/rule-file-policy.test.mts b/app/tools/oxlint/effect-native/tests/rule-file-policy.test.mts new file mode 100644 index 000000000..eacd93c28 --- /dev/null +++ b/app/tools/oxlint/effect-native/tests/rule-file-policy.test.mts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { acceptsRuleFile, ruleFilePolicyProperties } from '../shared/rule-file-policy.ts'; + +const policy = { include: ['packages/**'], ignore: [], ignoreTests: false }; + +test('rule file policy keeps source and test files in scope by default', () => { + assert.equal(acceptsRuleFile('packages/core/src/schema.ts', policy), true); + assert.equal(acceptsRuleFile('packages/core/tests/schema.test.ts', policy), true); + assert.equal(acceptsRuleFile('apps/shell/src/schema.ts', policy), false); + assert.equal(acceptsRuleFile('packages/core/src/schema.ts', { ...policy, include: [] }), false); +}); + +test('rule file policy applies ignore and optional test exclusion', () => { + assert.equal( + acceptsRuleFile('packages/core/src/schema.ts', { ...policy, ignore: ['packages/core/**'] }), + false, + ); + assert.equal( + acceptsRuleFile('packages/core/tests/schema.test.ts', { ...policy, ignoreTests: true }), + false, + ); + assert.equal( + acceptsRuleFile('packages/core/src/schema.ts', { ...policy, ignoreTests: true }), + true, + ); +}); + +test('rule file policy normalizes absolute and fixture paths before filtering', () => { + for (const filename of [ + '/workspace/app/packages/core/src/schema.ts', + 'C:\\workspace\\app\\packages\\core\\src\\schema.ts', + 'tools/oxlint/effect-native/tests/fixtures/no-nullable-schema-field/invalid/packages/core/src/schema.ts', + ]) { + assert.equal(acceptsRuleFile(filename, policy), true, filename); + } +}); + +test('rule file policy exposes the existing JSON option schemas', () => { + assert.deepEqual(ruleFilePolicyProperties, { + ignore: { items: { type: 'string' }, type: 'array' }, + ignoreTests: { type: 'boolean' }, + include: { items: { type: 'string' }, type: 'array' }, + }); +}); diff --git a/app/tools/oxlint/effect-native/tests/scaffold-text-unicode.test.mts b/app/tools/oxlint/effect-native/tests/scaffold-text-unicode.test.mts new file mode 100644 index 000000000..59185c44a --- /dev/null +++ b/app/tools/oxlint/effect-native/tests/scaffold-text-unicode.test.mts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { maskText } from '../shared/scaffold-text.ts'; + +test('scaffold masking preserves UTF-16 offsets after astral string data', () => { + const text = 'const icon = "😀😀😀😀😀"; throw new Error("bad");'; + const masked = maskText(text, true); + assert.equal(masked.length, text.length); + assert.equal(masked.indexOf('throw'), text.indexOf('throw')); +}); + +test('scaffold masking preserves astral comment offsets and CRLF', () => { + const text = '// 😀😀\r\n/* 😀\r\n😀 */ throw new Error("bad");'; + const masked = maskText(text); + assert.equal(masked.length, text.length); + assert.equal(masked.indexOf('throw'), text.indexOf('throw')); + assert.deepEqual( + [...masked.matchAll(/\r\n/gu)].map((match) => match.index), + [...text.matchAll(/\r\n/gu)].map((match) => match.index), + ); + assert.ok(masked.endsWith('throw new Error("bad");')); +}); diff --git a/app/tools/oxlint/effect-native/tests/scaffold-unicode.test.mts b/app/tools/oxlint/effect-native/tests/scaffold-unicode.test.mts new file mode 100644 index 000000000..67e184a61 --- /dev/null +++ b/app/tools/oxlint/effect-native/tests/scaffold-unicode.test.mts @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { maskText } from '../shared/scaffold-text.ts'; +import { runOxlint, testsDirectory } from './oxlint.mts'; +import { withTemporaryWorkspace } from './temporary-workspace.mts'; + +const supplementaryCharacter = '\u{1F600}'; + +test('scaffold masking preserves UTF-16 offsets and line endings', () => { + const source = `/* ${supplementaryCharacter}\r\n */ const label="${supplementaryCharacter.repeat(20)}"; process.env.X`; + for (const strings of [false, true]) { + const masked = maskText(source, strings); + assert.equal(masked.length, source.length); + assert.equal(masked.indexOf('\r\n'), source.indexOf('\r\n')); + assert.equal(masked.indexOf('process.env.X'), source.indexOf('process.env.X')); + assert.equal(masked.includes(supplementaryCharacter), !strings); + } +}); + +test('manual configuration rule detects access after supplementary Unicode but ignores quoted data', () => { + withTemporaryWorkspace((directory) => { + const scaffoldDirectory = join(directory, 'scripts/scaffolding'); + mkdirSync(scaffoldDirectory, { recursive: true }); + const positive = 'scripts/scaffolding/unicode-positive.mts'; + const negative = 'scripts/scaffolding/unicode-negative.mts'; + const prefix = `const label="${supplementaryCharacter.repeat(20)}"; `; + writeFileSync( + join(directory, positive), + `export const source = \`${prefix}process.env.X${' '.repeat(30)}\`;`, + ); + writeFileSync( + join(directory, negative), + `export const source = \`${prefix}const example = "process.env.X";\`;`, + ); + const config = join(directory, '.oxlintrc.json'); + writeFileSync( + config, + JSON.stringify({ + jsPlugins: [ + { name: 'effect-native', specifier: join(testsDirectory, 'fixture-plugin.ts') }, + ], + categories: { correctness: 'off' }, + rules: { 'effect-native/no-manual-config-in-scaffold-templates': 'error' }, + }), + ); + const result = runOxlint( + config, + [positive, negative], + directory, + 'no-manual-config-in-scaffold-templates', + ); + assert.equal(result.numberOfFiles, 2); + assert.equal(result.exitCode, 1); + assert.equal(result.diagnostics.length, 1); + assert.equal( + result.diagnostics[0]?.code, + 'effect-native(no-manual-config-in-scaffold-templates)', + ); + assert.equal(result.diagnostics[0]?.filename.replaceAll('\\', '/'), positive); + }); +}); diff --git a/app/tools/oxlint/effect-native/tests/shared-helpers.test.mts b/app/tools/oxlint/effect-native/tests/shared-helpers.test.mts new file mode 100644 index 000000000..1dcbde79a --- /dev/null +++ b/app/tools/oxlint/effect-native/tests/shared-helpers.test.mts @@ -0,0 +1,288 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { parseSync, visitorKeys } from 'oxc-parser'; +import type { Context, ESTree, Variable } from '@oxlint/plugins'; +import { + asNode, + childrenOf, + identityUnwrap, + memberName, + syntax, + unwrapNode, + walk, + type Syntax, +} from '../shared/ast.ts'; +import { isUnshadowedGlobal, resolvesToImport } from '../shared/bindings.ts'; +import { bindingPath, effectOrigin, isGenCallee } from '../shared/effect-identity.ts'; +import { collectEffectBindings } from '../shared/effect-imports.ts'; +import { + collectDirectMemberImports, + collectRootNamespaces, + collectSchemaLocals, +} from '../shared/imports.ts'; +import { + booleanOption, + compile, + compilePatterns, + positiveInteger, + safeRegExp, + stringArray, + stringList, +} from '../shared/options.ts'; +import { + globToRegExp, + inScriptScope, + scopePath, + scriptScope, + workspacePath, +} from '../shared/paths.ts'; +import { provenance } from '../shared/provenance.ts'; +import { snippet } from '../shared/reporting.ts'; +import { + isInErasedTypePosition, + isInTypePosition, + isNonReferencePosition, +} from '../shared/reference-positions.ts'; +import { emittedText, maskText, reportNode, type StringNode } from '../shared/scaffold-text.ts'; +import { schemaIdentity } from '../shared/schema-identity.ts'; +import { isEntryPosition } from '../shared/script-entry.ts'; + +function parse(source: string): ESTree.Program { + const parsed = parseSync('helpers.ts', source); + assert.deepEqual(parsed.errors, []); + const program = parsed.program as unknown as ESTree.Program; + walk(program, visitorKeys, (node) => { + for (const child of childrenOf(node, visitorKeys)) child.parent = node; + }); + return program; +} +function expression(source: string): ESTree.Node { + const statement = parse(source).body[0]; + assert.equal(statement?.type, 'ExpressionStatement'); + return (statement as ESTree.ExpressionStatement).expression; +} + +/** Explicit single-scope test double, not an attempt to reconstruct the lint engine's scopes. */ +function contextFor(program: ESTree.Program): { + context: Context; + variables: Map; +} { + const variables = new Map(); + const define = (name: string, definition: unknown) => + variables.set(name, { + defs: [definition], + references: [], + identifiers: [], + } as unknown as Variable); + for (const declaration of program.body) { + if (declaration.type === 'ImportDeclaration') { + for (const specifier of declaration.specifiers) + define(specifier.local.name, { + type: 'ImportBinding', + node: specifier, + parent: declaration, + }); + } + if (declaration.type === 'VariableDeclaration') { + for (const declarator of declaration.declarations) { + walk(declarator.id, visitorKeys, (node) => { + if (node.type === 'Identifier') + define(node.name, { type: 'Variable', node: declarator, parent: declaration }); + }); + } + } + } + const context = { + sourceCode: { + ast: program, + getScope: () => ({ set: variables, upper: null }), + } as unknown as Context['sourceCode'], + } as Context; + return { context, variables }; +} +function lastExpression(program: ESTree.Program): ESTree.Node { + const last = program.body.at(-1); + assert.equal(last?.type, 'ExpressionStatement'); + return (last as ESTree.ExpressionStatement).expression; +} + +test('shared option parsers preserve rejection, sparse arrays and regex flags', () => { + const fallback = ['default']; + assert.equal(stringArray(['yes', 1], fallback), fallback); + assert.equal(stringArray(new Array(2), fallback), fallback); + assert.equal(stringList(new Array(2), fallback).length, 2); + assert.equal(booleanOption('false', true), true); + assert.equal(positiveInteger(0, 3), 3); + assert.equal(positiveInteger(0, 3, 0), 0); + assert.equal(compile('[', 'fallback', 'iu').source, 'fallback'); + assert.equal(compile('[', 'fallback', 'iu').flags, 'iu'); + assert.equal(compilePatterns(['[', 'ok']).length, 1); + assert.equal(safeRegExp('', 'fallback').source, '(?:)'); + assert.equal(compile('', 'fallback').source, 'fallback'); +}); + +test('shared path policies distinguish earliest and latest markers and script scope', () => { + const fixture = + '/repo/tools/oxlint/effect-native/tests/fixtures/x/invalid/packages/p/scripts/apps/demo.ts'; + assert.equal(scopePath(fixture), 'packages/p/scripts/apps/demo.ts'); + assert.equal(scriptScope(fixture), 'packages/p/scripts/apps/demo.ts'); + assert.equal(workspacePath(fixture), 'apps/demo.ts'); + assert.equal(inScriptScope(scriptScope(fixture)), true); + assert.equal(inScriptScope('scripts/a.test.ts'), false); + assert.equal(scriptScope('packages/p/scripts/apps/demo.ts'), 'packages/p/scripts/apps/demo.ts'); + assert.equal(globToRegExp('**/*.{ts,mts}').test('a.ts'), true); + assert.equal(globToRegExp('a{').test('a{'), true); + assert.equal(globToRegExp('x/?*.ts').test('x/a.ts'), true); +}); + +test('shared unwrapping preserves sequence and await opt-ins and narrow wrapper policy', () => { + const sequence = expression('(ignored, Effect.gen)'); + assert.equal(unwrapNode(sequence).type, 'SequenceExpression'); + assert.equal(identityUnwrap(sequence).type, 'MemberExpression'); + const awaited = expression('await Effect.gen'); + assert.equal(unwrapNode(awaited).type, 'AwaitExpression'); + assert.equal(syntax(awaited)?.type, 'MemberExpression'); + const wrapped = expression('Effect as unknown'); + assert.equal(unwrapNode(wrapped, { wrappers: new Set() }), wrapped); + assert.equal(asNode({ type: 'Identifier' }, true), null); +}); + +test('shared static members distinguish templates and wrapped keys', () => { + const computed = expression('Effect[`gen`]'); + assert.equal(memberName(computed), null); + assert.equal(memberName(computed, { templates: true }), 'gen'); + const wrapped = expression('Effect[("gen" as const)]'); + assert.equal(memberName(wrapped), null); + assert.equal(memberName(wrapped, { unwrap: {} }), 'gen'); + assert.equal(memberName(expression('Effect[method]')), null); +}); + +test('shared imports preserve aliases and optional type-only filtering', () => { + const program = parse( + 'import type * as Types from "effect"; import { type gen as tgen, gen as g } from "effect/Effect"; import * as S from "effect/Schema";', + ); + assert.deepEqual([...collectRootNamespaces(program)], ['Types']); + assert.equal( + collectRootNamespaces(program, (source) => source === 'effect', { valueOnly: true }).size, + 0, + ); + assert.deepEqual(collectDirectMemberImports(program).get('tgen'), { + namespace: 'Effect', + member: 'gen', + }); + assert.equal( + collectDirectMemberImports(program, undefined, { valueOnly: true }).has('tgen'), + false, + ); + const schema = collectSchemaLocals(program, collectEffectBindings(program)); + assert.equal(schema.schema.has('S'), true); +}); + +test('shared Effect identities preserve const aliases and direct submodule imports', () => { + const program = parse('import { Effect as E } from "effect"; const { gen: g } = E; g;'); + const { context } = contextFor(program); + assert.deepEqual(bindingPath(context, lastExpression(program)), ['Effect', 'gen']); + assert.equal(isGenCallee(context, lastExpression(program), ['gen']), true); + const direct = parse('import { gen as g } from "effect/Effect"; g;'); + assert.deepEqual(bindingPath(contextFor(direct).context, lastExpression(direct)), [ + 'Effect', + 'gen', + ]); +}); + +test('shared Effect origin policies preserve glob barrels and default-import differences', () => { + const program = parse('import E from "effect/Effect"; E.gen;'); + const { context } = contextFor(program); + assert.equal(bindingPath(context, lastExpression(program)), null); + assert.deepEqual(effectOrigin(context, lastExpression(program), []), ['Effect', 'gen']); + const barrel = parse('import { Effect as E } from "@app/barrel"; E.gen;'); + const b = contextFor(barrel).context; + assert.equal(bindingPath(b, lastExpression(barrel), ['@app/*']), null); + assert.deepEqual(effectOrigin(b, lastExpression(barrel), ['@app/*']), ['Effect', 'gen']); +}); + +test('shared script provenance accepts unwritten let aliases but rejects later writes', () => { + const program = parse('import * as p from "node:process"; let { stderr: sink } = p; sink.write;'); + const { context, variables } = contextFor(program); + assert.equal(provenance(context, lastExpression(program)), 'process.stderr.write'); + assert.equal(bindingPath(context, lastExpression(program)), null); + const variable = variables.get('sink')!; + variables.set('sink', { + ...variable, + references: [{ init: false, isWrite: () => true }], + } as unknown as Variable); + assert.equal(provenance(context, lastExpression(program)), null); +}); + +test('shared script provenance follows dynamic import, require and ambient containers', () => { + const program = parse('await import("node:process");'); + assert.equal(provenance(contextFor(program).context, lastExpression(program)), 'process'); + const required = parse('require("node:console").warn;'); + assert.equal(provenance(contextFor(required).context, lastExpression(required)), 'console.warn'); + const global = parse('globalThis.process.stderr.write;'); + assert.equal( + provenance(contextFor(global).context, lastExpression(global)), + 'process.stderr.write', + ); +}); + +test('shared global/import resolution retains opt-in type-only policy', () => { + const program = parse('import type { JSON } from "types"; JSON;'); + const { context } = contextFor(program); + const node = lastExpression(program); + assert.equal(isUnshadowedGlobal(context, node, 'JSON'), false); + assert.equal(isUnshadowedGlobal(context, node, 'JSON', true), true); + assert.equal(resolvesToImport(context, node), true); + assert.equal(resolvesToImport(context, node, true), false); +}); + +test('shared Schema identity follows aliases while rejecting mutable declarations', () => { + const program = parse( + 'import * as E from "effect"; const S = E.Schema; const { decodeUnknownSync: decode } = S; decode;', + ); + assert.equal( + schemaIdentity(contextFor(program).context, lastExpression(program)), + 'decodeUnknownSync', + ); + const mutable = parse('import { Schema } from "effect"; let S = Schema; S.Json;'); + assert.equal(schemaIdentity(contextFor(mutable).context, lastExpression(mutable)), null); +}); + +test('shared template text preserves cooked offsets and diagnostic budgets', () => { + const node = expression('`first ${name} last`') as StringNode; + assert.equal(emittedText(node), 'first _ last'); + assert.equal(reportNode(node, 0, 4).type, 'TemplateElement'); + assert.equal(reportNode(node, 0, 12), node); + assert.equal(maskText('// hi\nconst x = "a";').length, '// hi\nconst x = "a";'.length); + assert.equal(maskText('"a"', true), ' '); + assert.equal(snippet('abcdef', 4, 2), 'ab…'); +}); + +test('shared entry detection includes module evaluation and top-level IIFEs only', () => { + const program = parse( + '(() => { console.warn("x"); })(); function nested() { console.warn("y"); }', + ); + const context = contextFor(program).context; + const calls: Syntax[] = []; + walk(program, visitorKeys, (node) => { + if (node.type === 'CallExpression' && node.callee.type === 'MemberExpression') calls.push(node); + }); + assert.equal(isEntryPosition(context, calls[0]!), true); + assert.equal(isEntryPosition(context, calls[1]!), false); +}); + +test('shared reference policies preserve declaration keys and TS expression edges', () => { + const program = parse('const result = Schema as unknown; type Result = typeof Schema;'); + const identifiers: Syntax[] = []; + walk(program, visitorKeys, (node) => { + if (node.type === 'Identifier') identifiers.push(node); + }); + const binding = identifiers.find((node) => node.name === 'result')!; + assert.equal(isNonReferencePosition(binding), false); + assert.equal(isNonReferencePosition(binding, { variableBindings: true }), true); + const schema = identifiers.filter((node) => node.name === 'Schema'); + assert.equal(isInErasedTypePosition(schema[0]!), false); + assert.equal(isInErasedTypePosition(schema[1]!), true); + assert.equal(isInTypePosition(schema[0]!, new Set(['TSAsExpression'])), false); + assert.equal(isInTypePosition(schema[1]!, new Set(['TSAsExpression'])), true); +}); diff --git a/app/tools/oxlint/effect-native/tests/source-rule-scope.test.mts b/app/tools/oxlint/effect-native/tests/source-rule-scope.test.mts new file mode 100644 index 000000000..bf0c332d3 --- /dev/null +++ b/app/tools/oxlint/effect-native/tests/source-rule-scope.test.mts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { isSourceRuleInScope } from '../shared/source-rule-scope.ts'; + +const defaults = { + includePaths: ['apps/**', 'packages/**', 'verticals/**'], + ignore: [], + includeScripts: false, + includeTests: false, +}; + +test('source rule scope applies include and ignore gates before opt-ins', () => { + assert.equal(isSourceRuleInScope('apps/example/src/main.ts', defaults), true); + assert.equal(isSourceRuleInScope('tools/example.ts', defaults), false); + assert.equal( + isSourceRuleInScope('apps/example/src/main.ts', { ...defaults, ignore: ['apps/**'] }), + false, + ); + assert.equal( + isSourceRuleInScope('scripts/main.ts', { ...defaults, includeScripts: true }), + false, + ); +}); + +test('source rule scope independently gates scripts and tests', () => { + const defaults = { + includePaths: ['apps/**', 'scripts/**'], + ignore: [], + includeScripts: false, + includeTests: false, + }; + const script = 'scripts/main.ts'; + const testFile = 'apps/example/src/main.test.ts'; + const scriptTest = 'scripts/main.test.ts'; + assert.equal(isSourceRuleInScope(script, defaults), false); + assert.equal(isSourceRuleInScope(script, { ...defaults, includeScripts: true }), true); + assert.equal(isSourceRuleInScope(testFile, defaults), false); + assert.equal(isSourceRuleInScope(testFile, { ...defaults, includeTests: true }), true); + assert.equal(isSourceRuleInScope(scriptTest, { ...defaults, includeScripts: true }), false); + assert.equal(isSourceRuleInScope(scriptTest, { ...defaults, includeTests: true }), false); + assert.equal( + isSourceRuleInScope(scriptTest, { ...defaults, includeScripts: true, includeTests: true }), + true, + ); +}); + +test('source rule scope preserves fixture path normalization', () => { + const prefix = 'tools/oxlint/effect-native/tests/fixtures/no-dependency-parameters/invalid/'; + assert.equal(isSourceRuleInScope(`${prefix}apps/example/src/main.ts`, defaults), true); + assert.equal(isSourceRuleInScope(`${prefix}apps/example/src/main.test.ts`, defaults), false); +}); diff --git a/app/verticals/party-registry/api/ares-lookup-read-server.ts b/app/verticals/party-registry/api/ares-lookup-read-server.ts index 15e127daf..ea6ebaa52 100644 --- a/app/verticals/party-registry/api/ares-lookup-read-server.ts +++ b/app/verticals/party-registry/api/ares-lookup-read-server.ts @@ -1,13 +1,5 @@ -// @generated by OntOS Codesmith module-api v1 -import { ReadRuntime } from '@app/core-runtime'; -import type { ReadCoreError } from '@app/core-runtime'; -import { - Effect, - HttpApiBuilder, - HttpEffect, - HttpServerResponse, -} from '@modern-js/plugin-bff/effect-edge'; -import { Match, Redacted, Schema } from 'effect'; +import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; +import { Schema } from 'effect'; import { partyRegistryApi } from '../shared/api.ts'; import { AresLookupAuthenticationProblemSchema, @@ -20,7 +12,8 @@ import { AresLookupUnavailableProblemSchema, } from '../shared/apis/ares-lookup.ts'; import { aresLookupRead } from '../src/api/ares-lookup.read.ts'; -import { authenticateOperationPrincipal } from './auth/action-principal.ts'; +import { governedReadHandler } from './governed-read-handler.ts'; +import type { GovernedReadProblems } from './governed-read-handler.ts'; const problemStatus = { authentication: 401, @@ -90,10 +83,7 @@ const internalProblem = () => title: 'Read failed', type: 'https://ontos.dev/problems/read-failed', }); -const bearerChallenge = HttpEffect.appendPreResponseHandler((_request, response) => - Effect.succeed(HttpServerResponse.setHeader(response, 'www-authenticate', 'Bearer')), -); -type AresLookupProblem = +type ReadProblem = | typeof AresLookupAuthenticationProblemSchema.Type | typeof AresLookupForbiddenProblemSchema.Type | typeof AresLookupInternalProblemSchema.Type @@ -102,34 +92,17 @@ type AresLookupProblem = | typeof AresLookupPolicyConflictProblemSchema.Type | typeof AresLookupPolicyProblemSchema.Type | typeof AresLookupUnavailableProblemSchema.Type; -const isAuthenticationProblem = Schema.is(AresLookupAuthenticationProblemSchema); -const failProblem = (problem: Problem) => - (isAuthenticationProblem(problem) ? bearerChallenge : Effect.void).pipe( - Effect.andThen(Effect.fail(problem)), - ); -const readProblem = (readError: ReadCoreError): AresLookupProblem => - Match.value(readError).pipe( - Match.tags({ - ModuleStateCheckUnavailableError: unavailableProblem, - ModuleStateDeniedError: forbiddenProblem, - OperationAuthenticationRequired: authenticationProblem, - OperationContextDenied: forbiddenProblem, - OperationContextInvalid: forbiddenProblem, - OperationContextUnavailable: unavailableProblem, - ReadEvidencePersistenceError: unavailableProblem, - ReadEvidenceValidationError: internalProblem, - ReadHandlerExecutionError: internalProblem, - ReadHandlerNotFound: notFoundProblem, - ReadHandlerUnavailable: unavailableProblem, - ReadInputValidationError: invalidProblem, - ReadPermissionDenied: forbiddenProblem, - ReadPermissionUnavailable: unavailableProblem, - ReadPolicyDenied: ({ httpStatus }) => policyProblem(httpStatus), - ReadPolicyEvaluationError: unavailableProblem, - ReadResultValidationError: internalProblem, - }), - Match.exhaustive, - ); + +const problems: GovernedReadProblems = { + authentication: authenticationProblem, + forbidden: forbiddenProblem, + internal: internalProblem, + invalid: invalidProblem, + notFound: notFoundProblem, + policy: policyProblem, + unavailable: unavailableProblem, + isAuthentication: Schema.is(AresLookupAuthenticationProblemSchema), +}; export const aresLookupReadApiLive = HttpApiBuilder.group( partyRegistryApi, @@ -137,30 +110,10 @@ export const aresLookupReadApiLive = HttpApiBuilder.group( (handlers) => handlers.handle( 'execute', - Effect.fn('aresLookupReadApiLive.execute')(function* handleAresLookupRead({ - payload, - request, - }) { - const correlationId = request.headers['x-correlation-id']; - if (correlationId === undefined || correlationId.trim().length === 0) { - return yield* Effect.fail(invalidProblem()); - } - const principal = yield* authenticateOperationPrincipal( - Redacted.make(request.headers['authorization']), - { - authentication: authenticationProblem, - unavailable: unavailableProblem, - }, - ); - const runtime = yield* ReadRuntime; - return yield* runtime - .runRead({ - input: payload, - principal, - registration: aresLookupRead, - transport: { correlationId }, - }) - .pipe(Effect.catch((error) => error.pipe(readProblem, failProblem))); + governedReadHandler({ + spanName: 'aresLookupReadApiLive.execute', + registration: aresLookupRead, + problems, }), ), ); diff --git a/app/verticals/party-registry/api/auth/action-principal.ts b/app/verticals/party-registry/api/auth/action-principal.ts index 285e457b3..85492e45e 100644 --- a/app/verticals/party-registry/api/auth/action-principal.ts +++ b/app/verticals/party-registry/api/auth/action-principal.ts @@ -4,43 +4,14 @@ import { GatewayAssertionRedemptionService } from '@app/core-runtime/auth/gateway-assertion-redemption'; import { makeMicroverticalHttpPrincipalAuthentication } from '@app/core-runtime/http/principal-authentication'; import { bindGatewayPrincipalVerifier } from '@app/gateway-principal-verifier/server'; -import type { GatewayPrincipalVerificationOptions } from '@app/gateway-principal-verifier/server'; import { Effect } from 'effect'; import type { Redacted } from 'effect'; -export { - ACTION_PRINCIPAL_BEARER_CHALLENGE, - ActionPrincipalConfigurationErrorSchema, - ActionPrincipalExpiredErrorSchema, - ActionPrincipalInvalidErrorSchema, - ActionPrincipalMissingErrorSchema, - ActionPrincipalScopeErrorSchema, - ActionPrincipalUnavailableErrorSchema, -} from '@app/gateway-principal-verifier/server'; -export type { - ActionPrincipalConfigurationError, - ActionPrincipalError, - ActionPrincipalExpiredError, - ActionPrincipalInvalidError, - ActionPrincipalMissingError, - ActionPrincipalScopeError, - ActionPrincipalUnavailableError, -} from '@app/gateway-principal-verifier/server'; - -export const ACTION_GATEWAY_AUDIENCE = 'party-registry' as const; -export { - GatewayPrincipalVerifierConfiguration as ActionPrincipalVerifier, - GatewayPrincipalVerifierLive as ActionPrincipalVerifierLive, -} from '@app/gateway-principal-verifier/server'; -export type ActionPrincipalVerificationOptions = GatewayPrincipalVerificationOptions; +const ACTION_GATEWAY_AUDIENCE = 'party-registry' as const; +export { GatewayPrincipalVerifierLive as ActionPrincipalVerifierLive } from '@app/gateway-principal-verifier/server'; const principalVerifier = bindGatewayPrincipalVerifier(ACTION_GATEWAY_AUDIENCE); -export const verifyActionPrincipal = ( - authorization: Redacted.Redacted, - options: ActionPrincipalVerificationOptions = {}, -) => principalVerifier.verify(authorization, options); - const verifyOperationPrincipal = (authorization: Redacted.Redacted) => GatewayAssertionRedemptionService.pipe( Effect.flatMap((redemption) => diff --git a/app/verticals/party-registry/api/counterparties-search-server.ts b/app/verticals/party-registry/api/counterparties-search-server.ts index 095b247e2..a0d6261d5 100644 --- a/app/verticals/party-registry/api/counterparties-search-server.ts +++ b/app/verticals/party-registry/api/counterparties-search-server.ts @@ -1,14 +1,7 @@ // @generated by OntOS Codesmith Governed Contribution v1 // @ontos-contribution-kind search-provider -import { ReadRuntime } from '@app/core-runtime'; -import type { ReadCoreError } from '@app/core-runtime'; -import { - Effect, - HttpApiBuilder, - HttpEffect, - HttpServerResponse, -} from '@modern-js/plugin-bff/effect-edge'; -import { Match, Redacted, Schema } from 'effect'; +import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; +import { Schema } from 'effect'; import { partyRegistryApi } from '../shared/api.ts'; import { CounterpartiesProviderAuthenticationProblemSchema, @@ -21,7 +14,8 @@ import { CounterpartiesProviderUnavailableProblemSchema, } from '../shared/apis/counterparties-search.ts'; import { counterpartiesRead } from '../src/search/counterparties.provider.ts'; -import { authenticateOperationPrincipal } from './auth/action-principal.ts'; +import { governedReadHandler } from './governed-read-handler.ts'; +import type { GovernedReadProblems } from './governed-read-handler.ts'; const problemStatus = { authentication: 401, @@ -91,10 +85,7 @@ const internalProblem = () => title: 'Read failed', type: 'https://ontos.dev/problems/read-failed', }); -const bearerChallenge = HttpEffect.appendPreResponseHandler((_request, response) => - Effect.succeed(HttpServerResponse.setHeader(response, 'www-authenticate', 'Bearer')), -); -type CounterpartiesProblem = +type ReadProblem = | typeof CounterpartiesProviderAuthenticationProblemSchema.Type | typeof CounterpartiesProviderForbiddenProblemSchema.Type | typeof CounterpartiesProviderInternalProblemSchema.Type @@ -103,34 +94,17 @@ type CounterpartiesProblem = | typeof CounterpartiesProviderPolicyConflictProblemSchema.Type | typeof CounterpartiesProviderPolicyProblemSchema.Type | typeof CounterpartiesProviderUnavailableProblemSchema.Type; -const isAuthenticationProblem = Schema.is(CounterpartiesProviderAuthenticationProblemSchema); -const failProblem = (problem: Problem) => - (isAuthenticationProblem(problem) ? bearerChallenge : Effect.void).pipe( - Effect.andThen(Effect.fail(problem)), - ); -const readProblem = (readError: ReadCoreError): CounterpartiesProblem => - Match.value(readError).pipe( - Match.tags({ - ModuleStateCheckUnavailableError: unavailableProblem, - ModuleStateDeniedError: forbiddenProblem, - OperationAuthenticationRequired: authenticationProblem, - OperationContextDenied: forbiddenProblem, - OperationContextInvalid: forbiddenProblem, - OperationContextUnavailable: unavailableProblem, - ReadEvidencePersistenceError: unavailableProblem, - ReadEvidenceValidationError: internalProblem, - ReadHandlerExecutionError: internalProblem, - ReadHandlerNotFound: notFoundProblem, - ReadHandlerUnavailable: unavailableProblem, - ReadInputValidationError: invalidProblem, - ReadPermissionDenied: forbiddenProblem, - ReadPermissionUnavailable: unavailableProblem, - ReadPolicyDenied: ({ httpStatus }) => policyProblem(httpStatus), - ReadPolicyEvaluationError: unavailableProblem, - ReadResultValidationError: internalProblem, - }), - Match.exhaustive, - ); + +const problems: GovernedReadProblems = { + authentication: authenticationProblem, + forbidden: forbiddenProblem, + internal: internalProblem, + invalid: invalidProblem, + notFound: notFoundProblem, + policy: policyProblem, + unavailable: unavailableProblem, + isAuthentication: Schema.is(CounterpartiesProviderAuthenticationProblemSchema), +}; export const counterpartiesReadApiLive = HttpApiBuilder.group( partyRegistryApi, @@ -138,30 +112,10 @@ export const counterpartiesReadApiLive = HttpApiBuilder.group( (handlers) => handlers.handle( 'execute', - Effect.fn('counterpartiesReadApiLive.execute')(function* handleCounterpartiesRead({ - payload, - request, - }) { - const correlationId = request.headers['x-correlation-id']; - if (correlationId === undefined || correlationId.trim().length === 0) { - return yield* Effect.fail(invalidProblem()); - } - const principal = yield* authenticateOperationPrincipal( - Redacted.make(request.headers['authorization']), - { - authentication: authenticationProblem, - unavailable: unavailableProblem, - }, - ); - const runtime = yield* ReadRuntime; - return yield* runtime - .runRead({ - input: payload, - principal, - registration: counterpartiesRead, - transport: { correlationId }, - }) - .pipe(Effect.catch((error) => error.pipe(readProblem, failProblem))); + governedReadHandler({ + spanName: 'counterpartiesReadApiLive.execute', + registration: counterpartiesRead, + problems, }), ), ); diff --git a/app/verticals/party-registry/api/counterparty-read-read-server.ts b/app/verticals/party-registry/api/counterparty-read-read-server.ts index f354d8975..460f5e460 100644 --- a/app/verticals/party-registry/api/counterparty-read-read-server.ts +++ b/app/verticals/party-registry/api/counterparty-read-read-server.ts @@ -1,13 +1,5 @@ -// @generated by OntOS Codesmith module-api v1 -import { ReadRuntime } from '@app/core-runtime'; -import type { ReadCoreError } from '@app/core-runtime'; -import { - Effect, - HttpApiBuilder, - HttpEffect, - HttpServerResponse, -} from '@modern-js/plugin-bff/effect-edge'; -import { Match, Redacted, Schema } from 'effect'; +import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; +import { Schema } from 'effect'; import { partyRegistryApi } from '../shared/api.ts'; import { CounterpartyReadAuthenticationProblemSchema, @@ -20,7 +12,8 @@ import { CounterpartyReadUnavailableProblemSchema, } from '../shared/apis/counterparty-read.ts'; import { counterpartyReadRead } from '../src/api/counterparty-read.read.ts'; -import { authenticateOperationPrincipal } from './auth/action-principal.ts'; +import { governedReadHandler } from './governed-read-handler.ts'; +import type { GovernedReadProblems } from './governed-read-handler.ts'; import { governedReadProblemStatus } from './read-server-support.ts'; const authenticationProblem = () => @@ -80,33 +73,26 @@ const internalProblem = () => title: 'Read failed', type: 'https://ontos.dev/problems/read-failed', }); -const bearerChallenge = HttpEffect.appendPreResponseHandler((_request, response) => - Effect.succeed(HttpServerResponse.setHeader(response, 'www-authenticate', 'Bearer')), -); -const isAuthenticationProblem = Schema.is(CounterpartyReadAuthenticationProblemSchema); -const readProblem = (error: ReadCoreError) => - Match.value(error).pipe( - Match.tags({ - ModuleStateCheckUnavailableError: unavailableProblem, - ModuleStateDeniedError: forbiddenProblem, - OperationAuthenticationRequired: authenticationProblem, - OperationContextDenied: forbiddenProblem, - OperationContextInvalid: forbiddenProblem, - OperationContextUnavailable: unavailableProblem, - ReadEvidencePersistenceError: unavailableProblem, - ReadEvidenceValidationError: internalProblem, - ReadHandlerExecutionError: internalProblem, - ReadHandlerNotFound: notFoundProblem, - ReadHandlerUnavailable: unavailableProblem, - ReadInputValidationError: invalidProblem, - ReadPermissionDenied: forbiddenProblem, - ReadPermissionUnavailable: unavailableProblem, - ReadPolicyDenied: (failure) => policyProblem(failure.httpStatus), - ReadPolicyEvaluationError: unavailableProblem, - ReadResultValidationError: internalProblem, - }), - Match.exhaustive, - ); +type ReadProblem = + | typeof CounterpartyReadAuthenticationProblemSchema.Type + | typeof CounterpartyReadForbiddenProblemSchema.Type + | typeof CounterpartyReadInternalProblemSchema.Type + | typeof CounterpartyReadInvalidProblemSchema.Type + | typeof CounterpartyReadNotFoundProblemSchema.Type + | typeof CounterpartyReadPolicyConflictProblemSchema.Type + | typeof CounterpartyReadPolicyProblemSchema.Type + | typeof CounterpartyReadUnavailableProblemSchema.Type; + +const problems: GovernedReadProblems = { + authentication: authenticationProblem, + forbidden: forbiddenProblem, + internal: internalProblem, + invalid: invalidProblem, + notFound: notFoundProblem, + policy: policyProblem, + unavailable: unavailableProblem, + isAuthentication: Schema.is(CounterpartyReadAuthenticationProblemSchema), +}; export const counterpartyReadReadApiLive = HttpApiBuilder.group( partyRegistryApi, @@ -114,34 +100,10 @@ export const counterpartyReadReadApiLive = HttpApiBuilder.group( (handlers) => handlers.handle( 'execute', - Effect.fn('counterpartyReadReadApiLive.execute')(function* executeRead({ payload, request }) { - const correlationId = request.headers['x-correlation-id']; - if (correlationId === undefined || correlationId.trim().length === 0) { - return yield* Effect.fail(invalidProblem()); - } - const principal = yield* authenticateOperationPrincipal( - Redacted.make(request.headers['authorization']), - { - authentication: authenticationProblem, - unavailable: unavailableProblem, - }, - ); - const runtime = yield* ReadRuntime; - return yield* runtime - .runRead({ - input: payload, - principal, - registration: counterpartyReadRead, - transport: { correlationId }, - }) - .pipe( - Effect.catch((error) => { - const problem = readProblem(error); - return (isAuthenticationProblem(problem) ? bearerChallenge : Effect.void).pipe( - Effect.andThen(Effect.fail(problem)), - ); - }), - ); + governedReadHandler({ + spanName: 'counterpartyReadReadApiLive.execute', + registration: counterpartyReadRead, + problems, }), ), ); diff --git a/app/verticals/party-registry/api/counterparty-role-history-read-server.ts b/app/verticals/party-registry/api/counterparty-role-history-read-server.ts index 3fe9e0347..127ec5c5d 100644 --- a/app/verticals/party-registry/api/counterparty-role-history-read-server.ts +++ b/app/verticals/party-registry/api/counterparty-role-history-read-server.ts @@ -1,13 +1,6 @@ // @generated by OntOS Codesmith module-api v1 -import { ReadRuntime } from '@app/core-runtime'; -import type { ReadCoreError } from '@app/core-runtime'; -import { - Effect, - HttpApiBuilder, - HttpEffect, - HttpServerResponse, -} from '@modern-js/plugin-bff/effect-edge'; -import { Match, Redacted, Schema } from 'effect'; +import { Effect, HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; +import { Schema } from 'effect'; import { partyRegistryApi } from '../shared/api.ts'; import { CounterpartyRoleHistoryAuthenticationProblemSchema, @@ -20,7 +13,7 @@ import { CounterpartyRoleHistoryUnavailableProblemSchema, } from '../shared/apis/counterparty-role-history.ts'; import { counterpartyRoleHistoryRead } from '../src/api/counterparty-role-history.read.ts'; -import { authenticateOperationPrincipal } from './auth/action-principal.ts'; +import { executeGovernedRead } from './governed-detail-read-execution.ts'; import { governedReadProblemStatus } from './read-server-support.ts'; const preserveCause = (problem: Problem, cause?: unknown): Problem => { @@ -113,68 +106,18 @@ const problem = { cause, ), }; -const bearerChallenge = HttpEffect.appendPreResponseHandler((_request, response) => - Effect.succeed(HttpServerResponse.setHeader(response, 'www-authenticate', 'Bearer')), -); -const isAuthenticationProblem = Schema.is(CounterpartyRoleHistoryAuthenticationProblemSchema); -const failProblem = (mapped: Problem) => - (isAuthenticationProblem(mapped) ? bearerChallenge : Effect.void).pipe( - Effect.andThen(Effect.fail(mapped)), - ); -const readProblem = (error: ReadCoreError) => - Match.value(error).pipe( - Match.tags({ - ModuleStateCheckUnavailableError: problem.unavailable, - ModuleStateDeniedError: problem.forbidden, - OperationAuthenticationRequired: problem.authentication, - OperationContextDenied: problem.forbidden, - OperationContextInvalid: problem.forbidden, - OperationContextUnavailable: problem.unavailable, - ReadEvidencePersistenceError: problem.unavailable, - ReadEvidenceValidationError: problem.internal, - ReadHandlerExecutionError: problem.internal, - ReadHandlerNotFound: problem.notFound, - ReadHandlerUnavailable: problem.unavailable, - ReadInputValidationError: problem.invalid, - ReadPermissionDenied: problem.forbidden, - ReadPermissionUnavailable: problem.unavailable, - ReadPolicyDenied: (failure) => problem.policy(failure.httpStatus, failure), - ReadPolicyEvaluationError: problem.unavailable, - ReadResultValidationError: problem.internal, - }), - Match.exhaustive, - ); - -const verifyPrincipal = (authorization: Redacted.Redacted) => - authenticateOperationPrincipal(authorization, { - authentication: () => problem.authentication(), - unavailable: () => problem.unavailable(), - }); - export const counterpartyRoleHistoryReadApiLive = HttpApiBuilder.group( partyRegistryApi, 'counterpartyRoleHistory', (handlers) => handlers.handle( 'execute', - Effect.fn('counterpartyRoleHistoryReadApiLive.execute')(function* executeRead({ - payload, - request, - }) { - const correlationId = request.headers['x-correlation-id']; - if (correlationId === undefined || correlationId.trim().length === 0) { - return yield* Effect.fail(problem.invalid()); - } - const principal = yield* verifyPrincipal(Redacted.make(request.headers['authorization'])); - const runtime = yield* ReadRuntime; - return yield* runtime - .runRead({ - input: payload, - principal, - registration: counterpartyRoleHistoryRead, - transport: { correlationId }, - }) - .pipe(Effect.mapError(readProblem), Effect.catchIf(isAuthenticationProblem, failProblem)); - }), + Effect.fn('counterpartyRoleHistoryReadApiLive.execute')( + executeGovernedRead({ + registration: counterpartyRoleHistoryRead, + problem, + isAuthenticationProblem: Schema.is(CounterpartyRoleHistoryAuthenticationProblemSchema), + }), + ), ), ); diff --git a/app/verticals/party-registry/api/duplicate-candidate-detail-read-server.ts b/app/verticals/party-registry/api/duplicate-candidate-detail-read-server.ts index b7f091a58..0a009e258 100644 --- a/app/verticals/party-registry/api/duplicate-candidate-detail-read-server.ts +++ b/app/verticals/party-registry/api/duplicate-candidate-detail-read-server.ts @@ -1,14 +1,6 @@ -// @generated by OntOS Codesmith module-api v1 /* eslint-disable effect-native/no-hand-built-problem-details -- These literals instantiate the shared typed schemas; the rule cannot recognize factory-produced contract schemas. expires: 2027-03-31. */ -import { ReadRuntime } from '@app/core-runtime'; -import type { ReadCoreError } from '@app/core-runtime'; -import { - Effect, - HttpApiBuilder, - HttpEffect, - HttpServerResponse, -} from '@modern-js/plugin-bff/effect-edge'; -import { Match, Redacted, Schema } from 'effect'; +import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; +import { Schema } from 'effect'; import { partyRegistryApi } from '../shared/api.ts'; import { DuplicateCandidateDetailAuthenticationProblemSchema, @@ -21,7 +13,8 @@ import { DuplicateCandidateDetailUnavailableProblemSchema, } from '../shared/apis/duplicate-candidate-detail.ts'; import { duplicateCandidateDetailRead } from '../src/api/duplicate-candidate-detail.read.ts'; -import { authenticateOperationPrincipal } from './auth/action-principal.ts'; +import { governedReadHandler } from './governed-read-handler.ts'; +import type { GovernedReadProblems } from './governed-read-handler.ts'; const authenticationProblem = () => DuplicateCandidateDetailAuthenticationProblemSchema.make({ @@ -84,33 +77,26 @@ const internalProblem = () => title: 'Read failed', type: 'https://ontos.dev/problems/read-failed', }); -const bearerChallenge = HttpEffect.appendPreResponseHandler((_request, response) => - Effect.succeed(HttpServerResponse.setHeader(response, 'www-authenticate', 'Bearer')), -); -const isAuthenticationProblem = Schema.is(DuplicateCandidateDetailAuthenticationProblemSchema); -const readProblem = (error: ReadCoreError) => - Match.value(error).pipe( - Match.tags({ - ModuleStateCheckUnavailableError: unavailableProblem, - ModuleStateDeniedError: forbiddenProblem, - OperationAuthenticationRequired: authenticationProblem, - OperationContextDenied: forbiddenProblem, - OperationContextInvalid: forbiddenProblem, - OperationContextUnavailable: unavailableProblem, - ReadEvidencePersistenceError: unavailableProblem, - ReadEvidenceValidationError: internalProblem, - ReadHandlerExecutionError: internalProblem, - ReadHandlerNotFound: notFoundProblem, - ReadHandlerUnavailable: unavailableProblem, - ReadInputValidationError: invalidProblem, - ReadPermissionDenied: forbiddenProblem, - ReadPermissionUnavailable: unavailableProblem, - ReadPolicyDenied: (failure) => policyProblem(failure.httpStatus), - ReadPolicyEvaluationError: unavailableProblem, - ReadResultValidationError: internalProblem, - }), - Match.exhaustive, - ); +type ReadProblem = + | typeof DuplicateCandidateDetailAuthenticationProblemSchema.Type + | typeof DuplicateCandidateDetailForbiddenProblemSchema.Type + | typeof DuplicateCandidateDetailInternalProblemSchema.Type + | typeof DuplicateCandidateDetailInvalidProblemSchema.Type + | typeof DuplicateCandidateDetailNotFoundProblemSchema.Type + | typeof DuplicateCandidateDetailPolicyConflictProblemSchema.Type + | typeof DuplicateCandidateDetailPolicyProblemSchema.Type + | typeof DuplicateCandidateDetailUnavailableProblemSchema.Type; + +const problems: GovernedReadProblems = { + authentication: authenticationProblem, + forbidden: forbiddenProblem, + internal: internalProblem, + invalid: invalidProblem, + notFound: notFoundProblem, + policy: policyProblem, + unavailable: unavailableProblem, + isAuthentication: Schema.is(DuplicateCandidateDetailAuthenticationProblemSchema), +}; export const duplicateCandidateDetailReadApiLive = HttpApiBuilder.group( partyRegistryApi, @@ -118,36 +104,10 @@ export const duplicateCandidateDetailReadApiLive = HttpApiBuilder.group( (handlers) => handlers.handle( 'execute', - Effect.fn('duplicateCandidateDetailReadApiLive.execute')( - function* executeDuplicateCandidateDetailRead({ payload, request }) { - const correlationId = request.headers['x-correlation-id']; - if (correlationId === undefined || correlationId.trim().length === 0) { - return yield* Effect.fail(invalidProblem()); - } - const principal = yield* authenticateOperationPrincipal( - Redacted.make(request.headers['authorization']), - { - authentication: authenticationProblem, - unavailable: unavailableProblem, - }, - ); - const runtime = yield* ReadRuntime; - return yield* runtime - .runRead({ - input: payload, - principal, - registration: duplicateCandidateDetailRead, - transport: { correlationId }, - }) - .pipe( - Effect.catch((error) => { - const problem = readProblem(error); - return (isAuthenticationProblem(problem) ? bearerChallenge : Effect.void).pipe( - Effect.andThen(Effect.fail(problem)), - ); - }), - ); - }, - ), + governedReadHandler({ + spanName: 'duplicateCandidateDetailReadApiLive.execute', + registration: duplicateCandidateDetailRead, + problems, + }), ), ); diff --git a/app/verticals/party-registry/api/governed-detail-read-execution.ts b/app/verticals/party-registry/api/governed-detail-read-execution.ts new file mode 100644 index 000000000..f5876effa --- /dev/null +++ b/app/verticals/party-registry/api/governed-detail-read-execution.ts @@ -0,0 +1,139 @@ +import type { HttpServerRequest } from '@modern-js/plugin-bff/effect-edge'; +import { + ReadEvidenceValidationError, + ReadHandlerExecutionError, + ReadResultValidationError, + ReadRuntime, +} from '@app/core-runtime'; +import type { ReadCoreError, ReadRegistration } from '@app/core-runtime'; +import { Effect, HttpEffect, HttpServerResponse } from '@modern-js/plugin-bff/effect-edge'; +import { Cause, Match, Redacted, Schema } from 'effect'; +import { authenticateOperationPrincipal } from './auth/action-principal.ts'; + +interface ReadProblems< + Authentication, + Forbidden, + Internal, + Invalid, + NotFound, + Policy, + Unavailable, +> { + readonly authentication: (cause?: unknown) => Authentication; + readonly forbidden: (cause?: unknown) => Forbidden; + readonly internal: (cause?: unknown) => Internal; + readonly invalid: (cause?: unknown) => Invalid; + readonly notFound: (cause?: unknown) => NotFound; + readonly policy: (status: 409 | 422, cause?: unknown) => Policy; + readonly unavailable: (cause?: unknown) => Unavailable; +} + +const bearerChallenge = HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed(HttpServerResponse.setHeader(response, 'www-authenticate', 'Bearer')), +); +const isInternalReadError = Schema.is( + Schema.Union([ReadEvidenceValidationError, ReadHandlerExecutionError, ReadResultValidationError]), +); +const hasInternalReadFailure = (cause: Cause.Cause) => + cause.reasons.some((reason) => Cause.isFailReason(reason) && isInternalReadError(reason.error)); + +const mapReadProblem = ( + problem: ReadProblems, + error: ReadCoreError, +) => + Match.value(error).pipe( + Match.tags({ + ModuleStateCheckUnavailableError: problem.unavailable, + ModuleStateDeniedError: problem.forbidden, + OperationAuthenticationRequired: problem.authentication, + OperationContextDenied: problem.forbidden, + OperationContextInvalid: problem.forbidden, + OperationContextUnavailable: problem.unavailable, + ReadEvidencePersistenceError: problem.unavailable, + ReadEvidenceValidationError: problem.internal, + ReadHandlerExecutionError: problem.internal, + ReadHandlerNotFound: problem.notFound, + ReadHandlerUnavailable: problem.unavailable, + ReadInputValidationError: problem.invalid, + ReadPermissionDenied: problem.forbidden, + ReadPermissionUnavailable: problem.unavailable, + ReadPolicyDenied: (failure) => problem.policy(failure.httpStatus, failure), + ReadPolicyEvaluationError: problem.unavailable, + ReadResultValidationError: problem.internal, + }), + Match.exhaustive, + ); + +export const executeGovernedRead = < + InputSchema extends Schema.ConstraintDecoder, + ResultSchema extends Schema.ConstraintDecoder, + Owner extends string, + Services, + HandlerError, + Requirements, + A, + F, + I, + V, + N, + P, + U, +>(options: { + readonly registration: ReadRegistration< + InputSchema, + ResultSchema, + Owner, + Services, + HandlerError, + Requirements + >; + readonly problem: ReadProblems; + readonly isAuthenticationProblem: ( + value: ReturnType>, + ) => boolean; + readonly internalFailureMessage?: string; +}) => + Effect.fn(function* executeRead({ + payload, + request, + }: { + readonly payload: unknown; + readonly request: HttpServerRequest.HttpServerRequest; + }) { + const { problem } = options; + const correlationId = request.headers['x-correlation-id']; + if (correlationId === undefined || correlationId.trim().length === 0) { + return yield* Effect.fail(problem.invalid()); + } + const principal = yield* authenticateOperationPrincipal( + Redacted.make(request.headers['authorization']), + { + authentication: () => problem.authentication(), + unavailable: () => problem.unavailable(), + }, + ); + const runtime = yield* ReadRuntime; + const read = runtime.runRead({ + input: payload, + principal, + registration: options.registration, + transport: { correlationId }, + }); + const message = options.internalFailureMessage; + const loggedRead = + message === undefined + ? read + : read.pipe( + Effect.tapCauseIf(hasInternalReadFailure, (cause) => + Effect.logError(message).pipe(Effect.annotateLogs({ cause })), + ), + ); + return yield* loggedRead.pipe( + Effect.catch((error) => { + const mapped = mapReadProblem(problem, error); + return (options.isAuthenticationProblem(mapped) ? bearerChallenge : Effect.void).pipe( + Effect.andThen(Effect.fail(mapped)), + ); + }), + ); + }); diff --git a/app/verticals/party-registry/api/governed-read-handler.ts b/app/verticals/party-registry/api/governed-read-handler.ts new file mode 100644 index 000000000..094f08083 --- /dev/null +++ b/app/verticals/party-registry/api/governed-read-handler.ts @@ -0,0 +1,112 @@ +import { ReadRuntime } from '@app/core-runtime'; +import type { ReadCoreError, ReadRegistration } from '@app/core-runtime'; +import { Effect, HttpEffect, HttpServerResponse } from '@modern-js/plugin-bff/effect-edge'; +import type { HttpServerRequest } from '@modern-js/plugin-bff/effect-edge'; +import { Match, Redacted } from 'effect'; +import type { Schema } from 'effect'; +import { authenticateOperationPrincipal } from './auth/action-principal.ts'; + +export interface GovernedReadProblems { + readonly authentication: () => Problem; + readonly forbidden: () => Problem; + readonly internal: () => Problem; + readonly invalid: () => Problem; + readonly notFound: () => Problem; + readonly policy: (status: 409 | 422) => Problem; + readonly unavailable: () => Problem; + readonly isAuthentication: (problem: Problem) => boolean; +} + +const readProblem = (error: ReadCoreError, problems: GovernedReadProblems) => + Match.value(error).pipe( + Match.tags({ + ModuleStateCheckUnavailableError: () => problems.unavailable, + ModuleStateDeniedError: () => problems.forbidden, + OperationAuthenticationRequired: () => problems.authentication, + OperationContextDenied: () => problems.forbidden, + OperationContextInvalid: () => problems.forbidden, + OperationContextUnavailable: () => problems.unavailable, + ReadEvidencePersistenceError: () => problems.unavailable, + ReadEvidenceValidationError: () => problems.internal, + ReadHandlerExecutionError: () => problems.internal, + ReadHandlerNotFound: () => problems.notFound, + ReadHandlerUnavailable: () => problems.unavailable, + ReadInputValidationError: () => problems.invalid, + ReadPermissionDenied: () => problems.forbidden, + ReadPermissionUnavailable: () => problems.unavailable, + ReadPolicyDenied: + ({ httpStatus }) => + () => + problems.policy(httpStatus), + ReadPolicyEvaluationError: () => problems.unavailable, + ReadResultValidationError: () => problems.internal, + }), + Match.exhaustive, + )(); + +const bearerChallenge = HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed(HttpServerResponse.setHeader(response, 'www-authenticate', 'Bearer')), +); + +const failReadProblem = ( + error: ReadCoreError, + problems: GovernedReadProblems, +) => { + const problem = readProblem(error, problems); + return (problems.isAuthentication(problem) ? bearerChallenge : Effect.void).pipe( + Effect.andThen(Effect.fail(problem)), + ); +}; + +interface GovernedReadRequest { + readonly payload: unknown; + readonly request: HttpServerRequest.HttpServerRequest; +} + +/** Preserve the HTTP acquisition order and typed failures for each registered read. */ +export const governedReadHandler = < + InputSchema extends Schema.ConstraintDecoder, + ResultSchema extends Schema.ConstraintDecoder, + Owner extends string, + Services, + HandlerError, + Requirements, + Problem, +>(options: { + readonly spanName: string; + readonly registration: ReadRegistration< + InputSchema, + ResultSchema, + Owner, + Services, + HandlerError, + Requirements + >; + readonly problems: GovernedReadProblems; +}) => + Effect.fn(options.spanName)(function* executeGovernedRead({ + payload, + request, + }: GovernedReadRequest) { + const { problems } = options; + const correlationId = request.headers['x-correlation-id']; + if (correlationId === undefined || correlationId.trim().length === 0) { + return yield* Effect.fail(problems.invalid()); + } + const principal = yield* authenticateOperationPrincipal( + Redacted.make(request.headers['authorization']), + { + authentication: problems.authentication, + unavailable: problems.unavailable, + }, + ); + const runtime = yield* ReadRuntime; + return yield* runtime + .runRead({ + input: payload, + principal, + registration: options.registration, + transport: { correlationId }, + }) + .pipe(Effect.catch((error) => failReadProblem(error, problems))); + }); diff --git a/app/verticals/party-registry/api/organization-engagement-profile-read-server.ts b/app/verticals/party-registry/api/organization-engagement-profile-read-server.ts index 454a77323..4c5942aaf 100644 --- a/app/verticals/party-registry/api/organization-engagement-profile-read-server.ts +++ b/app/verticals/party-registry/api/organization-engagement-profile-read-server.ts @@ -1,13 +1,5 @@ -// @generated by OntOS Codesmith module-api v1 -import { ReadRuntime } from '@app/core-runtime'; -import type { ReadCoreError } from '@app/core-runtime'; -import { - Effect, - HttpApiBuilder, - HttpEffect, - HttpServerResponse, -} from '@modern-js/plugin-bff/effect-edge'; -import { Match, Redacted, Schema } from 'effect'; +import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; +import { Schema } from 'effect'; import { partyRegistryApi } from '../shared/api.ts'; import { OrganizationEngagementProfileAuthenticationProblemSchema, @@ -20,19 +12,10 @@ import { OrganizationEngagementProfileUnavailableProblemSchema, } from '../shared/apis/organization-engagement-profile.ts'; import { organizationEngagementProfileRead } from '../src/api/organization-engagement-profile.read.ts'; -import { authenticateOperationPrincipal } from './auth/action-principal.ts'; +import { governedReadHandler } from './governed-read-handler.ts'; +import type { GovernedReadProblems } from './governed-read-handler.ts'; import { governedReadProblemStatus } from './read-server-support.ts'; -type OrganizationEngagementProfileProblem = - | typeof OrganizationEngagementProfileAuthenticationProblemSchema.Type - | typeof OrganizationEngagementProfileForbiddenProblemSchema.Type - | typeof OrganizationEngagementProfileInternalProblemSchema.Type - | typeof OrganizationEngagementProfileInvalidProblemSchema.Type - | typeof OrganizationEngagementProfileNotFoundProblemSchema.Type - | typeof OrganizationEngagementProfilePolicyConflictProblemSchema.Type - | typeof OrganizationEngagementProfilePolicyProblemSchema.Type - | typeof OrganizationEngagementProfileUnavailableProblemSchema.Type; - const authenticationProblem = () => OrganizationEngagementProfileAuthenticationProblemSchema.make({ detail: 'A valid audience-scoped Bearer assertion is required.', @@ -90,37 +73,26 @@ const internalProblem = () => title: 'Read failed', type: 'https://ontos.dev/problems/read-failed', }); -const bearerChallenge = HttpEffect.appendPreResponseHandler((_request, response) => - Effect.succeed(HttpServerResponse.setHeader(response, 'www-authenticate', 'Bearer')), -); -const isAuthenticationProblem = Schema.is(OrganizationEngagementProfileAuthenticationProblemSchema); -const failProblem = (mapped: Problem) => - (isAuthenticationProblem(mapped) ? bearerChallenge : Effect.void).pipe( - Effect.andThen(Effect.fail(mapped)), - ); -const readProblem = (error: ReadCoreError): OrganizationEngagementProfileProblem => - Match.value(error).pipe( - Match.tags({ - ModuleStateCheckUnavailableError: unavailableProblem, - ModuleStateDeniedError: forbiddenProblem, - OperationAuthenticationRequired: authenticationProblem, - OperationContextDenied: forbiddenProblem, - OperationContextInvalid: forbiddenProblem, - OperationContextUnavailable: unavailableProblem, - ReadEvidencePersistenceError: unavailableProblem, - ReadEvidenceValidationError: internalProblem, - ReadHandlerExecutionError: internalProblem, - ReadHandlerNotFound: notFoundProblem, - ReadHandlerUnavailable: unavailableProblem, - ReadInputValidationError: invalidProblem, - ReadPermissionDenied: forbiddenProblem, - ReadPermissionUnavailable: unavailableProblem, - ReadPolicyDenied: ({ httpStatus }) => policyProblem(httpStatus), - ReadPolicyEvaluationError: unavailableProblem, - ReadResultValidationError: internalProblem, - }), - Match.exhaustive, - ); +type ReadProblem = + | typeof OrganizationEngagementProfileAuthenticationProblemSchema.Type + | typeof OrganizationEngagementProfileForbiddenProblemSchema.Type + | typeof OrganizationEngagementProfileInternalProblemSchema.Type + | typeof OrganizationEngagementProfileInvalidProblemSchema.Type + | typeof OrganizationEngagementProfileNotFoundProblemSchema.Type + | typeof OrganizationEngagementProfilePolicyConflictProblemSchema.Type + | typeof OrganizationEngagementProfilePolicyProblemSchema.Type + | typeof OrganizationEngagementProfileUnavailableProblemSchema.Type; + +const problems: GovernedReadProblems = { + authentication: authenticationProblem, + forbidden: forbiddenProblem, + internal: internalProblem, + invalid: invalidProblem, + notFound: notFoundProblem, + policy: policyProblem, + unavailable: unavailableProblem, + isAuthentication: Schema.is(OrganizationEngagementProfileAuthenticationProblemSchema), +}; export const organizationEngagementProfileReadApiLive = HttpApiBuilder.group( partyRegistryApi, @@ -128,30 +100,10 @@ export const organizationEngagementProfileReadApiLive = HttpApiBuilder.group( (handlers) => handlers.handle( 'execute', - Effect.fn('organizationEngagementProfileReadApiLive.execute')(function* executeRead({ - payload, - request, - }) { - const correlationId = request.headers['x-correlation-id']; - if (correlationId === undefined || correlationId.trim().length === 0) { - return yield* Effect.fail(invalidProblem()); - } - const principal = yield* authenticateOperationPrincipal( - Redacted.make(request.headers['authorization']), - { - authentication: authenticationProblem, - unavailable: unavailableProblem, - }, - ); - const runtime = yield* ReadRuntime; - return yield* runtime - .runRead({ - input: payload, - principal, - registration: organizationEngagementProfileRead, - transport: { correlationId }, - }) - .pipe(Effect.catch((error) => error.pipe(readProblem, failProblem))); + governedReadHandler({ + spanName: 'organizationEngagementProfileReadApiLive.execute', + registration: organizationEngagementProfileRead, + problems, }), ), ); diff --git a/app/verticals/party-registry/api/parties-search-server.ts b/app/verticals/party-registry/api/parties-search-server.ts index eb13a8dea..c696c6c72 100644 --- a/app/verticals/party-registry/api/parties-search-server.ts +++ b/app/verticals/party-registry/api/parties-search-server.ts @@ -1,14 +1,7 @@ // @generated by OntOS Codesmith Governed Contribution v1 // @ontos-contribution-kind search-provider -import { ReadRuntime } from '@app/core-runtime'; -import type { ReadCoreError } from '@app/core-runtime'; -import { - Effect, - HttpApiBuilder, - HttpEffect, - HttpServerResponse, -} from '@modern-js/plugin-bff/effect-edge'; -import { Match, Redacted, Schema } from 'effect'; +import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; +import { Schema } from 'effect'; import { partyRegistryApi } from '../shared/api.ts'; import { PartiesProviderAuthenticationProblemSchema, @@ -21,7 +14,8 @@ import { PartiesProviderUnavailableProblemSchema, } from '../shared/apis/parties-search.ts'; import { partiesRead } from '../src/search/parties.provider.ts'; -import { authenticateOperationPrincipal } from './auth/action-principal.ts'; +import { governedReadHandler } from './governed-read-handler.ts'; +import type { GovernedReadProblems } from './governed-read-handler.ts'; const problemStatus = { authentication: 401, @@ -91,10 +85,7 @@ const internalProblem = () => title: 'Read failed', type: 'https://ontos.dev/problems/read-failed', }); -const bearerChallenge = HttpEffect.appendPreResponseHandler((_request, response) => - Effect.succeed(HttpServerResponse.setHeader(response, 'www-authenticate', 'Bearer')), -); -type PartiesProblem = +type ReadProblem = | typeof PartiesProviderAuthenticationProblemSchema.Type | typeof PartiesProviderForbiddenProblemSchema.Type | typeof PartiesProviderInternalProblemSchema.Type @@ -103,34 +94,17 @@ type PartiesProblem = | typeof PartiesProviderPolicyConflictProblemSchema.Type | typeof PartiesProviderPolicyProblemSchema.Type | typeof PartiesProviderUnavailableProblemSchema.Type; -const isAuthenticationProblem = Schema.is(PartiesProviderAuthenticationProblemSchema); -const failProblem = (problem: Problem) => - (isAuthenticationProblem(problem) ? bearerChallenge : Effect.void).pipe( - Effect.andThen(Effect.fail(problem)), - ); -const readProblem = (readError: ReadCoreError): PartiesProblem => - Match.value(readError).pipe( - Match.tags({ - ModuleStateCheckUnavailableError: unavailableProblem, - ModuleStateDeniedError: forbiddenProblem, - OperationAuthenticationRequired: authenticationProblem, - OperationContextDenied: forbiddenProblem, - OperationContextInvalid: forbiddenProblem, - OperationContextUnavailable: unavailableProblem, - ReadEvidencePersistenceError: unavailableProblem, - ReadEvidenceValidationError: internalProblem, - ReadHandlerExecutionError: internalProblem, - ReadHandlerNotFound: notFoundProblem, - ReadHandlerUnavailable: unavailableProblem, - ReadInputValidationError: invalidProblem, - ReadPermissionDenied: forbiddenProblem, - ReadPermissionUnavailable: unavailableProblem, - ReadPolicyDenied: ({ httpStatus }) => policyProblem(httpStatus), - ReadPolicyEvaluationError: unavailableProblem, - ReadResultValidationError: internalProblem, - }), - Match.exhaustive, - ); + +const problems: GovernedReadProblems = { + authentication: authenticationProblem, + forbidden: forbiddenProblem, + internal: internalProblem, + invalid: invalidProblem, + notFound: notFoundProblem, + policy: policyProblem, + unavailable: unavailableProblem, + isAuthentication: Schema.is(PartiesProviderAuthenticationProblemSchema), +}; export const partiesReadApiLive = HttpApiBuilder.group( partyRegistryApi, @@ -138,27 +112,10 @@ export const partiesReadApiLive = HttpApiBuilder.group( (handlers) => handlers.handle( 'execute', - Effect.fn('partiesReadApiLive.execute')(function* handlePartiesRead({ payload, request }) { - const correlationId = request.headers['x-correlation-id']; - if (correlationId === undefined || correlationId.trim().length === 0) { - return yield* Effect.fail(invalidProblem()); - } - const principal = yield* authenticateOperationPrincipal( - Redacted.make(request.headers['authorization']), - { - authentication: authenticationProblem, - unavailable: unavailableProblem, - }, - ); - const runtime = yield* ReadRuntime; - return yield* runtime - .runRead({ - input: payload, - principal, - registration: partiesRead, - transport: { correlationId }, - }) - .pipe(Effect.catch((error) => error.pipe(readProblem, failProblem))); + governedReadHandler({ + spanName: 'partiesReadApiLive.execute', + registration: partiesRead, + problems, }), ), ); diff --git a/app/verticals/party-registry/api/party-contact-point-detail-read-server.ts b/app/verticals/party-registry/api/party-contact-point-detail-read-server.ts index 14f517a48..baba64b82 100644 --- a/app/verticals/party-registry/api/party-contact-point-detail-read-server.ts +++ b/app/verticals/party-registry/api/party-contact-point-detail-read-server.ts @@ -1,13 +1,5 @@ -// @generated by OntOS Codesmith module-api v1 -import { ReadRuntime } from '@app/core-runtime'; -import type { ReadCoreError } from '@app/core-runtime'; -import { - Effect, - HttpApiBuilder, - HttpEffect, - HttpServerResponse, -} from '@modern-js/plugin-bff/effect-edge'; -import { Match, Redacted } from 'effect'; +import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; +import { Schema } from 'effect'; import { PartyContactPointDetailAuthenticationProblemSchema, PartyContactPointDetailForbiddenProblemSchema, @@ -20,7 +12,8 @@ import { partyRegistryApi, } from '../shared/api.ts'; import { partyContactPointDetailRead } from '../src/api/party-contact-point-detail.read.ts'; -import { authenticateOperationPrincipal } from './auth/action-principal.ts'; +import { governedReadHandler } from './governed-read-handler.ts'; +import type { GovernedReadProblems } from './governed-read-handler.ts'; const problemStatus = { authentication: 401, @@ -90,32 +83,26 @@ const internalProblem = () => title: 'Read failed', type: 'https://ontos.dev/problems/read-failed', }); -const bearerChallenge = HttpEffect.appendPreResponseHandler((_request, response) => - Effect.succeed(HttpServerResponse.setHeader(response, 'www-authenticate', 'Bearer')), -); -const readProblem = (error: ReadCoreError) => - Match.value(error).pipe( - Match.tags({ - ModuleStateCheckUnavailableError: unavailableProblem, - ModuleStateDeniedError: forbiddenProblem, - OperationAuthenticationRequired: authenticationProblem, - OperationContextDenied: forbiddenProblem, - OperationContextInvalid: forbiddenProblem, - OperationContextUnavailable: unavailableProblem, - ReadEvidencePersistenceError: unavailableProblem, - ReadEvidenceValidationError: internalProblem, - ReadHandlerExecutionError: internalProblem, - ReadHandlerNotFound: notFoundProblem, - ReadHandlerUnavailable: unavailableProblem, - ReadInputValidationError: invalidProblem, - ReadPermissionDenied: forbiddenProblem, - ReadPermissionUnavailable: unavailableProblem, - ReadPolicyDenied: ({ httpStatus }) => policyProblem(httpStatus), - ReadPolicyEvaluationError: unavailableProblem, - ReadResultValidationError: internalProblem, - }), - Match.exhaustive, - ); +type ReadProblem = + | typeof PartyContactPointDetailAuthenticationProblemSchema.Type + | typeof PartyContactPointDetailForbiddenProblemSchema.Type + | typeof PartyContactPointDetailInternalProblemSchema.Type + | typeof PartyContactPointDetailInvalidProblemSchema.Type + | typeof PartyContactPointDetailNotFoundProblemSchema.Type + | typeof PartyContactPointDetailPolicyConflictProblemSchema.Type + | typeof PartyContactPointDetailPolicyProblemSchema.Type + | typeof PartyContactPointDetailUnavailableProblemSchema.Type; + +const problems: GovernedReadProblems = { + authentication: authenticationProblem, + forbidden: forbiddenProblem, + internal: internalProblem, + invalid: invalidProblem, + notFound: notFoundProblem, + policy: policyProblem, + unavailable: unavailableProblem, + isAuthentication: Schema.is(PartyContactPointDetailAuthenticationProblemSchema), +}; export const partyContactPointDetailReadApiLive = HttpApiBuilder.group( partyRegistryApi, @@ -123,34 +110,10 @@ export const partyContactPointDetailReadApiLive = HttpApiBuilder.group( (handlers) => handlers.handle( 'execute', - Effect.fn('partyContactPointDetailReadApiLive.execute')( - function* handleContactPointDetailRead({ payload, request }) { - const correlationId = request.headers['x-correlation-id']; - if (correlationId === undefined || correlationId.trim().length === 0) { - return yield* Effect.fail(invalidProblem()); - } - const principal = yield* authenticateOperationPrincipal( - Redacted.make(request.headers['authorization']), - { - authentication: authenticationProblem, - unavailable: unavailableProblem, - }, - ); - const runtime = yield* ReadRuntime; - return yield* runtime - .runRead({ - input: payload, - principal, - registration: partyContactPointDetailRead, - transport: { correlationId }, - }) - .pipe( - Effect.mapError(readProblem), - Effect.catchTag('PartyContactPointDetailAuthenticationProblem', (problem) => - bearerChallenge.pipe(Effect.andThen(Effect.fail(problem))), - ), - ); - }, - ), + governedReadHandler({ + spanName: 'partyContactPointDetailReadApiLive.execute', + registration: partyContactPointDetailRead, + problems, + }), ), ); diff --git a/app/verticals/party-registry/api/party-contact-points-read-server.ts b/app/verticals/party-registry/api/party-contact-points-read-server.ts index f413ca555..41f186426 100644 --- a/app/verticals/party-registry/api/party-contact-points-read-server.ts +++ b/app/verticals/party-registry/api/party-contact-points-read-server.ts @@ -1,13 +1,5 @@ -// @generated by OntOS Codesmith module-api v1 -import { ReadRuntime } from '@app/core-runtime'; -import type { ReadCoreError } from '@app/core-runtime'; -import { - Effect, - HttpApiBuilder, - HttpEffect, - HttpServerResponse, -} from '@modern-js/plugin-bff/effect-edge'; -import { Match, Redacted } from 'effect'; +import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; +import { Schema } from 'effect'; import { PartyContactPointsAuthenticationProblemSchema, PartyContactPointsForbiddenProblemSchema, @@ -20,7 +12,8 @@ import { partyRegistryApi, } from '../shared/api.ts'; import { partyContactPointsRead } from '../src/api/party-contact-points.read.ts'; -import { authenticateOperationPrincipal } from './auth/action-principal.ts'; +import { governedReadHandler } from './governed-read-handler.ts'; +import type { GovernedReadProblems } from './governed-read-handler.ts'; const problemStatus = { authentication: 401, @@ -90,32 +83,26 @@ const internalProblem = () => title: 'Read failed', type: 'https://ontos.dev/problems/read-failed', }); -const bearerChallenge = HttpEffect.appendPreResponseHandler((_request, response) => - Effect.succeed(HttpServerResponse.setHeader(response, 'www-authenticate', 'Bearer')), -); -const readProblem = (error: ReadCoreError) => - Match.value(error).pipe( - Match.tags({ - ModuleStateCheckUnavailableError: unavailableProblem, - ModuleStateDeniedError: forbiddenProblem, - OperationAuthenticationRequired: authenticationProblem, - OperationContextDenied: forbiddenProblem, - OperationContextInvalid: forbiddenProblem, - OperationContextUnavailable: unavailableProblem, - ReadEvidencePersistenceError: unavailableProblem, - ReadEvidenceValidationError: internalProblem, - ReadHandlerExecutionError: internalProblem, - ReadHandlerNotFound: notFoundProblem, - ReadHandlerUnavailable: unavailableProblem, - ReadInputValidationError: invalidProblem, - ReadPermissionDenied: forbiddenProblem, - ReadPermissionUnavailable: unavailableProblem, - ReadPolicyDenied: ({ httpStatus }) => policyProblem(httpStatus), - ReadPolicyEvaluationError: unavailableProblem, - ReadResultValidationError: internalProblem, - }), - Match.exhaustive, - ); +type ReadProblem = + | typeof PartyContactPointsAuthenticationProblemSchema.Type + | typeof PartyContactPointsForbiddenProblemSchema.Type + | typeof PartyContactPointsInternalProblemSchema.Type + | typeof PartyContactPointsInvalidProblemSchema.Type + | typeof PartyContactPointsNotFoundProblemSchema.Type + | typeof PartyContactPointsPolicyConflictProblemSchema.Type + | typeof PartyContactPointsPolicyProblemSchema.Type + | typeof PartyContactPointsUnavailableProblemSchema.Type; + +const problems: GovernedReadProblems = { + authentication: authenticationProblem, + forbidden: forbiddenProblem, + internal: internalProblem, + invalid: invalidProblem, + notFound: notFoundProblem, + policy: policyProblem, + unavailable: unavailableProblem, + isAuthentication: Schema.is(PartyContactPointsAuthenticationProblemSchema), +}; export const partyContactPointsReadApiLive = HttpApiBuilder.group( partyRegistryApi, @@ -123,35 +110,10 @@ export const partyContactPointsReadApiLive = HttpApiBuilder.group( (handlers) => handlers.handle( 'execute', - Effect.fn('partyContactPointsReadApiLive.execute')(function* handleContactPointsRead({ - payload, - request, - }) { - const correlationId = request.headers['x-correlation-id']; - if (correlationId === undefined || correlationId.trim().length === 0) { - return yield* Effect.fail(invalidProblem()); - } - const principal = yield* authenticateOperationPrincipal( - Redacted.make(request.headers['authorization']), - { - authentication: authenticationProblem, - unavailable: unavailableProblem, - }, - ); - const runtime = yield* ReadRuntime; - return yield* runtime - .runRead({ - input: payload, - principal, - registration: partyContactPointsRead, - transport: { correlationId }, - }) - .pipe( - Effect.mapError(readProblem), - Effect.catchTag('PartyContactPointsAuthenticationProblem', (problem) => - bearerChallenge.pipe(Effect.andThen(Effect.fail(problem))), - ), - ); + governedReadHandler({ + spanName: 'partyContactPointsReadApiLive.execute', + registration: partyContactPointsRead, + problems, }), ), ); diff --git a/app/verticals/party-registry/api/party-correction-read-server.ts b/app/verticals/party-registry/api/party-correction-read-server.ts index 11ec05b51..1413232d0 100644 --- a/app/verticals/party-registry/api/party-correction-read-server.ts +++ b/app/verticals/party-registry/api/party-correction-read-server.ts @@ -1,13 +1,5 @@ -// @generated by OntOS Codesmith module-api v1 -import { ReadRuntime } from '@app/core-runtime'; -import type { ReadCoreError } from '@app/core-runtime'; -import { - Effect, - HttpApiBuilder, - HttpEffect, - HttpServerResponse, -} from '@modern-js/plugin-bff/effect-edge'; -import { Match, Redacted, Schema } from 'effect'; +import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; +import { Schema } from 'effect'; import { partyRegistryApi } from '../shared/api.ts'; import { PartyCorrectionAuthenticationProblemSchema, @@ -20,7 +12,8 @@ import { PartyCorrectionUnavailableProblemSchema, } from '../shared/apis/party-correction.ts'; import { partyCorrectionRead } from '../src/api/party-correction.read.ts'; -import { authenticateOperationPrincipal } from './auth/action-principal.ts'; +import { governedReadHandler } from './governed-read-handler.ts'; +import type { GovernedReadProblems } from './governed-read-handler.ts'; import { governedReadProblemStatus } from './read-server-support.ts'; const authenticationProblem = () => @@ -80,33 +73,26 @@ const internalProblem = () => title: 'Read failed', type: 'https://ontos.dev/problems/read-failed', }); -const bearerChallenge = HttpEffect.appendPreResponseHandler((_request, response) => - Effect.succeed(HttpServerResponse.setHeader(response, 'www-authenticate', 'Bearer')), -); -const isAuthenticationProblem = Schema.is(PartyCorrectionAuthenticationProblemSchema); -const readProblem = (error: ReadCoreError) => - Match.value(error).pipe( - Match.tags({ - ModuleStateCheckUnavailableError: unavailableProblem, - ModuleStateDeniedError: forbiddenProblem, - OperationAuthenticationRequired: authenticationProblem, - OperationContextDenied: forbiddenProblem, - OperationContextInvalid: forbiddenProblem, - OperationContextUnavailable: unavailableProblem, - ReadEvidencePersistenceError: unavailableProblem, - ReadEvidenceValidationError: internalProblem, - ReadHandlerExecutionError: internalProblem, - ReadHandlerNotFound: notFoundProblem, - ReadHandlerUnavailable: unavailableProblem, - ReadInputValidationError: invalidProblem, - ReadPermissionDenied: forbiddenProblem, - ReadPermissionUnavailable: unavailableProblem, - ReadPolicyDenied: (failure) => policyProblem(failure.httpStatus), - ReadPolicyEvaluationError: unavailableProblem, - ReadResultValidationError: internalProblem, - }), - Match.exhaustive, - ); +type ReadProblem = + | typeof PartyCorrectionAuthenticationProblemSchema.Type + | typeof PartyCorrectionForbiddenProblemSchema.Type + | typeof PartyCorrectionInternalProblemSchema.Type + | typeof PartyCorrectionInvalidProblemSchema.Type + | typeof PartyCorrectionNotFoundProblemSchema.Type + | typeof PartyCorrectionPolicyConflictProblemSchema.Type + | typeof PartyCorrectionPolicyProblemSchema.Type + | typeof PartyCorrectionUnavailableProblemSchema.Type; + +const problems: GovernedReadProblems = { + authentication: authenticationProblem, + forbidden: forbiddenProblem, + internal: internalProblem, + invalid: invalidProblem, + notFound: notFoundProblem, + policy: policyProblem, + unavailable: unavailableProblem, + isAuthentication: Schema.is(PartyCorrectionAuthenticationProblemSchema), +}; export const partyCorrectionReadApiLive = HttpApiBuilder.group( partyRegistryApi, @@ -114,34 +100,10 @@ export const partyCorrectionReadApiLive = HttpApiBuilder.group( (handlers) => handlers.handle( 'execute', - Effect.fn('partyCorrectionReadApiLive.execute')(function* executeRead({ payload, request }) { - const correlationId = request.headers['x-correlation-id']; - if (correlationId === undefined || correlationId.trim().length === 0) { - return yield* Effect.fail(invalidProblem()); - } - const principal = yield* authenticateOperationPrincipal( - Redacted.make(request.headers['authorization']), - { - authentication: authenticationProblem, - unavailable: unavailableProblem, - }, - ); - const runtime = yield* ReadRuntime; - return yield* runtime - .runRead({ - input: payload, - principal, - registration: partyCorrectionRead, - transport: { correlationId }, - }) - .pipe( - Effect.catch((error) => { - const problem = readProblem(error); - return (isAuthenticationProblem(problem) ? bearerChallenge : Effect.void).pipe( - Effect.andThen(Effect.fail(problem)), - ); - }), - ); + governedReadHandler({ + spanName: 'partyCorrectionReadApiLive.execute', + registration: partyCorrectionRead, + problems, }), ), ); diff --git a/app/verticals/party-registry/api/party-detail-read-server.ts b/app/verticals/party-registry/api/party-detail-read-server.ts index 1c35a2bbd..da96d7b13 100644 --- a/app/verticals/party-registry/api/party-detail-read-server.ts +++ b/app/verticals/party-registry/api/party-detail-read-server.ts @@ -1,13 +1,5 @@ -// @generated by OntOS Codesmith module-api v1 -import { ReadRuntime } from '@app/core-runtime'; -import type { ReadCoreError } from '@app/core-runtime'; -import { - Effect, - HttpApiBuilder, - HttpEffect, - HttpServerResponse, -} from '@modern-js/plugin-bff/effect-edge'; -import { Match, Redacted, Schema } from 'effect'; +import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; +import { Schema } from 'effect'; import { partyRegistryApi } from '../shared/api.ts'; import { PartyDetailAuthenticationProblemSchema, @@ -20,7 +12,8 @@ import { PartyDetailUnavailableProblemSchema, } from '../shared/apis/party-detail.ts'; import { partyDetailRead } from '../src/api/party-detail.read.ts'; -import { authenticateOperationPrincipal } from './auth/action-principal.ts'; +import { governedReadHandler } from './governed-read-handler.ts'; +import type { GovernedReadProblems } from './governed-read-handler.ts'; import { governedReadProblemStatus } from './read-server-support.ts'; const authenticationProblem = () => @@ -80,33 +73,26 @@ const internalProblem = () => title: 'Read failed', type: 'https://ontos.dev/problems/read-failed', }); -const bearerChallenge = HttpEffect.appendPreResponseHandler((_request, response) => - Effect.succeed(HttpServerResponse.setHeader(response, 'www-authenticate', 'Bearer')), -); -const isAuthenticationProblem = Schema.is(PartyDetailAuthenticationProblemSchema); -const readProblem = (error: ReadCoreError) => - Match.value(error).pipe( - Match.tags({ - ModuleStateCheckUnavailableError: unavailableProblem, - ModuleStateDeniedError: forbiddenProblem, - OperationAuthenticationRequired: authenticationProblem, - OperationContextDenied: forbiddenProblem, - OperationContextInvalid: forbiddenProblem, - OperationContextUnavailable: unavailableProblem, - ReadEvidencePersistenceError: unavailableProblem, - ReadEvidenceValidationError: internalProblem, - ReadHandlerExecutionError: internalProblem, - ReadHandlerNotFound: notFoundProblem, - ReadHandlerUnavailable: unavailableProblem, - ReadInputValidationError: invalidProblem, - ReadPermissionDenied: forbiddenProblem, - ReadPermissionUnavailable: unavailableProblem, - ReadPolicyDenied: (failure) => policyProblem(failure.httpStatus), - ReadPolicyEvaluationError: unavailableProblem, - ReadResultValidationError: internalProblem, - }), - Match.exhaustive, - ); +type ReadProblem = + | typeof PartyDetailAuthenticationProblemSchema.Type + | typeof PartyDetailForbiddenProblemSchema.Type + | typeof PartyDetailInternalProblemSchema.Type + | typeof PartyDetailInvalidProblemSchema.Type + | typeof PartyDetailNotFoundProblemSchema.Type + | typeof PartyDetailPolicyConflictProblemSchema.Type + | typeof PartyDetailPolicyProblemSchema.Type + | typeof PartyDetailUnavailableProblemSchema.Type; + +const problems: GovernedReadProblems = { + authentication: authenticationProblem, + forbidden: forbiddenProblem, + internal: internalProblem, + invalid: invalidProblem, + notFound: notFoundProblem, + policy: policyProblem, + unavailable: unavailableProblem, + isAuthentication: Schema.is(PartyDetailAuthenticationProblemSchema), +}; export const partyDetailReadApiLive = HttpApiBuilder.group( partyRegistryApi, @@ -114,34 +100,10 @@ export const partyDetailReadApiLive = HttpApiBuilder.group( (handlers) => handlers.handle( 'execute', - Effect.fn('partyDetailReadApiLive.execute')(function* executeRead({ payload, request }) { - const correlationId = request.headers['x-correlation-id']; - if (correlationId === undefined || correlationId.trim().length === 0) { - return yield* Effect.fail(invalidProblem()); - } - const principal = yield* authenticateOperationPrincipal( - Redacted.make(request.headers['authorization']), - { - authentication: authenticationProblem, - unavailable: unavailableProblem, - }, - ); - const runtime = yield* ReadRuntime; - return yield* runtime - .runRead({ - input: payload, - principal, - registration: partyDetailRead, - transport: { correlationId }, - }) - .pipe( - Effect.catch((error) => { - const problem = readProblem(error); - return (isAuthenticationProblem(problem) ? bearerChallenge : Effect.void).pipe( - Effect.andThen(Effect.fail(problem)), - ); - }), - ); + governedReadHandler({ + spanName: 'partyDetailReadApiLive.execute', + registration: partyDetailRead, + problems, }), ), ); diff --git a/app/verticals/party-registry/api/party-match-decision-read-server.ts b/app/verticals/party-registry/api/party-match-decision-read-server.ts index 5ca84b6d1..5841975c0 100644 --- a/app/verticals/party-registry/api/party-match-decision-read-server.ts +++ b/app/verticals/party-registry/api/party-match-decision-read-server.ts @@ -1,14 +1,6 @@ -// @generated by OntOS Codesmith module-api v1 /* eslint-disable effect-native/no-hand-built-problem-details -- These literals instantiate the shared typed schemas; the rule cannot recognize factory-produced contract schemas. expires: 2027-03-31. */ -import { ReadRuntime } from '@app/core-runtime'; -import type { ReadCoreError } from '@app/core-runtime'; -import { - Effect, - HttpApiBuilder, - HttpEffect, - HttpServerResponse, -} from '@modern-js/plugin-bff/effect-edge'; -import { Match, Redacted, Schema } from 'effect'; +import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; +import { Schema } from 'effect'; import { partyRegistryApi } from '../shared/api.ts'; import { PartyMatchDecisionAuthenticationProblemSchema, @@ -21,7 +13,8 @@ import { PartyMatchDecisionUnavailableProblemSchema, } from '../shared/apis/party-match-decision.ts'; import { partyMatchDecisionRead } from '../src/api/party-match-decision.read.ts'; -import { authenticateOperationPrincipal } from './auth/action-principal.ts'; +import { governedReadHandler } from './governed-read-handler.ts'; +import type { GovernedReadProblems } from './governed-read-handler.ts'; const authenticationProblem = () => PartyMatchDecisionAuthenticationProblemSchema.make({ @@ -84,33 +77,26 @@ const internalProblem = () => title: 'Read failed', type: 'https://ontos.dev/problems/read-failed', }); -const bearerChallenge = HttpEffect.appendPreResponseHandler((_request, response) => - Effect.succeed(HttpServerResponse.setHeader(response, 'www-authenticate', 'Bearer')), -); -const isAuthenticationProblem = Schema.is(PartyMatchDecisionAuthenticationProblemSchema); -const readProblem = (error: ReadCoreError) => - Match.value(error).pipe( - Match.tags({ - ModuleStateCheckUnavailableError: unavailableProblem, - ModuleStateDeniedError: forbiddenProblem, - OperationAuthenticationRequired: authenticationProblem, - OperationContextDenied: forbiddenProblem, - OperationContextInvalid: forbiddenProblem, - OperationContextUnavailable: unavailableProblem, - ReadEvidencePersistenceError: unavailableProblem, - ReadEvidenceValidationError: internalProblem, - ReadHandlerExecutionError: internalProblem, - ReadHandlerNotFound: notFoundProblem, - ReadHandlerUnavailable: unavailableProblem, - ReadInputValidationError: invalidProblem, - ReadPermissionDenied: forbiddenProblem, - ReadPermissionUnavailable: unavailableProblem, - ReadPolicyDenied: (failure) => policyProblem(failure.httpStatus), - ReadPolicyEvaluationError: unavailableProblem, - ReadResultValidationError: internalProblem, - }), - Match.exhaustive, - ); +type ReadProblem = + | typeof PartyMatchDecisionAuthenticationProblemSchema.Type + | typeof PartyMatchDecisionForbiddenProblemSchema.Type + | typeof PartyMatchDecisionInternalProblemSchema.Type + | typeof PartyMatchDecisionInvalidProblemSchema.Type + | typeof PartyMatchDecisionNotFoundProblemSchema.Type + | typeof PartyMatchDecisionPolicyConflictProblemSchema.Type + | typeof PartyMatchDecisionPolicyProblemSchema.Type + | typeof PartyMatchDecisionUnavailableProblemSchema.Type; + +const problems: GovernedReadProblems = { + authentication: authenticationProblem, + forbidden: forbiddenProblem, + internal: internalProblem, + invalid: invalidProblem, + notFound: notFoundProblem, + policy: policyProblem, + unavailable: unavailableProblem, + isAuthentication: Schema.is(PartyMatchDecisionAuthenticationProblemSchema), +}; export const partyMatchDecisionReadApiLive = HttpApiBuilder.group( partyRegistryApi, @@ -118,37 +104,10 @@ export const partyMatchDecisionReadApiLive = HttpApiBuilder.group( (handlers) => handlers.handle( 'execute', - Effect.fn('partyMatchDecisionReadApiLive.execute')(function* executePartyMatchDecisionRead({ - payload, - request, - }) { - const correlationId = request.headers['x-correlation-id']; - if (correlationId === undefined || correlationId.trim().length === 0) { - return yield* Effect.fail(invalidProblem()); - } - const principal = yield* authenticateOperationPrincipal( - Redacted.make(request.headers['authorization']), - { - authentication: authenticationProblem, - unavailable: unavailableProblem, - }, - ); - const runtime = yield* ReadRuntime; - return yield* runtime - .runRead({ - input: payload, - principal, - registration: partyMatchDecisionRead, - transport: { correlationId }, - }) - .pipe( - Effect.catch((error) => { - const problem = readProblem(error); - return (isAuthenticationProblem(problem) ? bearerChallenge : Effect.void).pipe( - Effect.andThen(Effect.fail(problem)), - ); - }), - ); + governedReadHandler({ + spanName: 'partyMatchDecisionReadApiLive.execute', + registration: partyMatchDecisionRead, + problems, }), ), ); diff --git a/app/verticals/party-registry/api/party-match-read-server.ts b/app/verticals/party-registry/api/party-match-read-server.ts index f8498e5b5..f53e483ff 100644 --- a/app/verticals/party-registry/api/party-match-read-server.ts +++ b/app/verticals/party-registry/api/party-match-read-server.ts @@ -1,14 +1,6 @@ -// @generated by OntOS Codesmith module-api v1 /* eslint-disable effect-native/no-hand-built-problem-details -- These literals instantiate the shared typed schemas; the rule cannot recognize factory-produced contract schemas. expires: 2027-03-31. */ -import { ReadRuntime } from '@app/core-runtime'; -import type { ReadCoreError } from '@app/core-runtime'; -import { - Effect, - HttpApiBuilder, - HttpEffect, - HttpServerResponse, -} from '@modern-js/plugin-bff/effect-edge'; -import { Match, Redacted, Schema } from 'effect'; +import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; +import { Schema } from 'effect'; import { partyRegistryApi } from '../shared/api.ts'; import { PartyMatchAuthenticationProblemSchema, @@ -21,7 +13,8 @@ import { PartyMatchUnavailableProblemSchema, } from '../shared/apis/party-match.ts'; import { partyMatchRead } from '../src/api/party-match.read.ts'; -import { authenticateOperationPrincipal } from './auth/action-principal.ts'; +import { governedReadHandler } from './governed-read-handler.ts'; +import type { GovernedReadProblems } from './governed-read-handler.ts'; const authenticationProblem = () => PartyMatchAuthenticationProblemSchema.make({ @@ -84,33 +77,26 @@ const internalProblem = () => title: 'Read failed', type: 'https://ontos.dev/problems/read-failed', }); -const bearerChallenge = HttpEffect.appendPreResponseHandler((_request, response) => - Effect.succeed(HttpServerResponse.setHeader(response, 'www-authenticate', 'Bearer')), -); -const isAuthenticationProblem = Schema.is(PartyMatchAuthenticationProblemSchema); -const readProblem = (error: ReadCoreError) => - Match.value(error).pipe( - Match.tags({ - ModuleStateCheckUnavailableError: unavailableProblem, - ModuleStateDeniedError: forbiddenProblem, - OperationAuthenticationRequired: authenticationProblem, - OperationContextDenied: forbiddenProblem, - OperationContextInvalid: forbiddenProblem, - OperationContextUnavailable: unavailableProblem, - ReadEvidencePersistenceError: unavailableProblem, - ReadEvidenceValidationError: internalProblem, - ReadHandlerExecutionError: internalProblem, - ReadHandlerNotFound: notFoundProblem, - ReadHandlerUnavailable: unavailableProblem, - ReadInputValidationError: invalidProblem, - ReadPermissionDenied: forbiddenProblem, - ReadPermissionUnavailable: unavailableProblem, - ReadPolicyDenied: (failure) => policyProblem(failure.httpStatus), - ReadPolicyEvaluationError: unavailableProblem, - ReadResultValidationError: internalProblem, - }), - Match.exhaustive, - ); +type ReadProblem = + | typeof PartyMatchAuthenticationProblemSchema.Type + | typeof PartyMatchForbiddenProblemSchema.Type + | typeof PartyMatchInternalProblemSchema.Type + | typeof PartyMatchInvalidProblemSchema.Type + | typeof PartyMatchNotFoundProblemSchema.Type + | typeof PartyMatchPolicyConflictProblemSchema.Type + | typeof PartyMatchPolicyProblemSchema.Type + | typeof PartyMatchUnavailableProblemSchema.Type; + +const problems: GovernedReadProblems = { + authentication: authenticationProblem, + forbidden: forbiddenProblem, + internal: internalProblem, + invalid: invalidProblem, + notFound: notFoundProblem, + policy: policyProblem, + unavailable: unavailableProblem, + isAuthentication: Schema.is(PartyMatchAuthenticationProblemSchema), +}; export const partyMatchReadApiLive = HttpApiBuilder.group( partyRegistryApi, @@ -118,37 +104,10 @@ export const partyMatchReadApiLive = HttpApiBuilder.group( (handlers) => handlers.handle( 'execute', - Effect.fn('partyMatchReadApiLive.execute')(function* executePartyMatchRead({ - payload, - request, - }) { - const correlationId = request.headers['x-correlation-id']; - if (correlationId === undefined || correlationId.trim().length === 0) { - return yield* Effect.fail(invalidProblem()); - } - const principal = yield* authenticateOperationPrincipal( - Redacted.make(request.headers['authorization']), - { - authentication: authenticationProblem, - unavailable: unavailableProblem, - }, - ); - const runtime = yield* ReadRuntime; - return yield* runtime - .runRead({ - input: payload, - principal, - registration: partyMatchRead, - transport: { correlationId }, - }) - .pipe( - Effect.catch((error) => { - const problem = readProblem(error); - return (isAuthenticationProblem(problem) ? bearerChallenge : Effect.void).pipe( - Effect.andThen(Effect.fail(problem)), - ); - }), - ); + governedReadHandler({ + spanName: 'partyMatchReadApiLive.execute', + registration: partyMatchRead, + problems, }), ), ); diff --git a/app/verticals/party-registry/api/party-merge-readiness-read-server.ts b/app/verticals/party-registry/api/party-merge-readiness-read-server.ts index 9003d49f4..5cc01c356 100644 --- a/app/verticals/party-registry/api/party-merge-readiness-read-server.ts +++ b/app/verticals/party-registry/api/party-merge-readiness-read-server.ts @@ -1,13 +1,5 @@ -// @generated by OntOS Codesmith module-api v1 -import { ReadRuntime } from '@app/core-runtime'; -import type { ReadCoreError } from '@app/core-runtime'; -import { - Effect, - HttpApiBuilder, - HttpEffect, - HttpServerResponse, -} from '@modern-js/plugin-bff/effect-edge'; -import { Match, Redacted, Schema } from 'effect'; +import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; +import { Schema } from 'effect'; import { partyRegistryApi } from '../shared/api.ts'; import { PartyMergeReadinessAuthenticationProblemSchema, @@ -20,7 +12,8 @@ import { PartyMergeReadinessUnavailableProblemSchema, } from '../shared/apis/party-merge-readiness.ts'; import { partyMergeReadinessRead } from '../src/api/party-merge-readiness.read.ts'; -import { authenticateOperationPrincipal } from './auth/action-principal.ts'; +import { governedReadHandler } from './governed-read-handler.ts'; +import type { GovernedReadProblems } from './governed-read-handler.ts'; import { governedReadProblemStatus } from './read-server-support.ts'; const authenticationProblem = () => @@ -80,33 +73,26 @@ const internalProblem = () => title: 'Read failed', type: 'https://ontos.dev/problems/read-failed', }); -const bearerChallenge = HttpEffect.appendPreResponseHandler((_request, response) => - Effect.succeed(HttpServerResponse.setHeader(response, 'www-authenticate', 'Bearer')), -); -const isAuthenticationProblem = Schema.is(PartyMergeReadinessAuthenticationProblemSchema); -const readProblem = (error: ReadCoreError) => - Match.value(error).pipe( - Match.tags({ - ModuleStateCheckUnavailableError: unavailableProblem, - ModuleStateDeniedError: forbiddenProblem, - OperationAuthenticationRequired: authenticationProblem, - OperationContextDenied: forbiddenProblem, - OperationContextInvalid: forbiddenProblem, - OperationContextUnavailable: unavailableProblem, - ReadEvidencePersistenceError: unavailableProblem, - ReadEvidenceValidationError: internalProblem, - ReadHandlerExecutionError: internalProblem, - ReadHandlerNotFound: notFoundProblem, - ReadHandlerUnavailable: unavailableProblem, - ReadInputValidationError: invalidProblem, - ReadPermissionDenied: forbiddenProblem, - ReadPermissionUnavailable: unavailableProblem, - ReadPolicyDenied: (failure) => policyProblem(failure.httpStatus), - ReadPolicyEvaluationError: unavailableProblem, - ReadResultValidationError: internalProblem, - }), - Match.exhaustive, - ); +type ReadProblem = + | typeof PartyMergeReadinessAuthenticationProblemSchema.Type + | typeof PartyMergeReadinessForbiddenProblemSchema.Type + | typeof PartyMergeReadinessInternalProblemSchema.Type + | typeof PartyMergeReadinessInvalidProblemSchema.Type + | typeof PartyMergeReadinessNotFoundProblemSchema.Type + | typeof PartyMergeReadinessPolicyConflictProblemSchema.Type + | typeof PartyMergeReadinessPolicyProblemSchema.Type + | typeof PartyMergeReadinessUnavailableProblemSchema.Type; + +const problems: GovernedReadProblems = { + authentication: authenticationProblem, + forbidden: forbiddenProblem, + internal: internalProblem, + invalid: invalidProblem, + notFound: notFoundProblem, + policy: policyProblem, + unavailable: unavailableProblem, + isAuthentication: Schema.is(PartyMergeReadinessAuthenticationProblemSchema), +}; export const partyMergeReadinessReadApiLive = HttpApiBuilder.group( partyRegistryApi, @@ -114,37 +100,10 @@ export const partyMergeReadinessReadApiLive = HttpApiBuilder.group( (handlers) => handlers.handle( 'execute', - Effect.fn('partyMergeReadinessReadApiLive.execute')(function* executeRead({ - payload, - request, - }) { - const correlationId = request.headers['x-correlation-id']; - if (correlationId === undefined || correlationId.trim().length === 0) { - return yield* Effect.fail(invalidProblem()); - } - const principal = yield* authenticateOperationPrincipal( - Redacted.make(request.headers['authorization']), - { - authentication: authenticationProblem, - unavailable: unavailableProblem, - }, - ); - const runtime = yield* ReadRuntime; - return yield* runtime - .runRead({ - input: payload, - principal, - registration: partyMergeReadinessRead, - transport: { correlationId }, - }) - .pipe( - Effect.catch((error) => { - const problem = readProblem(error); - return (isAuthenticationProblem(problem) ? bearerChallenge : Effect.void).pipe( - Effect.andThen(Effect.fail(problem)), - ); - }), - ); + governedReadHandler({ + spanName: 'partyMergeReadinessReadApiLive.execute', + registration: partyMergeReadinessRead, + problems, }), ), ); diff --git a/app/verticals/party-registry/api/party-official-identifier-detail-read-server.ts b/app/verticals/party-registry/api/party-official-identifier-detail-read-server.ts index aecbab52a..388f91114 100644 --- a/app/verticals/party-registry/api/party-official-identifier-detail-read-server.ts +++ b/app/verticals/party-registry/api/party-official-identifier-detail-read-server.ts @@ -1,18 +1,6 @@ // @generated by OntOS Codesmith module-api v1 -import { - ReadEvidenceValidationError, - ReadHandlerExecutionError, - ReadResultValidationError, - ReadRuntime, -} from '@app/core-runtime'; -import type { ReadCoreError } from '@app/core-runtime'; -import { - Effect, - HttpApiBuilder, - HttpEffect, - HttpServerResponse, -} from '@modern-js/plugin-bff/effect-edge'; -import { Cause, Match, Redacted, Schema } from 'effect'; +import { Effect, HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; +import { Schema } from 'effect'; import { partyRegistryApi } from '../shared/api.ts'; import { PartyOfficialIdentifierDetailAuthenticationProblemSchema, @@ -25,7 +13,7 @@ import { PartyOfficialIdentifierDetailUnavailableProblemSchema, } from '../shared/apis/party-official-identifier-detail.ts'; import { partyOfficialIdentifierDetailRead } from '../src/api/party-official-identifier-detail.read.ts'; -import { authenticateOperationPrincipal } from './auth/action-principal.ts'; +import { executeGovernedRead } from './governed-detail-read-execution.ts'; const problemStatus = { authentication: 401, @@ -97,83 +85,25 @@ const problem = { type: 'https://ontos.dev/problems/read-unavailable', }), }; -const policyProblem = (status: 409 | 422) => - status === problemStatus.conflict ? problem.policyConflict() : problem.ineligible(); -const bearerChallenge = HttpEffect.appendPreResponseHandler((_request, response) => - Effect.succeed(HttpServerResponse.setHeader(response, 'www-authenticate', 'Bearer')), -); -const isAuthenticationProblem = Schema.is(PartyOfficialIdentifierDetailAuthenticationProblemSchema); -const isInternalReadError = Schema.is( - Schema.Union([ReadEvidenceValidationError, ReadHandlerExecutionError, ReadResultValidationError]), -); -const hasInternalReadFailure = (cause: Cause.Cause) => - cause.reasons.some((reason) => Cause.isFailReason(reason) && isInternalReadError(reason.error)); -const readProblem = (error: ReadCoreError) => - Match.value(error).pipe( - Match.tags({ - ModuleStateCheckUnavailableError: problem.unavailable, - ModuleStateDeniedError: problem.forbidden, - OperationAuthenticationRequired: problem.authentication, - OperationContextDenied: problem.forbidden, - OperationContextInvalid: problem.forbidden, - OperationContextUnavailable: problem.unavailable, - ReadEvidencePersistenceError: problem.unavailable, - ReadEvidenceValidationError: problem.internal, - ReadHandlerExecutionError: problem.internal, - ReadHandlerNotFound: problem.notFound, - ReadHandlerUnavailable: problem.unavailable, - ReadInputValidationError: problem.invalid, - ReadPermissionDenied: problem.forbidden, - ReadPermissionUnavailable: problem.unavailable, - ReadPolicyDenied: (failure) => policyProblem(failure.httpStatus), - ReadPolicyEvaluationError: problem.unavailable, - ReadResultValidationError: problem.internal, - }), - Match.exhaustive, - ); - export const partyOfficialIdentifierDetailReadApiLive = HttpApiBuilder.group( partyRegistryApi, 'partyOfficialIdentifierDetail', (handlers) => handlers.handle( 'execute', - Effect.fn('partyOfficialIdentifierDetailReadApiLive.execute')(function* executeRead({ - payload, - request, - }) { - const correlationId = request.headers['x-correlation-id']; - if (correlationId === undefined || correlationId.trim().length === 0) { - return yield* Effect.fail(problem.invalid()); - } - const principal = yield* authenticateOperationPrincipal( - Redacted.make(request.headers['authorization']), - { - authentication: problem.authentication, - unavailable: problem.unavailable, + Effect.fn('partyOfficialIdentifierDetailReadApiLive.execute')( + executeGovernedRead({ + registration: partyOfficialIdentifierDetailRead, + problem: { + ...problem, + policy: (status: 409 | 422) => + status === problemStatus.conflict ? problem.policyConflict() : problem.ineligible(), }, - ); - const runtime = yield* ReadRuntime; - return yield* runtime - .runRead({ - input: payload, - principal, - registration: partyOfficialIdentifierDetailRead, - transport: { correlationId }, - }) - .pipe( - Effect.tapCauseIf(hasInternalReadFailure, (cause) => - Effect.logError('Party official identifier detail read failed').pipe( - Effect.annotateLogs({ cause }), - ), - ), - Effect.catch((error) => { - const mapped = readProblem(error); - return (isAuthenticationProblem(mapped) ? bearerChallenge : Effect.void).pipe( - Effect.andThen(Effect.fail(mapped)), - ); - }), - ); - }), + isAuthenticationProblem: Schema.is( + PartyOfficialIdentifierDetailAuthenticationProblemSchema, + ), + internalFailureMessage: 'Party official identifier detail read failed', + }), + ), ), ); diff --git a/app/verticals/party-registry/api/party-official-identifier-history-read-server.ts b/app/verticals/party-registry/api/party-official-identifier-history-read-server.ts index f376a8352..79405e0fe 100644 --- a/app/verticals/party-registry/api/party-official-identifier-history-read-server.ts +++ b/app/verticals/party-registry/api/party-official-identifier-history-read-server.ts @@ -1,18 +1,6 @@ // @generated by OntOS Codesmith module-api v1 -import { - ReadEvidenceValidationError, - ReadHandlerExecutionError, - ReadResultValidationError, - ReadRuntime, -} from '@app/core-runtime'; -import type { ReadCoreError } from '@app/core-runtime'; -import { - Effect, - HttpApiBuilder, - HttpEffect, - HttpServerResponse, -} from '@modern-js/plugin-bff/effect-edge'; -import { Cause, Match, Redacted, Schema } from 'effect'; +import { Effect, HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; +import { Schema } from 'effect'; import { partyRegistryApi } from '../shared/api.ts'; import { PartyOfficialIdentifierHistoryAuthenticationProblemSchema, @@ -25,7 +13,7 @@ import { PartyOfficialIdentifierHistoryUnavailableProblemSchema, } from '../shared/apis/party-official-identifier-history.ts'; import { partyOfficialIdentifierHistoryRead } from '../src/api/party-official-identifier-history.read.ts'; -import { authenticateOperationPrincipal } from './auth/action-principal.ts'; +import { executeGovernedRead } from './governed-detail-read-execution.ts'; const problemStatus = { authentication: 401, @@ -97,85 +85,25 @@ const problem = { type: 'https://ontos.dev/problems/read-unavailable', }), }; -const policyProblem = (status: 409 | 422) => - status === problemStatus.conflict ? problem.policyConflict() : problem.ineligible(); -const bearerChallenge = HttpEffect.appendPreResponseHandler((_request, response) => - Effect.succeed(HttpServerResponse.setHeader(response, 'www-authenticate', 'Bearer')), -); -const isAuthenticationProblem = Schema.is( - PartyOfficialIdentifierHistoryAuthenticationProblemSchema, -); -const isInternalReadError = Schema.is( - Schema.Union([ReadEvidenceValidationError, ReadHandlerExecutionError, ReadResultValidationError]), -); -const hasInternalReadFailure = (cause: Cause.Cause) => - cause.reasons.some((reason) => Cause.isFailReason(reason) && isInternalReadError(reason.error)); -const readProblem = (error: ReadCoreError) => - Match.value(error).pipe( - Match.tags({ - ModuleStateCheckUnavailableError: problem.unavailable, - ModuleStateDeniedError: problem.forbidden, - OperationAuthenticationRequired: problem.authentication, - OperationContextDenied: problem.forbidden, - OperationContextInvalid: problem.forbidden, - OperationContextUnavailable: problem.unavailable, - ReadEvidencePersistenceError: problem.unavailable, - ReadEvidenceValidationError: problem.internal, - ReadHandlerExecutionError: problem.internal, - ReadHandlerNotFound: problem.notFound, - ReadHandlerUnavailable: problem.unavailable, - ReadInputValidationError: problem.invalid, - ReadPermissionDenied: problem.forbidden, - ReadPermissionUnavailable: problem.unavailable, - ReadPolicyDenied: (failure) => policyProblem(failure.httpStatus), - ReadPolicyEvaluationError: problem.unavailable, - ReadResultValidationError: problem.internal, - }), - Match.exhaustive, - ); - export const partyOfficialIdentifierHistoryReadApiLive = HttpApiBuilder.group( partyRegistryApi, 'partyOfficialIdentifierHistory', (handlers) => handlers.handle( 'execute', - Effect.fn('partyOfficialIdentifierHistoryReadApiLive.execute')(function* executeRead({ - payload, - request, - }) { - const correlationId = request.headers['x-correlation-id']; - if (correlationId === undefined || correlationId.trim().length === 0) { - return yield* Effect.fail(problem.invalid()); - } - const principal = yield* authenticateOperationPrincipal( - Redacted.make(request.headers['authorization']), - { - authentication: problem.authentication, - unavailable: problem.unavailable, + Effect.fn('partyOfficialIdentifierHistoryReadApiLive.execute')( + executeGovernedRead({ + registration: partyOfficialIdentifierHistoryRead, + problem: { + ...problem, + policy: (status: 409 | 422) => + status === problemStatus.conflict ? problem.policyConflict() : problem.ineligible(), }, - ); - const runtime = yield* ReadRuntime; - return yield* runtime - .runRead({ - input: payload, - principal, - registration: partyOfficialIdentifierHistoryRead, - transport: { correlationId }, - }) - .pipe( - Effect.tapCauseIf(hasInternalReadFailure, (cause) => - Effect.logError('Party official identifier history read failed').pipe( - Effect.annotateLogs({ cause }), - ), - ), - Effect.catch((error) => { - const mapped = readProblem(error); - return (isAuthenticationProblem(mapped) ? bearerChallenge : Effect.void).pipe( - Effect.andThen(Effect.fail(mapped)), - ); - }), - ); - }), + isAuthenticationProblem: Schema.is( + PartyOfficialIdentifierHistoryAuthenticationProblemSchema, + ), + internalFailureMessage: 'Party official identifier history read failed', + }), + ), ), ); diff --git a/app/verticals/party-registry/api/party-relationship-detail-read-server.ts b/app/verticals/party-registry/api/party-relationship-detail-read-server.ts index b9f8db5c7..74d66ff20 100644 --- a/app/verticals/party-registry/api/party-relationship-detail-read-server.ts +++ b/app/verticals/party-registry/api/party-relationship-detail-read-server.ts @@ -1,13 +1,6 @@ // @generated by OntOS Codesmith module-api v1 -import { ReadRuntime } from '@app/core-runtime'; -import type { ReadCoreError } from '@app/core-runtime'; -import { - Effect, - HttpApiBuilder, - HttpEffect, - HttpServerResponse, -} from '@modern-js/plugin-bff/effect-edge'; -import { Match, Redacted, Schema } from 'effect'; +import { Effect, HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; +import { Schema } from 'effect'; import { partyRegistryApi } from '../shared/api.ts'; import { PartyRelationshipDetailAuthenticationProblemSchema, @@ -20,7 +13,7 @@ import { PartyRelationshipDetailUnavailableProblemSchema, } from '../shared/apis/party-relationship-detail.ts'; import { partyRelationshipDetailRead } from '../src/api/party-relationship-detail.read.ts'; -import { authenticateOperationPrincipal } from './auth/action-principal.ts'; +import { executeGovernedRead } from './governed-detail-read-execution.ts'; import { governedReadProblemStatus } from './read-server-support.ts'; const preserveCause = (problem: Problem, cause?: unknown): Problem => { @@ -113,68 +106,18 @@ const problem = { cause, ), }; -const bearerChallenge = HttpEffect.appendPreResponseHandler((_request, response) => - Effect.succeed(HttpServerResponse.setHeader(response, 'www-authenticate', 'Bearer')), -); -const isAuthenticationProblem = Schema.is(PartyRelationshipDetailAuthenticationProblemSchema); -const failProblem = (mapped: Problem) => - (isAuthenticationProblem(mapped) ? bearerChallenge : Effect.void).pipe( - Effect.andThen(Effect.fail(mapped)), - ); -const readProblem = (error: ReadCoreError) => - Match.value(error).pipe( - Match.tags({ - ModuleStateCheckUnavailableError: problem.unavailable, - ModuleStateDeniedError: problem.forbidden, - OperationAuthenticationRequired: problem.authentication, - OperationContextDenied: problem.forbidden, - OperationContextInvalid: problem.forbidden, - OperationContextUnavailable: problem.unavailable, - ReadEvidencePersistenceError: problem.unavailable, - ReadEvidenceValidationError: problem.internal, - ReadHandlerExecutionError: problem.internal, - ReadHandlerNotFound: problem.notFound, - ReadHandlerUnavailable: problem.unavailable, - ReadInputValidationError: problem.invalid, - ReadPermissionDenied: problem.forbidden, - ReadPermissionUnavailable: problem.unavailable, - ReadPolicyDenied: (failure) => problem.policy(failure.httpStatus, failure), - ReadPolicyEvaluationError: problem.unavailable, - ReadResultValidationError: problem.internal, - }), - Match.exhaustive, - ); - -const verifyPrincipal = (authorization: Redacted.Redacted) => - authenticateOperationPrincipal(authorization, { - authentication: () => problem.authentication(), - unavailable: () => problem.unavailable(), - }); - export const partyRelationshipDetailReadApiLive = HttpApiBuilder.group( partyRegistryApi, 'partyRelationshipDetail', (handlers) => handlers.handle( 'execute', - Effect.fn('partyRelationshipDetailReadApiLive.execute')(function* executeRead({ - payload, - request, - }) { - const correlationId = request.headers['x-correlation-id']; - if (correlationId === undefined || correlationId.trim().length === 0) { - return yield* Effect.fail(problem.invalid()); - } - const principal = yield* verifyPrincipal(Redacted.make(request.headers['authorization'])); - const runtime = yield* ReadRuntime; - return yield* runtime - .runRead({ - input: payload, - principal, - registration: partyRelationshipDetailRead, - transport: { correlationId }, - }) - .pipe(Effect.mapError(readProblem), Effect.catchIf(isAuthenticationProblem, failProblem)); - }), + Effect.fn('partyRelationshipDetailReadApiLive.execute')( + executeGovernedRead({ + registration: partyRelationshipDetailRead, + problem, + isAuthenticationProblem: Schema.is(PartyRelationshipDetailAuthenticationProblemSchema), + }), + ), ), ); diff --git a/app/verticals/party-registry/api/person-engagement-profile-read-server.ts b/app/verticals/party-registry/api/person-engagement-profile-read-server.ts index 82ae1b498..f138657ae 100644 --- a/app/verticals/party-registry/api/person-engagement-profile-read-server.ts +++ b/app/verticals/party-registry/api/person-engagement-profile-read-server.ts @@ -1,13 +1,5 @@ -// @generated by OntOS Codesmith module-api v1 -import { ReadRuntime } from '@app/core-runtime'; -import type { ReadCoreError } from '@app/core-runtime'; -import { - Effect, - HttpApiBuilder, - HttpEffect, - HttpServerResponse, -} from '@modern-js/plugin-bff/effect-edge'; -import { Match, Redacted, Schema } from 'effect'; +import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; +import { Schema } from 'effect'; import { partyRegistryApi } from '../shared/api.ts'; import { PersonEngagementProfileAuthenticationProblemSchema, @@ -20,19 +12,10 @@ import { PersonEngagementProfileUnavailableProblemSchema, } from '../shared/apis/person-engagement-profile.ts'; import { personEngagementProfileRead } from '../src/api/person-engagement-profile.read.ts'; -import { authenticateOperationPrincipal } from './auth/action-principal.ts'; +import { governedReadHandler } from './governed-read-handler.ts'; +import type { GovernedReadProblems } from './governed-read-handler.ts'; import { governedReadProblemStatus } from './read-server-support.ts'; -type PersonEngagementProfileProblem = - | typeof PersonEngagementProfileAuthenticationProblemSchema.Type - | typeof PersonEngagementProfileForbiddenProblemSchema.Type - | typeof PersonEngagementProfileInternalProblemSchema.Type - | typeof PersonEngagementProfileInvalidProblemSchema.Type - | typeof PersonEngagementProfileNotFoundProblemSchema.Type - | typeof PersonEngagementProfilePolicyConflictProblemSchema.Type - | typeof PersonEngagementProfilePolicyProblemSchema.Type - | typeof PersonEngagementProfileUnavailableProblemSchema.Type; - const authenticationProblem = () => PersonEngagementProfileAuthenticationProblemSchema.make({ detail: 'A valid audience-scoped Bearer assertion is required.', @@ -90,37 +73,26 @@ const internalProblem = () => title: 'Read failed', type: 'https://ontos.dev/problems/read-failed', }); -const bearerChallenge = HttpEffect.appendPreResponseHandler((_request, response) => - Effect.succeed(HttpServerResponse.setHeader(response, 'www-authenticate', 'Bearer')), -); -const isAuthenticationProblem = Schema.is(PersonEngagementProfileAuthenticationProblemSchema); -const failProblem = (mapped: Problem) => - (isAuthenticationProblem(mapped) ? bearerChallenge : Effect.void).pipe( - Effect.andThen(Effect.fail(mapped)), - ); -const readProblem = (error: ReadCoreError): PersonEngagementProfileProblem => - Match.value(error).pipe( - Match.tags({ - ModuleStateCheckUnavailableError: unavailableProblem, - ModuleStateDeniedError: forbiddenProblem, - OperationAuthenticationRequired: authenticationProblem, - OperationContextDenied: forbiddenProblem, - OperationContextInvalid: forbiddenProblem, - OperationContextUnavailable: unavailableProblem, - ReadEvidencePersistenceError: unavailableProblem, - ReadEvidenceValidationError: internalProblem, - ReadHandlerExecutionError: internalProblem, - ReadHandlerNotFound: notFoundProblem, - ReadHandlerUnavailable: unavailableProblem, - ReadInputValidationError: invalidProblem, - ReadPermissionDenied: forbiddenProblem, - ReadPermissionUnavailable: unavailableProblem, - ReadPolicyDenied: ({ httpStatus }) => policyProblem(httpStatus), - ReadPolicyEvaluationError: unavailableProblem, - ReadResultValidationError: internalProblem, - }), - Match.exhaustive, - ); +type ReadProblem = + | typeof PersonEngagementProfileAuthenticationProblemSchema.Type + | typeof PersonEngagementProfileForbiddenProblemSchema.Type + | typeof PersonEngagementProfileInternalProblemSchema.Type + | typeof PersonEngagementProfileInvalidProblemSchema.Type + | typeof PersonEngagementProfileNotFoundProblemSchema.Type + | typeof PersonEngagementProfilePolicyConflictProblemSchema.Type + | typeof PersonEngagementProfilePolicyProblemSchema.Type + | typeof PersonEngagementProfileUnavailableProblemSchema.Type; + +const problems: GovernedReadProblems = { + authentication: authenticationProblem, + forbidden: forbiddenProblem, + internal: internalProblem, + invalid: invalidProblem, + notFound: notFoundProblem, + policy: policyProblem, + unavailable: unavailableProblem, + isAuthentication: Schema.is(PersonEngagementProfileAuthenticationProblemSchema), +}; export const personEngagementProfileReadApiLive = HttpApiBuilder.group( partyRegistryApi, @@ -128,30 +100,10 @@ export const personEngagementProfileReadApiLive = HttpApiBuilder.group( (handlers) => handlers.handle( 'execute', - Effect.fn('personEngagementProfileReadApiLive.execute')(function* executeRead({ - payload, - request, - }) { - const correlationId = request.headers['x-correlation-id']; - if (correlationId === undefined || correlationId.trim().length === 0) { - return yield* Effect.fail(invalidProblem()); - } - const principal = yield* authenticateOperationPrincipal( - Redacted.make(request.headers['authorization']), - { - authentication: authenticationProblem, - unavailable: unavailableProblem, - }, - ); - const runtime = yield* ReadRuntime; - return yield* runtime - .runRead({ - input: payload, - principal, - registration: personEngagementProfileRead, - transport: { correlationId }, - }) - .pipe(Effect.catch((error) => error.pipe(readProblem, failProblem))); + governedReadHandler({ + spanName: 'personEngagementProfileReadApiLive.execute', + registration: personEngagementProfileRead, + problems, }), ), ); diff --git a/app/verticals/party-registry/api/read-server-support.ts b/app/verticals/party-registry/api/read-server-support.ts index b3e8f53f3..12085d733 100644 --- a/app/verticals/party-registry/api/read-server-support.ts +++ b/app/verticals/party-registry/api/read-server-support.ts @@ -1,6 +1,6 @@ import type { OperationContext } from '../shared/api.ts'; -export const DEFAULT_PARTY_REGISTRY_SHELL_ORIGIN = 'http://localhost:3020'; +const DEFAULT_PARTY_REGISTRY_SHELL_ORIGIN = 'http://localhost:3020'; export const resolvePartyRegistryShellOrigin = (value: string | undefined): string => value !== undefined && value.trim().length > 0 ? value : DEFAULT_PARTY_REGISTRY_SHELL_ORIGIN; diff --git a/app/verticals/party-registry/drizzle.config.ts b/app/verticals/party-registry/drizzle.config.ts index c5788ae01..71efc52d4 100644 --- a/app/verticals/party-registry/drizzle.config.ts +++ b/app/verticals/party-registry/drizzle.config.ts @@ -1,33 +1,7 @@ -import { APP_ENV_PATH } from '@app/core-runtime/workspace-environment'; -import { defineConfig } from 'drizzle-kit'; -import { Redacted, Result, Schema } from 'effect'; +import { defineWorkspaceDrizzleConfig } from '../../packages/core-runtime/src/environment/drizzle-config.ts'; -const nodeFileSystem = process.getBuiltinModule('node:fs'); -const nodeProcess = process.getBuiltinModule('node:process'); -const nodeUtilities = process.getBuiltinModule('node:util'); -const fileConfig = nodeFileSystem.existsSync(APP_ENV_PATH) - ? Result.getOrThrow( - Result.try(() => nodeUtilities.parseEnv(nodeFileSystem.readFileSync(APP_ENV_PATH, 'utf-8'))), - ) - : {}; -const configValues = { ...fileConfig, ...nodeProcess.env }; -const databaseUrl = Redacted.value( - Result.getOrThrow( - Schema.decodeUnknownResult( - Schema.RedactedFromValue(Schema.Trim.pipe(Schema.check(Schema.isMinLength(1)))), - )(configValues['DATABASE_ADMIN_URL']), - ), -); - -export default defineConfig({ - dbCredentials: { - url: databaseUrl, - }, - dialect: 'postgresql', - migrations: { - schema: 'drizzle', - table: '__drizzle_migrations_party', - }, +export default defineWorkspaceDrizzleConfig({ out: './drizzle', schema: './src/db/schema.ts', + table: '__drizzle_migrations_party', }); diff --git a/app/verticals/party-registry/drizzle.contacts.config.ts b/app/verticals/party-registry/drizzle.contacts.config.ts index 3123537da..52fba6f78 100644 --- a/app/verticals/party-registry/drizzle.contacts.config.ts +++ b/app/verticals/party-registry/drizzle.contacts.config.ts @@ -1,33 +1,7 @@ -import { APP_ENV_PATH } from '@app/core-runtime/workspace-environment'; -import { defineConfig } from 'drizzle-kit'; -import { Redacted, Result, Schema } from 'effect'; +import { defineWorkspaceDrizzleConfig } from '../../packages/core-runtime/src/environment/drizzle-config.ts'; -const nodeFileSystem = process.getBuiltinModule('node:fs'); -const nodeProcess = process.getBuiltinModule('node:process'); -const nodeUtilities = process.getBuiltinModule('node:util'); -const fileConfig = nodeFileSystem.existsSync(APP_ENV_PATH) - ? Result.getOrThrow( - Result.try(() => nodeUtilities.parseEnv(nodeFileSystem.readFileSync(APP_ENV_PATH, 'utf-8'))), - ) - : {}; -const configValues = { ...fileConfig, ...nodeProcess.env }; -const databaseUrl = Redacted.value( - Result.getOrThrow( - Schema.decodeUnknownResult( - Schema.RedactedFromValue(Schema.Trim.pipe(Schema.check(Schema.isMinLength(1)))), - )(configValues.DATABASE_ADMIN_URL), - ), -); - -export default defineConfig({ - dbCredentials: { - url: databaseUrl, - }, - dialect: 'postgresql', - migrations: { - schema: 'drizzle', - table: '__drizzle_migrations_contacts', - }, +export default defineWorkspaceDrizzleConfig({ out: './drizzle-contacts', schema: './src/db/engagement-schema.ts', + table: '__drizzle_migrations_contacts', }); diff --git a/app/verticals/party-registry/modern.config.ts b/app/verticals/party-registry/modern.config.ts index 03498e740..9900accde 100644 --- a/app/verticals/party-registry/modern.config.ts +++ b/app/verticals/party-registry/modern.config.ts @@ -1,6 +1,11 @@ import { readFileSync } from 'node:fs'; -import { builtinModules, createRequire } from 'node:module'; -import path from 'node:path'; +import { + createCloudflareWorkerSecurity, + createWorkerSsrPlugins, + createZephyrRspackPlugin, + resolveCloudflareExternal, +} from '../../packages/shared-contracts/tooling/modern-config.ts'; +import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { appTools, defineConfig, presetUltramodern } from '@modern-js/app-tools'; import type { AppTools, AppToolsUserConfig, CliPlugin } from '@modern-js/app-tools'; @@ -50,53 +55,20 @@ const resolvePostgresProtocolCommonJsEntry = () => const resolvePostgresPoolCommonJsEntry = () => createRequire(import.meta.resolve('pg/package.json')).resolve('pg-pool'); const resolveEffectApiSourceDirectory = () => fileURLToPath(new URL('api/', import.meta.url)); -const nodeBuiltinRequests = new Set( - cloudflareDeployEnabled ? builtinModules.flatMap((name) => [name, `node:${name}`]) : [], -); /* oxlint-disable promise/prefer-await-to-callbacks -- Rspack externals use a callback API. expires: 2026-12-31. */ const cloudflareRuntimeExternal = ( - { dependencyType, request }: { dependencyType?: string; request?: string }, + request: { dependencyType?: string; request?: string }, callback: (error?: Error, result?: string | string[], type?: 'module-import') => void, ) => { - const nativeModuleImport = (specifier: string) => - dependencyType?.startsWith('commonjs') === true ? [specifier, 'default'] : specifier; - if (request === 'cloudflare:sockets') { - callback(undefined, nativeModuleImport(request), 'module-import'); - return; - } - if (request !== undefined && nodeBuiltinRequests.has(request)) { - callback( - undefined, - nativeModuleImport(request.startsWith('node:') ? request : `node:${request}`), - 'module-import', - ); - return; - } - callback(); + callback(...resolveCloudflareExternal(request, cloudflareDeployEnabled)); }; /* oxlint-enable promise/prefer-await-to-callbacks */ -const zephyrRspackPlugin = (): CliPlugin => ({ - name: 'ultramodern-zephyr-rspack-plugin', - pre: ['@modern-js/plugin-module-federation-config'], - setup(api) { - // Zephyr uploads federated build artifacts to Zephyr Cloud (the fast - // rollback path). Uploading REQUIRES a Zephyr Cloud account and, in CI, a - // deploy-scoped ZE_CI_TOKEN; without it Zephyr fatally fails to load its - // application configuration. Zephyr therefore engages ONLY for such an - // authoritative deploy — a plain build never contacts Zephyr Cloud, needs - // no account, and is never blocked. This is the framework's "works with or - // without Zephyr" contract. The plugin stays registered unconditionally - // (this gate keys on Zephyr's native deploy token, not any UltraModern - // opt-out). When deploying, ZE_FAIL_BUILD=true makes an upload failure a - // hard build failure. - const zephyrCiDeploy = envValue('ZE_CI_TOKEN') !== undefined; - if (!zephyrCiDeploy) { - return; - } - api.modifyRspackConfig(withBuildConfigEnvironment('ZE_FAIL_BUILD', 'true', withZephyrRspack())); - }, -}); +const zephyrRspackPlugin = (): CliPlugin => + createZephyrRspackPlugin({ + readToken: () => getOptionalBuildConfig('ZE_CI_TOKEN'), + configure: () => withBuildConfigEnvironment('ZE_FAIL_BUILD', 'true', withZephyrRspack()), + }); const appId = 'party-registry'; const cloudflareWorkerName = 'app-party-registry'; @@ -180,45 +152,7 @@ const cloudflareDeployment = whenEnabled(cloudflareDeployEnabled, { worker: { compatibilityDate: '2026-06-02', name: cloudflareWorkerName, - security: { - contentSecurityPolicy: { - directives: { - 'base-uri': ["'self'"], - 'connect-src': ["'self'", 'https:', 'http:', 'wss:', 'ws:'], - 'default-src': ["'self'"], - 'font-src': ["'self'", 'data:', 'https:', 'http:'], - 'form-action': ["'self'"], - 'frame-ancestors': ["'self'"], - 'img-src': ["'self'", 'data:', 'blob:', 'https:', 'http:'], - 'manifest-src': ["'self'", 'https:', 'http:'], - 'object-src': ["'none'"], - 'script-src': [ - "'self'", - "'unsafe-inline'", - "'unsafe-eval'", - 'https:', - 'http:', - 'blob:', - ], - 'style-src': ["'self'", "'unsafe-inline'", 'https:', 'http:'], - 'worker-src': ["'self'", 'blob:'], - }, - mode: 'report-only', - reason: - 'Report-only by default so Cloudflare Module Federation SSR can prove remote script, style, and connect compatibility before enforcement.', - }, - enabled: true, - headers: { - contentTypeOptions: 'nosniff', - permissionsPolicy: 'camera=(), geolocation=(), microphone=(), payment=(), usb=()', - referrerPolicy: 'strict-origin-when-cross-origin', - }, - noindex: { - localhost: true, - previewHostnames: [], - workersDev: true, - }, - }, + security: createCloudflareWorkerSecurity(), ssr: true, }, }, @@ -375,29 +309,7 @@ export default defineConfig( __dirname: false, __filename: false, }); - config.plugins.push( - new rspack.DefinePlugin({ - 'globalThis.FinalizationRegistry': 'undefined', - }), - new rspack.NormalModuleReplacementPlugin(/[?&]loaderId=/u, (resource) => { - resource.request = resource.request.replace( - /(?[?&])retain=[^&]*/u, - '$retain=true', - ); - }), - new rspack.NormalModuleReplacementPlugin(/^\.\.?[/\\]/u, (resource) => { - const [requestPath] = resource.request.split('?', 1); - if ( - requestPath !== undefined && - path - .resolve(resource.context, requestPath) - .startsWith(effectApiSourceDirectory) && - !resource.request.includes('modern-bff-runtime-source') - ) { - resource.request = `${resource.request}?modern-bff-runtime-source`; - } - }), - ); + config.plugins.push(...createWorkerSsrPlugins(rspack, effectApiSourceDirectory)); } }, }, diff --git a/app/verticals/party-registry/package.json b/app/verticals/party-registry/package.json index b63d863d9..36800504c 100644 --- a/app/verticals/party-registry/package.json +++ b/app/verticals/party-registry/package.json @@ -65,23 +65,17 @@ "@app/gateway-principal-verifier": "workspace:*", "@app/shared-contracts": "workspace:*", "@app/shared-design-tokens": "workspace:*", - "@authzed/authzed-node": "1.6.1", "@effect/opentelemetry": "4.0.0-beta.107", "@modern-js/plugin-bff": "npm:@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12", "@modern-js/plugin-i18n": "npm:@bleedingdev/modern-js-plugin-i18n@3.8.2-ultramodern.12", "@modern-js/plugin-tanstack": "npm:@bleedingdev/modern-js-plugin-tanstack@3.8.2-ultramodern.12", "@modern-js/runtime": "npm:@bleedingdev/modern-js-runtime@3.8.2-ultramodern.12", - "@module-federation/bridge-react": "2.8.0", "@module-federation/modern-js-v3": "2.8.0", "@module-federation/runtime": "2.8.0", - "@tanstack/react-query": "5.101.4", "@tanstack/react-router": "1.170.25", - "@techsio/ui-kit": "0.25.1", - "dotenv": "17.4.2", "drizzle-orm": "1.0.0-rc.5-ab785fc", "effect": "4.0.0-beta.107", "i18next": "26.3.6", - "node-fetch": "^3.3.2", "pg": "8.22.0", "react": "19.2.8", "react-dom": "19.2.8", @@ -96,7 +90,6 @@ "@rstest/core": "0.11.10", "@testing-library/dom": "10.4.1", "@testing-library/react": "16.3.2", - "@testing-library/user-event": "14.6.1", "@types/node": "^20", "@types/pg": "8.20.0", "@types/react": "^19.2.17", diff --git a/app/verticals/party-registry/scripts/verify-db-schema.mts b/app/verticals/party-registry/scripts/verify-db-schema.mts index a049f9a99..ca783de7f 100644 --- a/app/verticals/party-registry/scripts/verify-db-schema.mts +++ b/app/verticals/party-registry/scripts/verify-db-schema.mts @@ -53,6 +53,20 @@ const expectedColumns = PARTY_TABLES.flatMap((table) => { return config.columns.map((column) => `${config.name}.${column.name}`); }).toSorted(); +const ownerPrivilegesMismatch = (owner: OwnerInfrastructureRow): boolean => + owner.runtime_create || !owner.runtime_usage || owner.role_super || owner.role_bypass_rls; + +const ownerConstraintsMismatch = (owner: OwnerInfrastructureRow): boolean => + owner.journal_count !== 1 || + owner.foreign_key_count !== 41 || + owner.external_foreign_key_count !== 0 || + owner.relationship_exclusion_count !== 1 || + owner.counterparty_role_exclusion_count !== 1 || + owner.correction_trigger_count !== 1; + +const ownerInfrastructureMismatch = (owner: OwnerInfrastructureRow | undefined): boolean => + owner === undefined || ownerPrivilegesMismatch(owner) || ownerConstraintsMismatch(owner); + const verification = Effect.gen(function* verifyPartyDatabase() { const connections = yield* loadDatabaseConnectionPair(); const database = yield* PartyDatabase; @@ -254,19 +268,7 @@ const verification = Effect.gen(function* verifyPartyDatabase() { ), ); const [owner] = infrastructure; - if ( - owner === undefined || - owner.runtime_create || - !owner.runtime_usage || - owner.role_super || - owner.role_bypass_rls || - owner.journal_count !== 1 || - owner.foreign_key_count !== 41 || - owner.external_foreign_key_count !== 0 || - owner.relationship_exclusion_count !== 1 || - owner.counterparty_role_exclusion_count !== 1 || - owner.correction_trigger_count !== 1 - ) { + if (ownerInfrastructureMismatch(owner)) { return yield* new PartyDatabaseVerificationError({ reason: 'Party Registry owner infrastructure does not match its journal, owner-local FK, exclusion, append-only, or least-privilege contract', diff --git a/app/verticals/party-registry/scripts/verify-engagement-db-schema.mts b/app/verticals/party-registry/scripts/verify-engagement-db-schema.mts index e8329ab86..8a083031f 100644 --- a/app/verticals/party-registry/scripts/verify-engagement-db-schema.mts +++ b/app/verticals/party-registry/scripts/verify-engagement-db-schema.mts @@ -55,13 +55,7 @@ const expectedColumns = [ const organizationEngagementProfileRelation = `${CONTACTS_SCHEMA_NAME}.organization_engagement_profiles`; const personEngagementProfileRelation = `${CONTACTS_SCHEMA_NAME}.person_engagement_profiles`; -const infrastructureMatches = (verified: InfrastructureCatalogRow, adminUser: string): boolean => - verified.organization_owner === adminUser && - verified.person_owner === adminUser && - verified.foreign_key_count === 0 && - verified.journal_count === 1 && - verified.policy_count === 8 && - verified.rls_count === 2 && +const runtimePrivilegesMatch = (verified: InfrastructureCatalogRow): boolean => !verified.runtime_create && verified.runtime_usage && verified.runtime_select && @@ -71,6 +65,15 @@ const infrastructureMatches = (verified: InfrastructureCatalogRow, adminUser: st !verified.role_super && !verified.role_bypass_rls; +const infrastructureMatches = (verified: InfrastructureCatalogRow, adminUser: string): boolean => + verified.organization_owner === adminUser && + verified.person_owner === adminUser && + verified.foreign_key_count === 0 && + verified.journal_count === 1 && + verified.policy_count === 8 && + verified.rls_count === 2 && + runtimePrivilegesMatch(verified); + const verification = Effect.gen(function* verifyContactsDatabase() { const connections = yield* loadDatabaseConnectionPair(); const database = yield* PartyDatabase; diff --git a/app/verticals/party-registry/shared/actions/archive-party.ts b/app/verticals/party-registry/shared/actions/archive-party.ts index da03168fa..ad8eaaa6e 100644 --- a/app/verticals/party-registry/shared/actions/archive-party.ts +++ b/app/verticals/party-registry/shared/actions/archive-party.ts @@ -1,6 +1,6 @@ // Canonical schema-only contract extracted from the generated archive-party Action. import { Schema } from 'effect'; -import { PartySchema } from '../domain/identity-contracts.ts'; + import { PartyRefSchema } from '../resources/party.ts'; export const ArchivePartyPayloadSchema = Schema.Struct({ @@ -9,5 +9,4 @@ export const ArchivePartyPayloadSchema = Schema.Struct({ reason: Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(1000)), }); export type ArchivePartyPayload = typeof ArchivePartyPayloadSchema.Type; -export const ArchivePartyResultSchema = PartySchema; -export type ArchivePartyResult = typeof ArchivePartyResultSchema.Type; +export { PartySchema as ArchivePartyResultSchema } from '../domain/identity-contracts.ts'; diff --git a/app/verticals/party-registry/shared/actions/confirm-duplicate-parties.ts b/app/verticals/party-registry/shared/actions/confirm-duplicate-parties.ts index 119d12bb0..f2deae32e 100644 --- a/app/verticals/party-registry/shared/actions/confirm-duplicate-parties.ts +++ b/app/verticals/party-registry/shared/actions/confirm-duplicate-parties.ts @@ -1,10 +1,6 @@ // Canonical schema-only contract extracted from the generated confirm-duplicate-parties Action. -import { - DuplicateCaseResolutionPayloadSchema, - DuplicateCaseResolutionResultSchema, -} from '../domain/matching-contracts.ts'; +import { DuplicateCaseResolutionPayloadSchema } from '../domain/matching-contracts.ts'; export const ConfirmDuplicatePartiesPayloadSchema = DuplicateCaseResolutionPayloadSchema; export type ConfirmDuplicatePartiesPayload = typeof ConfirmDuplicatePartiesPayloadSchema.Type; -export const ConfirmDuplicatePartiesResultSchema = DuplicateCaseResolutionResultSchema; -export type ConfirmDuplicatePartiesResult = typeof ConfirmDuplicatePartiesResultSchema.Type; +export { DuplicateCaseResolutionResultSchema as ConfirmDuplicatePartiesResultSchema } from '../domain/matching-contracts.ts'; diff --git a/app/verticals/party-registry/shared/actions/correct-party-fact.ts b/app/verticals/party-registry/shared/actions/correct-party-fact.ts index 47eda4979..f306f9c36 100644 --- a/app/verticals/party-registry/shared/actions/correct-party-fact.ts +++ b/app/verticals/party-registry/shared/actions/correct-party-fact.ts @@ -1,10 +1,6 @@ // Canonical schema-only contract extracted from the generated correct-party-fact Action. -import { - PartyCorrectionCommandSchema, - PartyCorrectionResultSchema, -} from '../domain/correction-contracts.ts'; +import { PartyCorrectionCommandSchema } from '../domain/correction-contracts.ts'; export const CorrectPartyFactPayloadSchema = PartyCorrectionCommandSchema; export type CorrectPartyFactPayload = typeof CorrectPartyFactPayloadSchema.Type; -export const CorrectPartyFactResultSchema = PartyCorrectionResultSchema; -export type CorrectPartyFactResult = typeof CorrectPartyFactResultSchema.Type; +export { PartyCorrectionResultSchema as CorrectPartyFactResultSchema } from '../domain/correction-contracts.ts'; diff --git a/app/verticals/party-registry/shared/actions/counterparty-create.ts b/app/verticals/party-registry/shared/actions/counterparty-create.ts index de9eb2b4f..a60ac3043 100644 --- a/app/verticals/party-registry/shared/actions/counterparty-create.ts +++ b/app/verticals/party-registry/shared/actions/counterparty-create.ts @@ -19,4 +19,3 @@ export const CounterpartyCreateResultSchema = Schema.Struct({ legalEntityRef: LegalEntityRefSchema, partyRef: PartyRefSchema, }); -export type CounterpartyCreateResult = typeof CounterpartyCreateResultSchema.Type; diff --git a/app/verticals/party-registry/shared/actions/counterparty-role-add.ts b/app/verticals/party-registry/shared/actions/counterparty-role-add.ts index 8b3ebc0bc..254e3ff0c 100644 --- a/app/verticals/party-registry/shared/actions/counterparty-role-add.ts +++ b/app/verticals/party-registry/shared/actions/counterparty-role-add.ts @@ -30,4 +30,3 @@ export const CounterpartyRoleAddResultSchema = Schema.Struct({ validFrom: CounterpartyIsoTimestampSchema, validTo: Schema.toEncoded(Schema.OptionFromNullOr(CounterpartyIsoTimestampSchema)), }); -export type CounterpartyRoleAddResult = typeof CounterpartyRoleAddResultSchema.Type; diff --git a/app/verticals/party-registry/shared/actions/counterparty-role-end.ts b/app/verticals/party-registry/shared/actions/counterparty-role-end.ts index 9abe8a0f4..67178a524 100644 --- a/app/verticals/party-registry/shared/actions/counterparty-role-end.ts +++ b/app/verticals/party-registry/shared/actions/counterparty-role-end.ts @@ -23,4 +23,3 @@ export const CounterpartyRoleEndResultSchema = Schema.Struct({ validFrom: CounterpartyIsoTimestampSchema, validTo: CounterpartyIsoTimestampSchema, }); -export type CounterpartyRoleEndResult = typeof CounterpartyRoleEndResultSchema.Type; diff --git a/app/verticals/party-registry/shared/actions/create-party.ts b/app/verticals/party-registry/shared/actions/create-party.ts index fa053daf1..e88142ec7 100644 --- a/app/verticals/party-registry/shared/actions/create-party.ts +++ b/app/verticals/party-registry/shared/actions/create-party.ts @@ -1,6 +1,6 @@ // Canonical schema-only contract extracted from the generated create-party Action. import { Schema } from 'effect'; -import { PartyCandidateSchema, PartyCreateOutcomeSchema } from '../domain/identity-contracts.ts'; +import { PartyCandidateSchema } from '../domain/identity-contracts.ts'; import type { PartyCandidate } from '../domain/identity-contracts.ts'; export const CreatePartyPayloadSchema = Schema.Struct({ candidate: PartyCandidateSchema }); @@ -8,5 +8,4 @@ export const CreatePartyPayloadJsonSchema = Schema.toEncoded(CreatePartyPayloadS export interface CreatePartyPayload { readonly candidate: PartyCandidate; } -export const CreatePartyResultSchema = PartyCreateOutcomeSchema; -export type CreatePartyResult = typeof CreatePartyResultSchema.Type; +export { PartyCreateOutcomeSchema as CreatePartyResultSchema } from '../domain/identity-contracts.ts'; diff --git a/app/verticals/party-registry/shared/actions/dismiss-duplicate-candidate.ts b/app/verticals/party-registry/shared/actions/dismiss-duplicate-candidate.ts index 62cd4dbd4..55aea1519 100644 --- a/app/verticals/party-registry/shared/actions/dismiss-duplicate-candidate.ts +++ b/app/verticals/party-registry/shared/actions/dismiss-duplicate-candidate.ts @@ -1,10 +1,6 @@ // Canonical schema-only contract extracted from the generated dismiss-duplicate-candidate Action. -import { - DuplicateCaseResolutionPayloadSchema, - DuplicateCaseResolutionResultSchema, -} from '../domain/matching-contracts.ts'; +import { DuplicateCaseResolutionPayloadSchema } from '../domain/matching-contracts.ts'; export const DismissDuplicateCandidatePayloadSchema = DuplicateCaseResolutionPayloadSchema; export type DismissDuplicateCandidatePayload = typeof DismissDuplicateCandidatePayloadSchema.Type; -export const DismissDuplicateCandidateResultSchema = DuplicateCaseResolutionResultSchema; -export type DismissDuplicateCandidateResult = typeof DismissDuplicateCandidateResultSchema.Type; +export { DuplicateCaseResolutionResultSchema as DismissDuplicateCandidateResultSchema } from '../domain/matching-contracts.ts'; diff --git a/app/verticals/party-registry/shared/actions/end-contact-point.ts b/app/verticals/party-registry/shared/actions/end-contact-point.ts index 3b8fd5a5b..cbae7cc4e 100644 --- a/app/verticals/party-registry/shared/actions/end-contact-point.ts +++ b/app/verticals/party-registry/shared/actions/end-contact-point.ts @@ -4,7 +4,6 @@ import { AddressPurposeTargetSchema, ContactPointProvenanceSchema, ContactPointTimestampSchema, - PartyContactPointSchema, } from '../domain/contact-point.ts'; import { PartyContactPointRefSchema } from '../resources/party-contact-point.ts'; @@ -22,5 +21,4 @@ export const EndContactPointPayloadSchema = Schema.Struct({ }); export type EndContactPointPayload = typeof EndContactPointPayloadSchema.Type; -export const EndContactPointResultSchema = PartyContactPointSchema; -export type EndContactPointResult = typeof EndContactPointResultSchema.Type; +export { PartyContactPointSchema as EndContactPointResultSchema } from '../domain/contact-point.ts'; diff --git a/app/verticals/party-registry/shared/actions/mark-duplicate-candidate-needs-evidence.ts b/app/verticals/party-registry/shared/actions/mark-duplicate-candidate-needs-evidence.ts index e4a729cd6..a651c27bb 100644 --- a/app/verticals/party-registry/shared/actions/mark-duplicate-candidate-needs-evidence.ts +++ b/app/verticals/party-registry/shared/actions/mark-duplicate-candidate-needs-evidence.ts @@ -1,13 +1,8 @@ // Canonical schema-only contract extracted from the generated mark-duplicate-candidate-needs-evidence Action. -import { - DuplicateCaseResolutionPayloadSchema, - DuplicateCaseResolutionResultSchema, -} from '../domain/matching-contracts.ts'; +import { DuplicateCaseResolutionPayloadSchema } from '../domain/matching-contracts.ts'; export const MarkDuplicateCandidateNeedsEvidencePayloadSchema = DuplicateCaseResolutionPayloadSchema; export type MarkDuplicateCandidateNeedsEvidencePayload = typeof MarkDuplicateCandidateNeedsEvidencePayloadSchema.Type; -export const MarkDuplicateCandidateNeedsEvidenceResultSchema = DuplicateCaseResolutionResultSchema; -export type MarkDuplicateCandidateNeedsEvidenceResult = - typeof MarkDuplicateCandidateNeedsEvidenceResultSchema.Type; +export { DuplicateCaseResolutionResultSchema as MarkDuplicateCandidateNeedsEvidenceResultSchema } from '../domain/matching-contracts.ts'; diff --git a/app/verticals/party-registry/shared/actions/match-party.ts b/app/verticals/party-registry/shared/actions/match-party.ts index 0c0c3980a..557d98054 100644 --- a/app/verticals/party-registry/shared/actions/match-party.ts +++ b/app/verticals/party-registry/shared/actions/match-party.ts @@ -1,6 +1,6 @@ // Canonical schema-only contract extracted from the generated match-party Action. import { Schema } from 'effect'; -import { PartyMatchRequestSchema, PartyMatchResponseSchema } from '../domain/matching-contracts.ts'; +import { PartyMatchRequestSchema } from '../domain/matching-contracts.ts'; import { DuplicateCandidateCaseRefSchema } from '../resources/duplicate-candidate-case.ts'; export const MatchPartyPayloadSchema = Schema.Struct({ @@ -9,5 +9,4 @@ export const MatchPartyPayloadSchema = Schema.Struct({ }); export type MatchPartyPayload = typeof MatchPartyPayloadSchema.Type; -export const MatchPartyResultSchema = PartyMatchResponseSchema; -export type MatchPartyResult = typeof MatchPartyResultSchema.Type; +export { PartyMatchResponseSchema as MatchPartyResultSchema } from '../domain/matching-contracts.ts'; diff --git a/app/verticals/party-registry/shared/actions/request-search-rebuild.ts b/app/verticals/party-registry/shared/actions/request-search-rebuild.ts index 47857a98a..a7f2e8541 100644 --- a/app/verticals/party-registry/shared/actions/request-search-rebuild.ts +++ b/app/verticals/party-registry/shared/actions/request-search-rebuild.ts @@ -9,4 +9,3 @@ export const RequestSearchRebuildResultSchema = Schema.Struct({ requestId: ActionInvocationIdSchema, status: Schema.Literal('QUEUED'), }); -export type RequestSearchRebuildResult = typeof RequestSearchRebuildResultSchema.Type; diff --git a/app/verticals/party-registry/shared/actions/resolve-duplicate-candidate-create.ts b/app/verticals/party-registry/shared/actions/resolve-duplicate-candidate-create.ts index 52a925768..d1bf74cd4 100644 --- a/app/verticals/party-registry/shared/actions/resolve-duplicate-candidate-create.ts +++ b/app/verticals/party-registry/shared/actions/resolve-duplicate-candidate-create.ts @@ -1,12 +1,7 @@ // Canonical schema-only contract extracted from the generated resolve-duplicate-candidate-create Action. -import { - DuplicateCaseResolutionPayloadSchema, - DuplicateCaseResolutionResultSchema, -} from '../domain/matching-contracts.ts'; +import { DuplicateCaseResolutionPayloadSchema } from '../domain/matching-contracts.ts'; export const ResolveDuplicateCandidateCreatePayloadSchema = DuplicateCaseResolutionPayloadSchema; export type ResolveDuplicateCandidateCreatePayload = typeof ResolveDuplicateCandidateCreatePayloadSchema.Type; -export const ResolveDuplicateCandidateCreateResultSchema = DuplicateCaseResolutionResultSchema; -export type ResolveDuplicateCandidateCreateResult = - typeof ResolveDuplicateCandidateCreateResultSchema.Type; +export { DuplicateCaseResolutionResultSchema as ResolveDuplicateCandidateCreateResultSchema } from '../domain/matching-contracts.ts'; diff --git a/app/verticals/party-registry/shared/actions/resolve-duplicate-candidate-match.ts b/app/verticals/party-registry/shared/actions/resolve-duplicate-candidate-match.ts index 5341d7747..e64191039 100644 --- a/app/verticals/party-registry/shared/actions/resolve-duplicate-candidate-match.ts +++ b/app/verticals/party-registry/shared/actions/resolve-duplicate-candidate-match.ts @@ -1,9 +1,6 @@ // Canonical schema-only contract extracted from the generated resolve-duplicate-candidate-match Action. import { Schema } from 'effect'; -import { - DuplicateCaseResolutionPayloadSchema, - DuplicateCaseResolutionResultSchema, -} from '../domain/matching-contracts.ts'; +import { DuplicateCaseResolutionPayloadSchema } from '../domain/matching-contracts.ts'; import { PartyRefSchema } from '../resources/party.ts'; export const ResolveDuplicateCandidateMatchPayloadSchema = Schema.Struct({ @@ -12,6 +9,4 @@ export const ResolveDuplicateCandidateMatchPayloadSchema = Schema.Struct({ }); export type ResolveDuplicateCandidateMatchPayload = typeof ResolveDuplicateCandidateMatchPayloadSchema.Type; -export const ResolveDuplicateCandidateMatchResultSchema = DuplicateCaseResolutionResultSchema; -export type ResolveDuplicateCandidateMatchResult = - typeof ResolveDuplicateCandidateMatchResultSchema.Type; +export { DuplicateCaseResolutionResultSchema as ResolveDuplicateCandidateMatchResultSchema } from '../domain/matching-contracts.ts'; diff --git a/app/verticals/party-registry/shared/actions/unarchive-party.ts b/app/verticals/party-registry/shared/actions/unarchive-party.ts index 7841aeaad..94b60f297 100644 --- a/app/verticals/party-registry/shared/actions/unarchive-party.ts +++ b/app/verticals/party-registry/shared/actions/unarchive-party.ts @@ -11,7 +11,7 @@ export const UnarchivePartyPayloadSchema = Schema.Struct({ reason: Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(1000)), }); export type UnarchivePartyPayload = typeof UnarchivePartyPayloadSchema.Type; -export const UnarchivePartyBlockedSchema = Schema.Struct({ +const UnarchivePartyBlockedSchema = Schema.Struct({ caseRef: DuplicateCandidateCaseRefSchema, decisionRef: PartyMatchDecisionRefSchema, outcome: Schema.Literal('BLOCKED'), @@ -28,4 +28,3 @@ export const UnarchivePartyResultSchema = Schema.Union([ Schema.Struct({ outcome: Schema.Literal('UNARCHIVED'), party: PartySchema }), UnarchivePartyBlockedSchema, ]); -export type UnarchivePartyResult = typeof UnarchivePartyResultSchema.Type; diff --git a/app/verticals/party-registry/shared/actions/update-contact-point.ts b/app/verticals/party-registry/shared/actions/update-contact-point.ts index 925295224..d692b3b7f 100644 --- a/app/verticals/party-registry/shared/actions/update-contact-point.ts +++ b/app/verticals/party-registry/shared/actions/update-contact-point.ts @@ -8,11 +8,10 @@ import { ContactPointProvenanceSchema, ContactPointTimestampSchema, ContactPointVerificationSchema, - PartyContactPointSchema, } from '../domain/contact-point.ts'; import { PartyContactPointRefSchema } from '../resources/party-contact-point.ts'; -export const ContactPointMetadataChangeSchema = Schema.Union([ +const ContactPointMetadataChangeSchema = Schema.Union([ Schema.Struct({ preferred: Schema.Boolean, type: Schema.Literal('SET_CHANNEL_PREFERRED') }), Schema.Struct({ assignment: AddressPurposeAssignmentSchema, @@ -49,7 +48,6 @@ export const ContactPointMetadataChangeSchema = Schema.Union([ type: Schema.Literal('CORRECT_CONTACT_POINT'), }), ]); -export type ContactPointMetadataChange = typeof ContactPointMetadataChangeSchema.Type; export const UpdateContactPointPayloadSchema = Schema.Struct({ change: ContactPointMetadataChangeSchema, @@ -59,5 +57,4 @@ export const UpdateContactPointPayloadSchema = Schema.Struct({ }); export type UpdateContactPointPayload = typeof UpdateContactPointPayloadSchema.Type; -export const UpdateContactPointResultSchema = PartyContactPointSchema; -export type UpdateContactPointResult = typeof UpdateContactPointResultSchema.Type; +export { PartyContactPointSchema as UpdateContactPointResultSchema } from '../domain/contact-point.ts'; diff --git a/app/verticals/party-registry/shared/domain/ares-application.ts b/app/verticals/party-registry/shared/domain/ares-application.ts index f8344cfca..ffecbf301 100644 --- a/app/verticals/party-registry/shared/domain/ares-application.ts +++ b/app/verticals/party-registry/shared/domain/ares-application.ts @@ -14,9 +14,8 @@ export const AresCanonicalRouteSchema = Schema.Literals([ 'CONTACT_POINT_ADD', 'PARTY_CORRECTION', ]); -export type AresCanonicalRoute = typeof AresCanonicalRouteSchema.Type; -export const AresApplyOutcomeSchema = Schema.Literals([ +const AresApplyOutcomeSchema = Schema.Literals([ 'PREFILL_ONLY', 'APPLY_ENRICHMENT', 'NO_CHANGE', @@ -24,15 +23,15 @@ export const AresApplyOutcomeSchema = Schema.Literals([ 'CORRECTION_CANDIDATE', 'IDENTITY_AMBIGUITY', ]); -export type AresApplyOutcome = typeof AresApplyOutcomeSchema.Type; +type AresApplyOutcome = typeof AresApplyOutcomeSchema.Type; -export const AresSelectedFactSchema = Schema.Literals([ +const AresSelectedFactSchema = Schema.Literals([ 'BUSINESS_NAME', 'ICO', 'REGISTERED_ADDRESS', 'PARTY_CANDIDATE', ]); -export type AresSelectedFact = typeof AresSelectedFactSchema.Type; +type AresSelectedFact = typeof AresSelectedFactSchema.Type; const decisionEvidence = { authorityPolicyKey: Schema.String.check( @@ -48,7 +47,7 @@ const decisionEvidence = { ), } as const; -export const AresFactDecisionSchema = Schema.Union([ +const AresFactDecisionSchema = Schema.Union([ Schema.Struct({ ...decisionEvidence, fact: Schema.Literal('BUSINESS_NAME'), @@ -240,6 +239,39 @@ export const prefillPartyCandidateFromAres = ( return candidate; }; +const acceptedObservationMatches = ( + accepted: AresAppliedEvidence, + evidence: AresSubjectEvidence, + validFrom: string, +): boolean => + (Option.isNone(accepted.providerRecordRef) || + Option.isNone(evidence.providerRecordRef) || + Option.getOrNull(accepted.providerRecordRef) === + Option.getOrNull(evidence.providerRecordRef)) && + DateTime.toEpochMillis(accepted.observedAt) <= epochMillisFromString(validFrom) && + epochMillisFromString(validFrom) <= DateTime.toEpochMillis(evidence.observedAt); + +const acceptedEvidenceConflicts = ( + assertion: AresCanonicalFactEvidence, + evidence: AresSubjectEvidence, + fact: 'BUSINESS_NAME' | 'ICO', +): boolean => { + const acceptedInput = assertion.externalEvidence; + const accepted = + acceptedInput === null || Schema.is(AresAppliedEvidenceSchema)(acceptedInput) + ? acceptedInput + : Result.getOrUndefined(Schema.decodeUnknownResult(AresAppliedEvidenceSchema)(acceptedInput)); + return ( + accepted !== null && + accepted !== undefined && + accepted.fact === fact && + accepted.outcome === 'APPLY_ENRICHMENT' && + accepted.queryIco === evidence.queryIco && + Option.contains(accepted.providerChangedOn, Option.getOrThrow(evidence.providerChangedOn)) && + acceptedObservationMatches(accepted, evidence, assertion.validFrom) + ); +}; + const historicalConflict = ( canonical: AresCanonicalSnapshot, evidence: AresSubjectEvidence, @@ -254,38 +286,143 @@ const historicalConflict = ( ) { return undefined; } - const assertions = (canonical.factEvidence ?? []).filter((assertion) => { - const acceptedInput = assertion.externalEvidence; - const accepted = - acceptedInput === null || Schema.is(AresAppliedEvidenceSchema)(acceptedInput) - ? acceptedInput - : Result.getOrUndefined( - Schema.decodeUnknownResult(AresAppliedEvidenceSchema)(acceptedInput), - ); - return ( + const assertions = (canonical.factEvidence ?? []).filter( + (assertion) => assertion.fact === fact && (fact === 'BUSINESS_NAME' ? normalizeText(assertion.value) === normalizeText(canonical.displayName) : canonical.icoValues.includes(assertion.value)) && normalizeText(assertion.value) !== normalizeText(observedValue) && - accepted !== null && - accepted !== undefined && - accepted.fact === fact && - accepted.outcome === 'APPLY_ENRICHMENT' && - accepted.queryIco === evidence.queryIco && - Option.contains(accepted.providerChangedOn, Option.getOrThrow(evidence.providerChangedOn)) && - (Option.isNone(accepted.providerRecordRef) || - Option.isNone(evidence.providerRecordRef) || - Option.getOrNull(accepted.providerRecordRef) === - Option.getOrNull(evidence.providerRecordRef)) && - DateTime.toEpochMillis(accepted.observedAt) <= epochMillisFromString(assertion.validFrom) && - epochMillisFromString(assertion.validFrom) <= DateTime.toEpochMillis(evidence.observedAt) - ); - }); + acceptedEvidenceConflicts(assertion, evidence, fact), + ); // Multiple current assertions are an unresolved conflict, never an arbitrary review target. return assertions.length === 1 ? assertions[0] : undefined; }; +const blocked = ( + fact: AresSelectedFact, + outcome: Exclude, + reasonCode: string, +): AresFactDecision => ({ ...ownerPolicy, fact, outcome, reasonCode, route: null }); + +const businessNameDecision = ( + canonical: AresCanonicalSnapshot, + evidence: AresSubjectEvidence, +): AresFactDecision | undefined => { + const fact = 'BUSINESS_NAME'; + const businessName = Option.getOrNull(evidence.subject.businessName); + if (businessName === null || !isSupportedBusinessName(businessName)) { + return blocked(fact, 'NO_CHANGE', 'provider_fact_absent_or_unsupported'); + } + if (normalizeText(canonical.displayName) === normalizeText(businessName)) { + return blocked(fact, 'NO_CHANGE', 'canonical_fact_equal'); + } + if (canonical.displayName !== null) { + return blocked(fact, 'NEEDS_CONFIRMATION', 'canonical_fact_conflict'); + } + return undefined; +}; + +const addressDecision = ( + canonical: AresCanonicalSnapshot, + evidence: AresSubjectEvidence, +): AresFactDecision | undefined => { + const fact = 'REGISTERED_ADDRESS'; + const address = Option.getOrUndefined(evidence.subject.registeredAddress); + if (address === undefined || !isSupportedAddress(address)) { + return blocked(fact, 'NO_CHANGE', 'provider_fact_absent_or_unsupported'); + } + if ( + canonical.registeredAddresses.some((current) => aresRegisteredAddressMatches(address, current)) + ) { + return blocked(fact, 'NO_CHANGE', 'canonical_fact_equal'); + } + if (canonical.registeredAddresses.length > 0) { + return blocked(fact, 'NEEDS_CONFIRMATION', 'canonical_fact_conflict'); + } + return undefined; +}; + +const identityDecision = ( + fact: AresSelectedFact, + canonical: AresCanonicalSnapshot, + evidence: AresSubjectEvidence, +): AresFactDecision | undefined => { + const conflictingIco = canonical.icoValues.some((value) => value !== evidence.subject.ico); + const historical = + fact === 'BUSINESS_NAME' || fact === 'ICO' + ? historicalConflict(canonical, evidence, fact) + : undefined; + if ( + conflictingIco && + (fact !== 'ICO' || historical === undefined || canonical.icoValues.length !== 1) + ) { + return blocked(fact, 'IDENTITY_AMBIGUITY', 'canonical_identity_conflict'); + } + if (historical !== undefined) { + return blocked( + fact, + 'CORRECTION_CANDIDATE', + 'unchanged_provider_revision_conflicts_with_accepted_assertion', + ); + } + return undefined; +}; + +const applyFactDecision = ( + fact: Exclude, + canonical: AresCanonicalSnapshot, + userConfirmed: boolean, +): AresFactDecision => { + // Only ORGANIZATION ICO assertions qualify for the current authoritative claim rule. + if (fact === 'ICO' && canonical.partyType !== 'ORGANIZATION') { + return blocked(fact, 'NEEDS_CONFIRMATION', 'party_type_not_supported_for_authoritative_ico'); + } + if (!userConfirmed) { + return blocked(fact, 'NEEDS_CONFIRMATION', 'user_confirmation_required'); + } + const common = { + ...ownerPolicy, + outcome: 'APPLY_ENRICHMENT', + reasonCode: 'selected_missing_fact_confirmed', + } as const; + if (fact === 'BUSINESS_NAME') { + return { ...common, fact, route: 'PARTY_UPDATE' }; + } + if (fact === 'ICO') { + return { ...common, fact, route: 'IDENTIFIER_ADD' }; + } + return { ...common, fact, route: 'CONTACT_POINT_ADD' }; +}; + +const selectedFactDecision = ( + fact: Exclude, + canonical: AresCanonicalSnapshot, + evidence: AresSubjectEvidence, + userConfirmed: boolean, +): AresFactDecision => { + const conflict = identityDecision(fact, canonical, evidence); + if (conflict !== undefined) { + return conflict; + } + if (fact === 'BUSINESS_NAME') { + const decision = businessNameDecision(canonical, evidence); + if (decision !== undefined) { + return decision; + } + } + if (fact === 'REGISTERED_ADDRESS') { + const decision = addressDecision(canonical, evidence); + if (decision !== undefined) { + return decision; + } + } + if (fact === 'ICO' && canonical.icoValues.includes(evidence.subject.ico)) { + return blocked(fact, 'NO_CHANGE', 'canonical_fact_equal'); + } + return applyFactDecision(fact, canonical, userConfirmed); +}; + /** Policy is closed owner code, never a caller-supplied outcome, route or authority assertion. */ export const deriveAresEvidenceApplication = ( input: AresDecisionInput, @@ -315,12 +452,6 @@ export const deriveAresEvidenceApplication = ( age >= 0 && age <= 300_000 && decidedAtEpochMillis >= DateTime.toEpochMillis(evidence.servedAt); - const blocked = ( - fact: AresSelectedFact, - outcome: Exclude, - reasonCode: string, - ): AresFactDecision => ({ ...ownerPolicy, fact, outcome, reasonCode, route: null }); - // eslint-disable-next-line complexity -- Keep the closed fact precedence and mutation routes in one auditable decision. const decisions = selectedFacts.map((fact): AresFactDecision => { if (evidence.subject.ico !== evidence.queryIco) { return blocked(fact, 'IDENTITY_AMBIGUITY', 'provider_subject_does_not_match_query'); @@ -340,74 +471,7 @@ export const deriveAresEvidenceApplication = ( if (!fresh) { return blocked(fact, 'NEEDS_CONFIRMATION', 'observation_not_fresh'); } - const conflictingIco = canonical.icoValues.some((value) => value !== evidence.subject.ico); - const historical = - fact === 'BUSINESS_NAME' || fact === 'ICO' - ? historicalConflict(canonical, evidence, fact) - : undefined; - if ( - conflictingIco && - (fact !== 'ICO' || historical === undefined || canonical.icoValues.length !== 1) - ) { - return blocked(fact, 'IDENTITY_AMBIGUITY', 'canonical_identity_conflict'); - } - if (historical !== undefined) { - return blocked( - fact, - 'CORRECTION_CANDIDATE', - 'unchanged_provider_revision_conflicts_with_accepted_assertion', - ); - } - if (fact === 'BUSINESS_NAME') { - const businessName = Option.getOrNull(evidence.subject.businessName); - if (businessName === null || !isSupportedBusinessName(businessName)) { - return blocked(fact, 'NO_CHANGE', 'provider_fact_absent_or_unsupported'); - } - if (normalizeText(canonical.displayName) === normalizeText(businessName)) { - return blocked(fact, 'NO_CHANGE', 'canonical_fact_equal'); - } - if (canonical.displayName !== null) { - return blocked(fact, 'NEEDS_CONFIRMATION', 'canonical_fact_conflict'); - } - } - if (fact === 'ICO' && canonical.icoValues.includes(evidence.subject.ico)) { - return blocked(fact, 'NO_CHANGE', 'canonical_fact_equal'); - } - if (fact === 'REGISTERED_ADDRESS') { - const address = Option.getOrUndefined(evidence.subject.registeredAddress); - if (address === undefined || !isSupportedAddress(address)) { - return blocked(fact, 'NO_CHANGE', 'provider_fact_absent_or_unsupported'); - } - if ( - canonical.registeredAddresses.some((current) => - aresRegisteredAddressMatches(address, current), - ) - ) { - return blocked(fact, 'NO_CHANGE', 'canonical_fact_equal'); - } - if (canonical.registeredAddresses.length > 0) { - return blocked(fact, 'NEEDS_CONFIRMATION', 'canonical_fact_conflict'); - } - } - // Only ORGANIZATION ICO assertions qualify for the current authoritative claim rule. - if (fact === 'ICO' && canonical.partyType !== 'ORGANIZATION') { - return blocked(fact, 'NEEDS_CONFIRMATION', 'party_type_not_supported_for_authoritative_ico'); - } - if (!input.userConfirmed) { - return blocked(fact, 'NEEDS_CONFIRMATION', 'user_confirmation_required'); - } - const common = { - ...ownerPolicy, - outcome: 'APPLY_ENRICHMENT', - reasonCode: 'selected_missing_fact_confirmed', - } as const; - if (fact === 'BUSINESS_NAME') { - return { ...common, fact, route: 'PARTY_UPDATE' }; - } - if (fact === 'ICO') { - return { ...common, fact, route: 'IDENTIFIER_ADD' }; - } - return { ...common, fact, route: 'CONTACT_POINT_ADD' }; + return selectedFactDecision(fact, canonical, evidence, input.userConfirmed); }); const priority: readonly AresApplyOutcome[] = [ 'APPLY_ENRICHMENT', diff --git a/app/verticals/party-registry/shared/domain/ares-evidence.ts b/app/verticals/party-registry/shared/domain/ares-evidence.ts index 6e8321750..99fd6c70c 100644 --- a/app/verticals/party-registry/shared/domain/ares-evidence.ts +++ b/app/verticals/party-registry/shared/domain/ares-evidence.ts @@ -18,7 +18,6 @@ const validDateOnly = Schema.makeFilter((value: string) => { export const AresSubjectLookupIcoSchema = Schema.Trim.check(Schema.isPattern(/^\d{8}$/u)).pipe( Schema.brand('AresSubjectLookupIco'), ); -export type AresSubjectLookupIco = typeof AresSubjectLookupIcoSchema.Type; export const AresDicSchema = Schema.Trim.check( Schema.isPattern(/^CZ\d{8,10}$/u), @@ -61,7 +60,7 @@ export const AresRegisteredAddressSchema = Schema.Struct({ }); export type AresRegisteredAddress = typeof AresRegisteredAddressSchema.Type; -export const AresSubjectObservationSchema = Schema.Struct({ +const AresSubjectObservationSchema = Schema.Struct({ businessName: Schema.OptionFromNullOr(boundedText(500)), dic: Schema.OptionFromNullOr(AresDicSchema), dissolvedOn: Schema.OptionFromNullOr(AresDateOnlySchema), @@ -70,7 +69,6 @@ export const AresSubjectObservationSchema = Schema.Struct({ legalFormCode: Schema.OptionFromNullOr(AresLegalFormCodeSchema), registeredAddress: Schema.OptionFromNullOr(AresRegisteredAddressSchema), }); -export type AresSubjectObservation = typeof AresSubjectObservationSchema.Type; export const AresSubjectEvidenceSchema = Schema.Struct({ cacheAgeSeconds: Schema.Finite.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)), diff --git a/app/verticals/party-registry/shared/domain/canonical-utc-timestamp.ts b/app/verticals/party-registry/shared/domain/canonical-utc-timestamp.ts new file mode 100644 index 000000000..cc57ce853 --- /dev/null +++ b/app/verticals/party-registry/shared/domain/canonical-utc-timestamp.ts @@ -0,0 +1,17 @@ +import { DateTime, Option, Schema, SchemaGetter } from 'effect'; + +export const CanonicalUtcTimestampJsonSchema = Schema.String.check( + Schema.isPattern(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/u), + Schema.makeFilter((value) => { + const parsed = DateTime.make(value); + const canonicalInput = value.length === 20 ? value.replace(/Z$/u, '.000Z') : value; + return Option.isSome(parsed) && DateTime.formatIso(parsed.value) === canonicalInput + ? undefined + : 'invalid UTC calendar timestamp'; + }), +).pipe( + Schema.decode({ + decode: SchemaGetter.dateTimeUtcFromInput().map(DateTime.formatIso), + encode: SchemaGetter.dateTimeUtcFromInput().map(DateTime.formatIso), + }), +); diff --git a/app/verticals/party-registry/shared/domain/contact-point.ts b/app/verticals/party-registry/shared/domain/contact-point.ts index 9850d5c42..d9e70c7bc 100644 --- a/app/verticals/party-registry/shared/domain/contact-point.ts +++ b/app/verticals/party-registry/shared/domain/contact-point.ts @@ -22,24 +22,21 @@ const EndedByPrincipalIdSchema = TrimmedTextSchema.pipe( export const ContactPointTimestampSchema = IsoTimestampSchema; export const ContactPointTypeSchema = Schema.Literals(['EMAIL', 'PHONE', 'ADDRESS']); -export type ContactPointType = typeof ContactPointTypeSchema.Type; -export const AddressPurposeSchema = Schema.Literals([ +const AddressPurposeSchema = Schema.Literals([ 'REGISTERED', 'BILLING', 'DELIVERY', 'CORRESPONDENCE', ]); -export type AddressPurpose = typeof AddressPurposeSchema.Type; -export const ContactPointLifecycleStateSchema = Schema.Literals([ +const ContactPointLifecycleStateSchema = Schema.Literals([ 'ACTIVE', 'ENDED', 'SUPERSEDED', 'RETRACTED', 'DISPUTED', ]); -export type ContactPointLifecycleState = typeof ContactPointLifecycleStateSchema.Type; export const ContactPointPrivacyClassificationSchema = Schema.Literals([ 'PUBLIC', @@ -48,12 +45,7 @@ export const ContactPointPrivacyClassificationSchema = Schema.Literals([ ]); export type ContactPointPrivacyClassification = typeof ContactPointPrivacyClassificationSchema.Type; -export const ContactPointVerificationStateSchema = Schema.Literals([ - 'UNVERIFIED', - 'VERIFIED', - 'REJECTED', -]); -export type ContactPointVerificationState = typeof ContactPointVerificationStateSchema.Type; +const ContactPointVerificationStateSchema = Schema.Literals(['UNVERIFIED', 'VERIFIED', 'REJECTED']); export const ContactPointProvenanceSchema = Schema.Struct({ authoritative: Schema.Boolean, @@ -100,7 +92,6 @@ export const EmailContactPointInputSchema = Schema.Struct({ type: Schema.Literal('EMAIL'), value: EmailValueSchema, }); -export type EmailContactPointInput = typeof EmailContactPointInputSchema.Type; const PhoneValueSchema = Schema.Trim.check( Schema.isMinLength(3), @@ -131,9 +122,8 @@ export const PhoneContactPointInputSchema = Schema.Union([ value: PhoneValueSchema.check(Schema.isPattern(/^[0-9(]/u)), }), ]); -export type PhoneContactPointInput = typeof PhoneContactPointInputSchema.Type; -export const StructuredAddressSchema = Schema.Struct({ +const StructuredAddressSchema = Schema.Struct({ addressLine1: OptionalTrimmedTextSchema, addressLine2: OptionalTrimmedTextSchema, city: OptionalTrimmedTextSchema, @@ -174,7 +164,6 @@ export const AddressContactPointInputSchema = Schema.Struct({ ), type: Schema.Literal('ADDRESS'), }); -export type AddressContactPointInput = typeof AddressContactPointInputSchema.Type; export const ContactPointInputSchema = Schema.Union([ EmailContactPointInputSchema, @@ -195,15 +184,14 @@ export const ContactPointEndSchema = Schema.Struct({ reason: TrimmedTextSchema, recordedAt: ContactPointTimestampSchema, }); -export type ContactPointEnd = typeof ContactPointEndSchema.Type; -export const EmailContactPointValueSchema = Schema.Struct({ +const EmailContactPointValueSchema = Schema.Struct({ displayValue: EmailValueSchema, lookupValue: EmailValueSchema, preferred: Schema.Boolean, type: Schema.Literal('EMAIL'), }); -export const PhoneContactPointValueSchema = Schema.Struct({ +const PhoneContactPointValueSchema = Schema.Struct({ countryCode: Schema.Union([CountryCodeSchema, Schema.Null]), displayValue: PhoneValueSchema, extension: Schema.Union([TrimmedTextSchema, Schema.Null]), @@ -236,12 +224,11 @@ export const AddressContactPointValueSchema = Schema.Struct({ ), type: Schema.Literal('ADDRESS'), }); -export const ContactPointValueSchema = Schema.Union([ +const ContactPointValueSchema = Schema.Union([ EmailContactPointValueSchema, PhoneContactPointValueSchema, AddressContactPointValueSchema, ]); -export type ContactPointValue = typeof ContactPointValueSchema.Type; export const PartyContactPointSchema = Schema.Struct({ contactPointRef: PartyContactPointRefSchema, @@ -298,15 +285,11 @@ export const normalizeEmail = (rawValue: string): NormalizedChannel => { const digitsOnly = (value: string): string => value.replaceAll(/[^0-9]/gu, ''); -export const normalizePhone = ( - rawValue: string, - rawCountryCode?: string, - rawExtension?: string, -): NormalizedPhone => { - const displayValue = rawValue.trim(); - const countryCode = rawCountryCode?.trim().toUpperCase(); - const digits = digitsOnly(displayValue); - const extension = rawExtension?.trim() ?? null; +const assertPhoneParts = ( + displayValue: string, + countryCode: string | undefined, + extension: string | null, +): void => { if (countryCode !== undefined && !/^[A-Z]{2}$/u.test(countryCode)) { return invalidContactPoint('PHONE country context must be a two-letter country code'); } @@ -316,6 +299,18 @@ export const normalizePhone = ( if (!/^\+?[()0-9 .-]+$/u.test(displayValue)) { return invalidContactPoint('PHONE contains unsupported characters'); } +}; + +export const normalizePhone = ( + rawValue: string, + rawCountryCode?: string, + rawExtension?: string, +): NormalizedPhone => { + const displayValue = rawValue.trim(); + const countryCode = rawCountryCode?.trim().toUpperCase(); + const digits = digitsOnly(displayValue); + const extension = rawExtension?.trim() ?? null; + assertPhoneParts(displayValue, countryCode, extension); if (displayValue.startsWith('+')) { if (!/^[1-9][0-9]{6,14}$/u.test(digits)) { return invalidContactPoint( @@ -428,6 +423,28 @@ export const normalizedAddressKey = (address: StructuredAddress): string => { .join('|'); }; +const assertAddressPurposeAssignment = ( + assignment: AddressPurposeAssignment, + provenance: ContactPointProvenance, +): void => { + if (assignment.purpose === 'REGISTERED') { + if ( + assignment.registryContext === undefined || + assignment.registryContext.registryKey === 'GENERAL' || + !/^[A-Za-z]{2}$/u.test(assignment.registryContext.jurisdiction) || + assignment.registryContext.jurisdiction.toUpperCase() === 'ZZ' || + !provenance.authoritative || + provenance.evidenceReference === undefined + ) { + return invalidContactPoint( + 'REGISTERED requires an explicit registry context and authoritative provenance', + ); + } + } else if (assignment.registryContext !== undefined) { + return invalidContactPoint('Registry context belongs only to REGISTERED purpose'); + } +}; + export const assertAddressPurposeRules = ( assignments: readonly AddressPurposeAssignment[], provenance: ContactPointProvenance, @@ -437,21 +454,6 @@ export const assertAddressPurposeRules = ( return invalidContactPoint('ADDRESS purposes must be unique on one Contact Point'); } for (const assignment of assignments) { - if (assignment.purpose === 'REGISTERED') { - if ( - assignment.registryContext === undefined || - assignment.registryContext.registryKey === 'GENERAL' || - !/^[A-Za-z]{2}$/u.test(assignment.registryContext.jurisdiction) || - assignment.registryContext.jurisdiction.toUpperCase() === 'ZZ' || - !provenance.authoritative || - provenance.evidenceReference === undefined - ) { - return invalidContactPoint( - 'REGISTERED requires an explicit registry context and authoritative provenance', - ); - } - } else if (assignment.registryContext !== undefined) { - return invalidContactPoint('Registry context belongs only to REGISTERED purpose'); - } + assertAddressPurposeAssignment(assignment, provenance); } }; diff --git a/app/verticals/party-registry/shared/domain/correction-contracts.ts b/app/verticals/party-registry/shared/domain/correction-contracts.ts index 54d836872..4b32d009f 100644 --- a/app/verticals/party-registry/shared/domain/correction-contracts.ts +++ b/app/verticals/party-registry/shared/domain/correction-contracts.ts @@ -33,7 +33,7 @@ export const TargetAssertionIdSchema = Schema.String.check(Schema.isUUID()).pipe export const ReplacementAssertionIdSchema = Schema.String.check(Schema.isUUID()).pipe( Schema.brand('ReplacementAssertionId'), ); -export const RetractedAssertionIdSchema = Schema.String.check(Schema.isUUID()).pipe( +const RetractedAssertionIdSchema = Schema.String.check(Schema.isUUID()).pipe( Schema.brand('RetractedAssertionId'), ); export const AssertionIdSchema = Schema.String.check(Schema.isUUID()).pipe( @@ -54,7 +54,6 @@ export const PartyCorrectionReasonCodeSchema = Schema.Literals([ 'WRONG_PARTY_ASSIGNMENT', 'WRONG_IDENTITY_VALUE', ]); -export type PartyCorrectionReasonCode = typeof PartyCorrectionReasonCodeSchema.Type; export const PartyCorrectionEvidenceSourceSchema = Schema.Literals([ 'AUTHORITATIVE_REGISTRY', @@ -62,7 +61,6 @@ export const PartyCorrectionEvidenceSourceSchema = Schema.Literals([ 'MANUAL_REVIEW', 'SYSTEM_RECONCILIATION', ]); -export type PartyCorrectionEvidenceSource = typeof PartyCorrectionEvidenceSourceSchema.Type; const correctionEvidenceFields = { evidenceRefs: EvidenceRefsSchema, @@ -91,9 +89,6 @@ export const IdentityCorrectionCommandSchema = Schema.Struct({ ); export type IdentityCorrectionCommand = typeof IdentityCorrectionCommandSchema.Type; -export const RelationshipCorrectionModeSchema = Schema.Literals(['SUPERSEDE', 'RETRACT']); -export type RelationshipCorrectionMode = typeof RelationshipCorrectionModeSchema.Type; - const relationshipCorrectionFields = { ...correctionEvidenceFields, expectedRevision: PositiveRevisionSchema, @@ -116,12 +111,12 @@ export const SupersedeRelationshipCorrectionCommandSchema = Schema.Struct({ ), ); -export const RetractRelationshipCorrectionCommandSchema = Schema.Struct({ +const RetractRelationshipCorrectionCommandSchema = Schema.Struct({ ...relationshipCorrectionFields, correctionMode: Schema.Literal('RETRACT'), }); -export const RelationshipCorrectionCommandSchema = Schema.Union([ +const RelationshipCorrectionCommandSchema = Schema.Union([ SupersedeRelationshipCorrectionCommandSchema, RetractRelationshipCorrectionCommandSchema, ]); @@ -133,7 +128,7 @@ export const PartyCorrectionCommandSchema = Schema.Union([ ]); export type PartyCorrectionCommand = typeof PartyCorrectionCommandSchema.Type; -export const CorrectionRouteSchema = Schema.Literals([ +const CorrectionRouteSchema = Schema.Literals([ 'ENRICHMENT_REVIEW', 'LIFECYCLE_REVIEW', 'CLAIM_REASSIGNMENT_REVIEW', @@ -213,7 +208,6 @@ export const PartyCorrectionAssertionValueSchema = Schema.Union([ validFrom: Schema.OptionFromNullOr(RelationshipIsoTimestampSchema), }), ]); -export type PartyCorrectionAssertionValue = typeof PartyCorrectionAssertionValueSchema.Type; export const PartyCorrectionGovernance = { classification: 'SENSITIVE_IDENTITY', @@ -222,7 +216,7 @@ export const PartyCorrectionGovernance = { retention: 'PRESERVE_WITH_IDENTITY_HISTORY_NO_AUTOMATIC_DELETION', visibility: 'RESTRICTED_IDENTITY_HISTORY', } as const; -export const PartyCorrectionGovernanceSchema = Schema.Struct({ +const PartyCorrectionGovernanceSchema = Schema.Struct({ classification: Schema.Literal(PartyCorrectionGovernance.classification), legalHolds: Schema.Literal(PartyCorrectionGovernance.legalHolds), policyVersion: PolicyVersionSchema, diff --git a/app/verticals/party-registry/shared/domain/counterparty-contract.ts b/app/verticals/party-registry/shared/domain/counterparty-contract.ts index 92f99cf64..28ec964ee 100644 --- a/app/verticals/party-registry/shared/domain/counterparty-contract.ts +++ b/app/verticals/party-registry/shared/domain/counterparty-contract.ts @@ -4,10 +4,7 @@ import { CounterpartyRefSchema } from '../resources/counterparty.ts'; import { CounterpartyRolePeriodRefSchema } from '../resources/counterparty-role-period.ts'; export const CounterpartyUuidSchema = Schema.String.check(Schema.isUUID()); -export const CounterpartyTextSchema = Schema.Trim.check( - Schema.isMinLength(1), - Schema.isMaxLength(500), -); +const CounterpartyTextSchema = Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(500)); const CounterpartyInstantSchema = Schema.String.check( Schema.makeFilter((value) => { const parsed = DateTime.make(value); @@ -61,14 +58,13 @@ export const CounterpartyAuditEvidenceSchema = Schema.Struct({ export const CounterpartyRoleTypeSchema = Schema.Literals(['CUSTOMER', 'SUPPLIER']); export type CounterpartyRoleType = typeof CounterpartyRoleTypeSchema.Type; -export const CounterpartyRoleStateSchema = Schema.Literals([ +const CounterpartyRoleStateSchema = Schema.Literals([ 'ACTIVE', 'ENDED', 'SUPERSEDED', 'RETRACTED', 'DISPUTED', ]); -export type CounterpartyRoleState = typeof CounterpartyRoleStateSchema.Type; export const CounterpartyRolePeriodSchema = Schema.Struct({ endProvenance: Schema.toEncoded(Schema.OptionFromOptionalNullOr(CounterpartyProvenanceSchema)), @@ -95,9 +91,8 @@ export const CounterpartyPartyProjectionSchema = Schema.Struct({ partyType: Schema.Literals(['PERSON', 'ORGANIZATION', 'UNRESOLVED']), storedPartyRef: PartyRefSchema, }); -export type CounterpartyPartyProjection = typeof CounterpartyPartyProjectionSchema.Type; -export const CounterpartyRecordSchema = Schema.Struct({ +const CounterpartyRecordSchema = Schema.Struct({ counterpartyRef: CounterpartyRefSchema, createdAt: CounterpartyIsoTimestampSchema, legalEntityRef: LegalEntityRefSchema, diff --git a/app/verticals/party-registry/shared/domain/engagement-profile.ts b/app/verticals/party-registry/shared/domain/engagement-profile.ts index e6b4218cc..75a49a242 100644 --- a/app/verticals/party-registry/shared/domain/engagement-profile.ts +++ b/app/verticals/party-registry/shared/domain/engagement-profile.ts @@ -1,4 +1,5 @@ -import { DateTime, Option, Schema, SchemaGetter } from 'effect'; +import { Schema } from 'effect'; +import { CanonicalUtcTimestampJsonSchema } from './canonical-utc-timestamp.ts'; import { CounterpartyRefSchema, PartyRefSchema } from '../party-registry-references.ts'; import { OrganizationEngagementProfileRefSchema } from '../resources/organization-engagement-profile.ts'; import { PersonEngagementProfileRefSchema } from '../resources/person-engagement-profile.ts'; @@ -12,28 +13,12 @@ export { EngagementProfilePersistenceUnavailable } from './engagement-profile-er export { PartyRegistryReferenceUnavailable } from './engagement-profile-errors/party-registry-reference-unavailable.ts'; export const EngagementIsoTimestampSchema = Schema.DateTimeUtcFromString; -const EngagementIsoTimestampJsonSchema = Schema.String.check( - Schema.isPattern(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/u), - Schema.makeFilter((value) => { - const parsed = DateTime.make(value); - const canonicalInput = value.length === 20 ? value.replace(/Z$/u, '.000Z') : value; - return Option.isSome(parsed) && DateTime.formatIso(parsed.value) === canonicalInput - ? undefined - : 'invalid UTC calendar timestamp'; - }), -).pipe( - Schema.decode({ - decode: SchemaGetter.dateTimeUtcFromInput().map(DateTime.formatIso), - encode: SchemaGetter.dateTimeUtcFromInput().map(DateTime.formatIso), - }), -); - const commonFields = { - archivedAt: Schema.toEncoded(Schema.OptionFromNullOr(EngagementIsoTimestampJsonSchema)), + archivedAt: Schema.toEncoded(Schema.OptionFromNullOr(CanonicalUtcTimestampJsonSchema)), counterpartyRef: Schema.toEncoded(Schema.OptionFromNullOr(CounterpartyRefSchema)), - createdAt: EngagementIsoTimestampJsonSchema, + createdAt: CanonicalUtcTimestampJsonSchema, partyRef: PartyRefSchema, - updatedAt: EngagementIsoTimestampJsonSchema, + updatedAt: CanonicalUtcTimestampJsonSchema, } as const; export const OrganizationEngagementProfileSchema = Schema.Struct({ diff --git a/app/verticals/party-registry/shared/domain/identifier-contracts.ts b/app/verticals/party-registry/shared/domain/identifier-contracts.ts index 1489560df..a1a779e9b 100644 --- a/app/verticals/party-registry/shared/domain/identifier-contracts.ts +++ b/app/verticals/party-registry/shared/domain/identifier-contracts.ts @@ -1,17 +1,17 @@ import { AresAppliedEvidenceSchema } from './ares-application.ts'; -import { DateTime, Option, Schema, SchemaGetter } from 'effect'; +import { Schema } from 'effect'; +import { CanonicalUtcTimestampJsonSchema } from './canonical-utc-timestamp.ts'; import { PartyOfficialIdentifierRefSchema } from '../resources/party-official-identifier.ts'; import { PartyRefSchema } from '../resources/party.ts'; export { OfficialIdentifierClaimConflict } from './identifier-errors/claim-conflict.ts'; export { OfficialIdentifierInvalid } from './identifier-errors/invalid.ts'; -export const OfficialIdentifierTypeSchema = Schema.Literals(['ICO', 'CZ_DIC']); -export type OfficialIdentifierType = typeof OfficialIdentifierTypeSchema.Type; +const OfficialIdentifierTypeSchema = Schema.Literals(['ICO', 'CZ_DIC']); export const IdentifierVerificationSchema = Schema.Literals(['UNVERIFIED', 'VERIFIED', 'REJECTED']); export type IdentifierVerification = typeof IdentifierVerificationSchema.Type; -export const isValidCzechIco = (value: string): boolean => { +const isValidCzechIco = (value: string): boolean => { if (!/^[0-9]{8}$/u.test(value)) { return false; } @@ -89,23 +89,6 @@ export const OfficialIdentifierAssertionStateSchema = Schema.Literals([ 'DISPUTED', ]); -export const OfficialIdentifierIsoTimestampSchema = Schema.DateTimeUtcFromString; -const OfficialIdentifierIsoTimestampJsonSchema = Schema.String.check( - Schema.isPattern(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/u), - Schema.makeFilter((value) => { - const parsed = DateTime.make(value); - const canonicalInput = value.length === 20 ? value.replace(/Z$/u, '.000Z') : value; - return Option.isSome(parsed) && DateTime.formatIso(parsed.value) === canonicalInput - ? undefined - : 'invalid UTC calendar timestamp'; - }), -).pipe( - Schema.decode({ - decode: SchemaGetter.dateTimeUtcFromInput().map(DateTime.formatIso), - encode: SchemaGetter.dateTimeUtcFromInput().map(DateTime.formatIso), - }), -); - export const OfficialIdentifierAssertionSchema = Schema.Struct({ externalEvidence: Schema.optionalKey( Schema.toEncoded(Schema.OptionFromNullOr(AresAppliedEvidenceSchema)), @@ -115,10 +98,10 @@ export const OfficialIdentifierAssertionSchema = Schema.Struct({ normalizedValue: Schema.String, officialIdentifierRef: PartyOfficialIdentifierRefSchema, partyRef: PartyRefSchema, - recordedAt: OfficialIdentifierIsoTimestampJsonSchema, + recordedAt: CanonicalUtcTimestampJsonSchema, state: OfficialIdentifierAssertionStateSchema, - validFrom: OfficialIdentifierIsoTimestampJsonSchema, - validTo: Schema.toEncoded(Schema.OptionFromNullOr(OfficialIdentifierIsoTimestampJsonSchema)), + validFrom: CanonicalUtcTimestampJsonSchema, + validTo: Schema.toEncoded(Schema.OptionFromNullOr(CanonicalUtcTimestampJsonSchema)), verification: IdentifierVerificationSchema, }); export type OfficialIdentifierAssertion = typeof OfficialIdentifierAssertionSchema.Type; diff --git a/app/verticals/party-registry/shared/domain/identity-contracts.ts b/app/verticals/party-registry/shared/domain/identity-contracts.ts index 3b27ee64b..cb9ea02e9 100644 --- a/app/verticals/party-registry/shared/domain/identity-contracts.ts +++ b/app/verticals/party-registry/shared/domain/identity-contracts.ts @@ -13,7 +13,7 @@ export const isPartyTypeEnrichment = (current: PartyType, requested: PartyType): export const IsoTimestampSchema = Schema.DateTimeUtcFromString; export const PartyIdSchema = Schema.String.check(Schema.isUUID()).pipe(Schema.brand('PartyId')); export type PartyId = typeof PartyIdSchema.Type; -export const PartySubjectKeySchema = Schema.Trim.check( +const PartySubjectKeySchema = Schema.Trim.check( Schema.isMinLength(1), Schema.isMaxLength(200), ).pipe(Schema.brand('PartySubjectKey')); @@ -24,7 +24,7 @@ export const PartyDisplayNameSchema = Schema.Trim.check( Schema.isMinLength(1), Schema.isMaxLength(300), ); -export const ProvenanceSchema = Schema.Struct({ +const ProvenanceSchema = Schema.Struct({ externalEvidence: Schema.optionalKey(AresAppliedEvidenceSchema), method: Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(100)), source: Schema.Trim.check(Schema.isMinLength(1), Schema.isMaxLength(200)), @@ -137,54 +137,6 @@ const PartyLifecycleConflictSchema = Schema.TaggedStruct( export const PartyLifecycleConflict = Schema.TaggedError< typeof PartyLifecycleConflictSchema.Type >()('PartyLifecycleConflict', partyLifecycleConflictFields); -export type PartyLifecycleConflictError = InstanceType; - -const partyUnarchiveIdentityConflictFields = { - code: Schema.Literal('party_unarchive_identity_conflict'), - conflictingPartyRef: PartyRefSchema, - reason: Schema.String, -} as const; -const PartyUnarchiveIdentityConflictSchema = Schema.TaggedStruct( - 'PartyUnarchiveIdentityConflict', - partyUnarchiveIdentityConflictFields, -); -export const PartyUnarchiveIdentityConflict = Schema.TaggedError< - typeof PartyUnarchiveIdentityConflictSchema.Type ->()('PartyUnarchiveIdentityConflict', partyUnarchiveIdentityConflictFields); -export type PartyUnarchiveIdentityConflictError = InstanceType< - typeof PartyUnarchiveIdentityConflict ->; - -const partyUnarchiveIdentityAmbiguousFields = { - candidatePartyRefs: Schema.Array(PartyRefSchema).check(Schema.isMinLength(2)), - code: Schema.Literal('party_unarchive_identity_ambiguous'), - reason: Schema.String, -} as const; -const PartyUnarchiveIdentityAmbiguousSchema = Schema.TaggedStruct( - 'PartyUnarchiveIdentityAmbiguous', - partyUnarchiveIdentityAmbiguousFields, -); -export const PartyUnarchiveIdentityAmbiguous = Schema.TaggedError< - typeof PartyUnarchiveIdentityAmbiguousSchema.Type ->()('PartyUnarchiveIdentityAmbiguous', partyUnarchiveIdentityAmbiguousFields); -export type PartyUnarchiveIdentityAmbiguousError = InstanceType< - typeof PartyUnarchiveIdentityAmbiguous ->; - -const partyUnarchiveReviewRequiredFields = { - caseRefs: Schema.Array(DuplicateCandidateCaseRefSchema), - code: Schema.Literal('party_unarchive_review_required'), - reason: Schema.String, - reasonCode: Schema.Literals(['OPEN_DUPLICATE_CASE', 'UNRESOLVED_IDENTITY']), -} as const; -const PartyUnarchiveReviewRequiredSchema = Schema.TaggedStruct( - 'PartyUnarchiveReviewRequired', - partyUnarchiveReviewRequiredFields, -); -export const PartyUnarchiveReviewRequired = Schema.TaggedError< - typeof PartyUnarchiveReviewRequiredSchema.Type ->()('PartyUnarchiveReviewRequired', partyUnarchiveReviewRequiredFields); -export type PartyUnarchiveReviewRequiredError = InstanceType; const partyEvidenceInsufficientFields = { code: Schema.Literal('party_evidence_insufficient'), diff --git a/app/verticals/party-registry/shared/domain/matching-contracts.ts b/app/verticals/party-registry/shared/domain/matching-contracts.ts index 014e1912c..56423c5d6 100644 --- a/app/verticals/party-registry/shared/domain/matching-contracts.ts +++ b/app/verticals/party-registry/shared/domain/matching-contracts.ts @@ -10,14 +10,13 @@ export { ClaimOwnedByDifferentParty } from './claim-owned-by-different-party.ts' export { DuplicateCandidateConflict } from './duplicate-candidate-conflict.ts'; export { PartyCreateRecoveryUnavailable } from './party-create-recovery-unavailable.ts'; -export const MatchOutcomeSchema = Schema.Literals(['MATCHED', 'NO_MATCH', 'AMBIGUOUS']); -export type MatchOutcome = typeof MatchOutcomeSchema.Type; +const MatchOutcomeSchema = Schema.Literals(['MATCHED', 'NO_MATCH', 'AMBIGUOUS']); +type MatchOutcome = typeof MatchOutcomeSchema.Type; export const RuleKeySchema = Schema.String.check( Schema.isMinLength(1), Schema.isMaxLength(100), ).pipe(Schema.brand('RuleKey')); -export type RuleKey = typeof RuleKeySchema.Type; // Matching contracts predate Option/DateTime models and are consumed directly as JSON-shaped DTOs. // Validate through Effect's temporal and absence codecs while retaining those decoded DTO shapes. @@ -55,7 +54,7 @@ export const evaluateExactClaims = (partyIds: readonly string[]) => { }; export const PartyMatchRequestSchema = Schema.Struct({ candidate: PartyCandidateSchema }); -export const MatchEvidenceExplanationSchema = Schema.Struct({ +const MatchEvidenceExplanationSchema = Schema.Struct({ evidenceRefs: Schema.optionalKey( Schema.Array(Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(500))).check( Schema.isMaxLength(100), @@ -74,7 +73,7 @@ export const MatchEvidenceExplanationSchema = Schema.Struct({ ruleKey: RuleKeySchema, verification: Schema.optionalKey(Schema.Literals(['REJECTED', 'UNVERIFIED', 'VERIFIED'])), }); -export const MatchPreviewEvidenceSchema = Schema.Struct({ +const MatchPreviewEvidenceSchema = Schema.Struct({ kind: Schema.Literals(['EXACT_CLAIM', 'WEAK_EVIDENCE']), partyRef: PartyRefSchema, }); @@ -114,7 +113,7 @@ export const DuplicateCaseResolutionResultSchema = Schema.Struct({ }); export type DuplicateCaseResolutionResult = typeof DuplicateCaseResolutionResultSchema.Type; -export const PartyDecisionOperationSchema = Schema.Literals([ +const PartyDecisionOperationSchema = Schema.Literals([ 'CREATE', 'MATCH', 'REVIEW_MATCH', @@ -122,13 +121,9 @@ export const PartyDecisionOperationSchema = Schema.Literals([ 'LIFECYCLE', 'LEGACY', ]); -export const CommittedCreateOutcomeSchema = Schema.Literals([ - 'CREATED', - 'MATCHED_EXISTING', - 'AMBIGUOUS', -]); +const CommittedCreateOutcomeSchema = Schema.Literals(['CREATED', 'MATCHED_EXISTING', 'AMBIGUOUS']); -export const PartyMatchDecisionRecordSchema = Schema.Struct({ +const PartyMatchDecisionRecordFieldsSchema = Schema.Struct({ caseRef: Schema.toEncoded(Schema.OptionFromNullOr(DuplicateCandidateCaseRefSchema)), committedCreateOutcome: Schema.toEncoded( Schema.OptionFromOptionalNullOr(CommittedCreateOutcomeSchema, { onNoneEncoding: null }), @@ -143,34 +138,47 @@ export const PartyMatchDecisionRecordSchema = Schema.Struct({ operation: Schema.optionalKey(PartyDecisionOperationSchema), outcome: Schema.Literals(['CREATED', 'MATCHED', 'NO_MATCH', 'AMBIGUOUS']), partyRef: Schema.toEncoded(Schema.OptionFromNullOr(PartyRefSchema)), -}).check( - Schema.makeFilter((record) => { - const isCreate = record.operation === 'CREATE' || record.operation === 'REVIEW_CREATE'; - const expected = record.outcome === 'MATCHED' ? 'MATCHED_EXISTING' : record.outcome; - if (isCreate && (record.committedCreateOutcome !== expected || record.outcome === 'NO_MATCH')) { - return 'Create decisions must preserve the exact committed Create result'; - } - if ( - !isCreate && - record.committedCreateOutcome !== null && - record.committedCreateOutcome !== undefined - ) { - return 'Only Create operations carry committed Create outcomes'; - } - if (record.outcome === 'AMBIGUOUS') { - return record.partyRef === null && record.caseRef !== null - ? undefined - : 'Ambiguity requires exactly one case reference'; - } - if (record.outcome === 'NO_MATCH') { - return record.partyRef === null && record.caseRef === null - ? undefined - : 'NO_MATCH has no result reference'; - } - return record.partyRef !== null && record.caseRef === null +}); +type DecisionRecord = typeof PartyMatchDecisionRecordFieldsSchema.Type; +const isCreateOperation = (operation: DecisionRecord['operation']): boolean => + operation === 'CREATE' || operation === 'REVIEW_CREATE'; + +const validateCreateOutcome = (record: DecisionRecord): string | undefined => { + const isCreate = isCreateOperation(record.operation); + const expected = record.outcome === 'MATCHED' ? 'MATCHED_EXISTING' : record.outcome; + if (isCreate && (record.committedCreateOutcome !== expected || record.outcome === 'NO_MATCH')) { + return 'Create decisions must preserve the exact committed Create result'; + } + if ( + !isCreate && + record.committedCreateOutcome !== null && + record.committedCreateOutcome !== undefined + ) { + return 'Only Create operations carry committed Create outcomes'; + } + return undefined; +}; + +const validateDecisionReferences = (record: DecisionRecord): string | undefined => { + if (record.outcome === 'AMBIGUOUS') { + return record.partyRef === null && record.caseRef !== null ? undefined - : 'Resolved decisions require exactly one Party reference'; - }), + : 'Ambiguity requires exactly one case reference'; + } + if (record.outcome === 'NO_MATCH') { + return record.partyRef === null && record.caseRef === null + ? undefined + : 'NO_MATCH has no result reference'; + } + return record.partyRef !== null && record.caseRef === null + ? undefined + : 'Resolved decisions require exactly one Party reference'; +}; + +export const PartyMatchDecisionRecordSchema = PartyMatchDecisionRecordFieldsSchema.check( + Schema.makeFilter( + (record) => validateCreateOutcome(record) ?? validateDecisionReferences(record), + ), ); export const DuplicateCandidateDetailSchema = Schema.Struct({ candidate: PartyCandidateSchema, @@ -186,21 +194,9 @@ export const DuplicateCandidateDetailSchema = Schema.Struct({ revision: Schema.Finite.check(Schema.isInt(), Schema.isGreaterThan(0)), }); -/** No inference from LEGACY, matching or lifecycle records is safe for Create recovery. */ -export const committedCreateResult = ( - record: typeof PartyMatchDecisionRecordSchema.Type, +const resolvedCreateResult = ( + record: DecisionRecord, ): typeof PartyCreateOutcomeSchema.Type | null => { - if (record.operation !== 'CREATE' && record.operation !== 'REVIEW_CREATE') { - return null; - } - if ( - record.committedCreateOutcome === 'AMBIGUOUS' && - record.caseRef !== null && - record.partyRef === null && - record.outcome === 'AMBIGUOUS' - ) { - return { caseRef: record.caseRef, decisionRef: record.decisionRef, outcome: 'AMBIGUOUS' }; - } if ( record.partyRef !== null && record.caseRef === null && @@ -215,3 +211,21 @@ export const committedCreateResult = ( } return null; }; + +/** No inference from LEGACY, matching or lifecycle records is safe for Create recovery. */ +export const committedCreateResult = ( + record: typeof PartyMatchDecisionRecordSchema.Type, +): typeof PartyCreateOutcomeSchema.Type | null => { + if (!isCreateOperation(record.operation)) { + return null; + } + if ( + record.committedCreateOutcome === 'AMBIGUOUS' && + record.caseRef !== null && + record.partyRef === null && + record.outcome === 'AMBIGUOUS' + ) { + return { caseRef: record.caseRef, decisionRef: record.decisionRef, outcome: 'AMBIGUOUS' }; + } + return resolvedCreateResult(record); +}; diff --git a/app/verticals/party-registry/shared/domain/merge-alias-resolution-errors/shared.ts b/app/verticals/party-registry/shared/domain/merge-alias-resolution-errors/shared.ts index 7b4d85ace..efc5b37e5 100644 --- a/app/verticals/party-registry/shared/domain/merge-alias-resolution-errors/shared.ts +++ b/app/verticals/party-registry/shared/domain/merge-alias-resolution-errors/shared.ts @@ -1,8 +1,6 @@ import { Schema } from 'effect'; -export const PartyIdSchema = Schema.String.check(Schema.isMinLength(1)).pipe( - Schema.brand('PartyId'), -); -export const TenantIdSchema = Schema.String.check(Schema.isUUID()).pipe(Schema.brand('TenantId')); +const PartyIdSchema = Schema.String.check(Schema.isMinLength(1)).pipe(Schema.brand('PartyId')); +const TenantIdSchema = Schema.String.check(Schema.isUUID()).pipe(Schema.brand('TenantId')); export const PartyIdJsonSchema = Schema.toEncoded(PartyIdSchema); export const TenantIdJsonSchema = Schema.toEncoded(TenantIdSchema); diff --git a/app/verticals/party-registry/shared/domain/merge-readiness.ts b/app/verticals/party-registry/shared/domain/merge-readiness.ts index 2b9c96cca..da030e0fa 100644 --- a/app/verticals/party-registry/shared/domain/merge-readiness.ts +++ b/app/verticals/party-registry/shared/domain/merge-readiness.ts @@ -5,7 +5,7 @@ const MergeReadinessOwnerKeySchema = Schema.String.check(Schema.isMinLength(1)). Schema.brand('MergeReadinessOwnerKey'), ); -export const MergeReadinessBlockerCodeSchema = Schema.Literals([ +const MergeReadinessBlockerCodeSchema = Schema.Literals([ 'PRODUCTION_MERGE_DISABLED', 'PREPARED_STATE_UNAVAILABLE', 'AUTHORITATIVE_IDENTITY_CONFLICT', @@ -24,9 +24,8 @@ export const MergeReadinessBlockerCodeSchema = Schema.Literals([ 'RELATIONSHIP_SELF_REFERENCE', 'STRONG_IDENTIFIER_CONFLICT', ]); -export type MergeReadinessBlockerCode = typeof MergeReadinessBlockerCodeSchema.Type; -export const MergeReadinessBlockerSchema = Schema.Struct({ +const MergeReadinessBlockerSchema = Schema.Struct({ code: MergeReadinessBlockerCodeSchema, detail: Schema.String.check(Schema.isMinLength(1)), ownerKey: Schema.toEncoded(MergeReadinessOwnerKeySchema), diff --git a/app/verticals/party-registry/shared/domain/merge-selection.ts b/app/verticals/party-registry/shared/domain/merge-selection.ts index 97b313067..f76606868 100644 --- a/app/verticals/party-registry/shared/domain/merge-selection.ts +++ b/app/verticals/party-registry/shared/domain/merge-selection.ts @@ -9,7 +9,7 @@ const MergeIsoTimestampJsonSchema = Schema.toEncoded(IsoTimestampSchema).check( }), ); -export const MergeSurvivorCandidateSchema = Schema.Struct({ +const MergeSurvivorCandidateSchema = Schema.Struct({ authoritativeEvidenceRank: Schema.Finite.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)), blockingAuthoritativeConflict: Schema.Boolean, completenessRank: Schema.Finite.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)), @@ -32,12 +32,12 @@ export const MergeSurvivorSelectionReasonSchema = Schema.Literals([ ]); export type MergeSurvivorSelectionReason = typeof MergeSurvivorSelectionReasonSchema.Type; -export const MergeSelectionEvidenceCriterionSchema = Schema.Union([ +const MergeSelectionEvidenceCriterionSchema = Schema.Union([ Schema.Literals(['CONFIRMED_DUPLICATE_SET', 'IDENTITY_SAFETY']), MergeSurvivorSelectionReasonSchema, ]); export type MergeSelectionEvidenceCriterion = typeof MergeSelectionEvidenceCriterionSchema.Type; -export const MergeEvaluatedCandidateSnapshotSchema = Schema.Struct({ +const MergeEvaluatedCandidateSnapshotSchema = Schema.Struct({ candidate: MergeSurvivorCandidateSchema, criterionValue: Schema.Union([Schema.String, Schema.Finite, Schema.Boolean]), eligibleBefore: Schema.Boolean, @@ -64,7 +64,7 @@ export const DecisionActorPrincipalIdSchema = Schema.String.check(Schema.isMinLe Schema.brand('DecisionActorPrincipalId'), ); -export const ConfirmedDuplicateSetSchema = Schema.Struct({ +const ConfirmedDuplicateSetSchema = Schema.Struct({ confirmedDuplicateDecisionId: Schema.toEncoded(ConfirmedDuplicateDecisionIdSchema), confirmedPartyRefs: Schema.Array(PartyRefSchema).check(Schema.isMinLength(2)), decisionActorPrincipalId: Schema.toEncoded(DecisionActorPrincipalIdSchema), diff --git a/app/verticals/party-registry/shared/domain/relationship-contract.ts b/app/verticals/party-registry/shared/domain/relationship-contract.ts index ba358b1de..e6c6f1d50 100644 --- a/app/verticals/party-registry/shared/domain/relationship-contract.ts +++ b/app/verticals/party-registry/shared/domain/relationship-contract.ts @@ -12,19 +12,16 @@ export { PartyRelationshipOverlapConflict, PartyRelationshipPersistenceUnavailable, PartyRelationshipRevisionConflict, - PartyRelationshipTypeUnsupported, } from './relationship-errors/index.ts'; export const ContactPersonOfRelationshipType = 'CONTACT_PERSON_OF' as const; export const PartyRelationshipTypeSchema = Schema.Literal(ContactPersonOfRelationshipType); -export type PartyRelationshipType = typeof PartyRelationshipTypeSchema.Type; export const RelationshipPartyTypeSchema = Schema.Literals([ 'PERSON', 'ORGANIZATION', 'UNRESOLVED', ]); -export type RelationshipPartyType = typeof RelationshipPartyTypeSchema.Type; export const RelationshipIsoTimestampSchema = Schema.String.pipe( Schema.check( @@ -101,14 +98,13 @@ export const EndPartyRelationshipPayloadSchema = Schema.Struct({ }); export type EndPartyRelationshipPayload = typeof EndPartyRelationshipPayloadSchema.Type; -export const RelationshipStoredEndpointSchema = Schema.Struct({ +const RelationshipStoredEndpointSchema = Schema.Struct({ canonicalPartyRef: PartyRefSchema, requestedAlias: Schema.OptionFromNullOr(PartyRefSchema), storedPartyRef: PartyRefSchema, }); -export type RelationshipStoredEndpoint = typeof RelationshipStoredEndpointSchema.Type; -export const PartyRelationshipStateSchema = Schema.Literals(['SCHEDULED', 'CURRENT', 'HISTORICAL']); +const PartyRelationshipStateSchema = Schema.Literals(['SCHEDULED', 'CURRENT', 'HISTORICAL']); export type PartyRelationshipState = typeof PartyRelationshipStateSchema.Type; export const PartyRelationshipAssertionStateSchema = Schema.Literals([ @@ -117,7 +113,6 @@ export const PartyRelationshipAssertionStateSchema = Schema.Literals([ 'RETRACTED', 'DISPUTED', ]); -export type PartyRelationshipAssertionState = typeof PartyRelationshipAssertionStateSchema.Type; export const RelationshipEndEvidenceSchema = Schema.Struct({ effectiveAt: RelationshipIsoTimestampSchema, @@ -125,7 +120,6 @@ export const RelationshipEndEvidenceSchema = Schema.Struct({ reason: Schema.OptionFromNullOr(ReasonSchema), recordedAt: RelationshipIsoTimestampSchema, }); -export type RelationshipEndEvidence = typeof RelationshipEndEvidenceSchema.Type; export const UpdateRelationshipAuditEvidenceSchema = Schema.Struct({ changeReason: ReasonSchema, @@ -173,13 +167,11 @@ export const CreatePartyRelationshipResultSchema = Schema.Struct({ outcome: Schema.Literals(['CREATED', 'REUSED_EXISTING']), relationship: PartyRelationshipDetailSchema, }); -export type CreatePartyRelationshipResult = typeof CreatePartyRelationshipResultSchema.Type; export const ChangePartyRelationshipResultSchema = Schema.Struct({ outcome: Schema.Literals(['CHANGED', 'UNCHANGED']), relationship: PartyRelationshipDetailSchema, }); -export type ChangePartyRelationshipResult = typeof ChangePartyRelationshipResultSchema.Type; export const PartyRelationshipLifecycleEventPayloadSchema = Schema.Struct({ fromPartyRef: PartyRefSchema, @@ -195,8 +187,6 @@ export type PartyRelationshipLifecycleEventPayload = export const PartyRelationshipLifecycleEventPayloadJsonSchema = Schema.toEncoded( PartyRelationshipLifecycleEventPayloadSchema, ); -export type PartyRelationshipLifecycleEventPayloadJson = - typeof PartyRelationshipLifecycleEventPayloadJsonSchema.Type; export const partyRef = (tenantId: string, resourceId: string) => ({ moduleId: 'party.registry' as const, diff --git a/app/verticals/party-registry/shared/domain/relationship-errors/index.ts b/app/verticals/party-registry/shared/domain/relationship-errors/index.ts index 4e62f9e8e..8510f0148 100644 --- a/app/verticals/party-registry/shared/domain/relationship-errors/index.ts +++ b/app/verticals/party-registry/shared/domain/relationship-errors/index.ts @@ -18,7 +18,6 @@ export { PartyRelationshipNotFound } from './not-found.ts'; export { PartyRelationshipOverlapConflict } from './overlap-conflict.ts'; export { PartyRelationshipPersistenceUnavailable } from './persistence-unavailable.ts'; export { PartyRelationshipRevisionConflict } from './revision-conflict.ts'; -export { PartyRelationshipTypeUnsupported } from './type-unsupported.ts'; export const PartyRelationshipMutationErrorSchema = Schema.Union([ PartyAliasWriteRejected, diff --git a/app/verticals/party-registry/shared/domain/relationship-temporal.ts b/app/verticals/party-registry/shared/domain/relationship-temporal.ts index 98b102f70..9776d85ee 100644 --- a/app/verticals/party-registry/shared/domain/relationship-temporal.ts +++ b/app/verticals/party-registry/shared/domain/relationship-temporal.ts @@ -86,6 +86,37 @@ export const decideRelationshipCreate = ( : { _tag: 'overlap', relationshipId: overlapping.relationshipId }; }; +const requiresStartCorrection = ( + current: RelationshipUpdateState, + request: RelationshipUpdateRequest, + now: RelationshipIsoTimestamp, +): boolean => + request.validFrom !== undefined && + Option.isSome(current.validFrom) && + !sameInstant(request.validFrom, current.validFrom.value) && + (DateTime.Order(current.validFrom.value, now) <= 0 || + DateTime.Order(request.validFrom, now) <= 0); + +const requiresEndCorrection = ( + current: RelationshipUpdateState, + request: RelationshipUpdateRequest, + now: RelationshipIsoTimestamp, +): boolean => + request.validTo !== undefined && + Option.isSome(current.validTo) && + DateTime.Order(current.validTo.value, now) <= 0 && + !sameOptionalInstant(current.validTo, request.validTo); + +const requiresExplicitEnd = ( + current: RelationshipUpdateState, + request: RelationshipUpdateRequest, + now: RelationshipIsoTimestamp, +): boolean => + Option.isNone(current.validTo) && + request.validTo !== undefined && + Option.isSome(request.validTo) && + DateTime.Order(request.validTo.value, now) <= 0; + export const decideRelationshipUpdate = ( current: RelationshipUpdateState, request: RelationshipUpdateRequest, @@ -109,32 +140,37 @@ export const decideRelationshipUpdate = ( ) { return { _tag: 'invalid_interval' }; } - if ( - request.validFrom !== undefined && - Option.isSome(current.validFrom) && - !sameInstant(request.validFrom, current.validFrom.value) && - (DateTime.Order(current.validFrom.value, now) <= 0 || - DateTime.Order(request.validFrom, now) <= 0) - ) { + if (requiresStartCorrection(current, request, now)) { return { _tag: 'correction_required', fact: 'validFrom' }; } + if (requiresEndCorrection(current, request, now)) { + return { _tag: 'correction_required', fact: 'validTo' }; + } + if (requiresExplicitEnd(current, request, now)) { + return { _tag: 'end_required' }; + } + return { _tag: 'update' }; +}; + +const decideRepeatedRelationshipEnd = ( + current: RelationshipEndState, + request: RelationshipEndRequest, +) => { if ( - request.validTo !== undefined && - Option.isSome(current.validTo) && - DateTime.Order(current.validTo.value, now) <= 0 && - !sameOptionalInstant(current.validTo, request.validTo) + current.endReason === (request.reason ?? null) && + current.endProvenanceMethod === request.provenance.method && + current.endProvenanceSource === request.provenance.source ) { - return { _tag: 'correction_required', fact: 'validTo' }; + return { _tag: 'unchanged' } as const; } if ( - Option.isNone(current.validTo) && - request.validTo !== undefined && - Option.isSome(request.validTo) && - DateTime.Order(request.validTo.value, now) <= 0 + current.endReason === null && + current.endProvenanceMethod === null && + current.endProvenanceSource === null ) { - return { _tag: 'end_required' }; + return { _tag: 'attach_end_evidence' } as const; } - return { _tag: 'update' }; + return { _tag: 'correction_required', fact: 'validTo' } as const; }; export const decideRelationshipEnd = ( @@ -159,21 +195,7 @@ export const decideRelationshipEnd = ( return { _tag: 'invalid_interval' }; } if (Option.isSome(current.validTo) && sameInstant(current.validTo.value, request.effectiveAt)) { - if ( - current.endReason === (request.reason ?? null) && - current.endProvenanceMethod === request.provenance.method && - current.endProvenanceSource === request.provenance.source - ) { - return { _tag: 'unchanged' }; - } - if ( - current.endReason === null && - current.endProvenanceMethod === null && - current.endProvenanceSource === null - ) { - return { _tag: 'attach_end_evidence' }; - } - return { _tag: 'correction_required', fact: 'validTo' }; + return decideRepeatedRelationshipEnd(current, request); } if (Option.isSome(current.validTo)) { return DateTime.Order(current.validTo.value, now) > 0 diff --git a/app/verticals/party-registry/shared/domain/search-projection-gateway.ts b/app/verticals/party-registry/shared/domain/search-projection-gateway.ts index 1671a1c5b..40c2a345a 100644 --- a/app/verticals/party-registry/shared/domain/search-projection-gateway.ts +++ b/app/verticals/party-registry/shared/domain/search-projection-gateway.ts @@ -25,7 +25,7 @@ export interface PartySearchProjectionHit { readonly title: string; } -export interface CounterpartyRoleProjectionPeriod { +interface CounterpartyRoleProjectionPeriod { readonly role: CurrentCounterpartyRole; readonly validFrom: string; readonly validTo?: string; diff --git a/app/verticals/party-registry/shared/domain/search-result.ts b/app/verticals/party-registry/shared/domain/search-result.ts index 964439fbc..b39ab384b 100644 --- a/app/verticals/party-registry/shared/domain/search-result.ts +++ b/app/verticals/party-registry/shared/domain/search-result.ts @@ -16,7 +16,7 @@ export const PartySearchQuerySchema = Schema.Trim.check( export const CurrentCounterpartyRoleSchema = Schema.Literals(['CUSTOMER', 'SUPPLIER']); export type CurrentCounterpartyRole = typeof CurrentCounterpartyRoleSchema.Type; -export const SearchLegalEntityContextSchema = Schema.Struct({ +const SearchLegalEntityContextSchema = Schema.Struct({ legalEntityId: Schema.toEncoded(LegalEntityIdSchema), tenantId: Schema.toEncoded(TenantIdSchema), }); @@ -30,11 +30,10 @@ export const PartySearchResultSchema = Schema.Struct({ }); export type PartySearchResult = typeof PartySearchResultSchema.Type; -export const CounterpartyCollisionSchema = Schema.Struct({ +const CounterpartyCollisionSchema = Schema.Struct({ counterpartyRefs: Schema.Array(CounterpartyRefSchema), kind: Schema.Literal('CANONICAL_PARTY_COUNTERPARTY_COLLISION'), }); -export type CounterpartyCollision = typeof CounterpartyCollisionSchema.Type; export const CounterpartySearchResultSchema = Schema.Struct({ collision: Schema.optionalKey(CounterpartyCollisionSchema), diff --git a/app/verticals/party-registry/shared/domain/search-semantics.ts b/app/verticals/party-registry/shared/domain/search-semantics.ts index c4e9b9cc2..e66ebb2ea 100644 --- a/app/verticals/party-registry/shared/domain/search-semantics.ts +++ b/app/verticals/party-registry/shared/domain/search-semantics.ts @@ -11,10 +11,10 @@ import type { PartySearchResult, } from './search-result.ts'; -export const SearchProjectionViolationSchema = Schema.TaggedStruct('SearchProjectionViolation', { +const SearchProjectionViolationSchema = Schema.TaggedStruct('SearchProjectionViolation', { reason: Schema.String, }); -export type SearchProjectionViolation = typeof SearchProjectionViolationSchema.Type; +type SearchProjectionViolation = typeof SearchProjectionViolationSchema.Type; const SearchResultsTagSchema = Schema.TaggedStruct('SearchResults', {}); type SearchResultsTag = typeof SearchResultsTagSchema.Type; @@ -40,17 +40,18 @@ const samePartyRef = (left: PartyRef, right: PartyRef): boolean => refKey(left) const isAliasHit = (canonical: PartyRef, matched: PartyRef | undefined): boolean => matched !== undefined && !samePartyRef(canonical, matched); +const partyHitViolatesScope = (hit: PartySearchProjectionHit, tenantId: string): boolean => + hit.canonicalPartyRef.tenantId !== tenantId || + (hit.matchedPartyRef !== undefined && hit.matchedPartyRef.tenantId !== tenantId) || + hit.title.trim().length === 0; + export const normalizePartySearchHits = ( scope: Readonly<{ readonly includeArchived: boolean; readonly tenantId: string }>, hits: readonly PartySearchProjectionHit[], ): SearchNormalizationResult => { const byCanonicalParty = new Map(); for (const hit of hits) { - if ( - hit.canonicalPartyRef.tenantId !== scope.tenantId || - (hit.matchedPartyRef !== undefined && hit.matchedPartyRef.tenantId !== scope.tenantId) || - hit.title.trim().length === 0 - ) { + if (partyHitViolatesScope(hit, scope.tenantId)) { return violation('Party Search projection returned data outside its trusted tenant contract'); } const key = refKey(hit.canonicalPartyRef); @@ -115,6 +116,45 @@ const sameCurrentProjection = ( existing.currentRoles.length === currentRoles.length && existing.currentRoles.every((role, index) => role === currentRoles[index]); +const counterpartyHitViolatesScope = ( + hit: CounterpartySearchProjectionHit, + scope: Readonly<{ tenantId: string; legalEntityId: string }>, +): boolean => + hit.counterpartyRef.tenantId !== scope.tenantId || + hit.canonicalPartyRef.tenantId !== scope.tenantId || + (hit.matchedPartyRef !== undefined && hit.matchedPartyRef.tenantId !== scope.tenantId) || + hit.legalEntity.tenantId !== scope.tenantId || + hit.legalEntity.legalEntityId !== scope.legalEntityId || + hit.partyTitle.trim().length === 0; + +const withCounterpartyCollisions = ( + filtered: readonly CounterpartySearchResult[], +): SearchNormalizationResult => { + const byCanonicalParty = new Map(); + for (const item of filtered) { + const key = refKey(item.party.ref); + byCanonicalParty.set(key, [...(byCanonicalParty.get(key) ?? []), item]); + } + + return searchResults( + filtered.map((item) => { + const colliding = byCanonicalParty.get(refKey(item.party.ref)) ?? []; + if (colliding.length < 2) { + return item; + } + return { + ...item, + collision: { + counterpartyRefs: colliding + .map(({ ref }) => ref) + .toSorted((left, right) => left.resourceId.localeCompare(right.resourceId)), + kind: 'CANONICAL_PARTY_COUNTERPARTY_COLLISION' as const, + }, + }; + }), + ); +}; + export const normalizeCounterpartySearchHits = ( scope: Readonly<{ readonly effectiveAt: typeof Schema.DateTimeUtcFromString.Encoded; @@ -132,14 +172,7 @@ export const normalizeCounterpartySearchHits = ( const byCounterparty = new Map(); for (const hit of hits) { - if ( - hit.counterpartyRef.tenantId !== scope.tenantId || - hit.canonicalPartyRef.tenantId !== scope.tenantId || - (hit.matchedPartyRef !== undefined && hit.matchedPartyRef.tenantId !== scope.tenantId) || - hit.legalEntity.tenantId !== scope.tenantId || - hit.legalEntity.legalEntityId !== scope.legalEntityId || - hit.partyTitle.trim().length === 0 - ) { + if (counterpartyHitViolatesScope(hit, scope)) { return violation( 'Counterparty Search projection returned data outside its trusted tenant or Legal Entity contract', ); @@ -181,27 +214,5 @@ export const normalizeCounterpartySearchHits = ( (scope.includeArchived || !item.party.archived) && (scope.role === undefined || item.currentRoles.includes(scope.role)), ); - const byCanonicalParty = new Map(); - for (const item of filtered) { - const key = refKey(item.party.ref); - byCanonicalParty.set(key, [...(byCanonicalParty.get(key) ?? []), item]); - } - - return searchResults( - filtered.map((item) => { - const colliding = byCanonicalParty.get(refKey(item.party.ref)) ?? []; - if (colliding.length < 2) { - return item; - } - return { - ...item, - collision: { - counterpartyRefs: colliding - .map(({ ref }) => ref) - .toSorted((left, right) => left.resourceId.localeCompare(right.resourceId)), - kind: 'CANONICAL_PARTY_COUNTERPARTY_COLLISION' as const, - }, - }; - }), - ); + return withCounterpartyCollisions(filtered); }; diff --git a/app/verticals/party-registry/shared/resources/party-merge.ts b/app/verticals/party-registry/shared/resources/party-merge.ts index 6f0b5e785..3e2db167a 100644 --- a/app/verticals/party-registry/shared/resources/party-merge.ts +++ b/app/verticals/party-registry/shared/resources/party-merge.ts @@ -11,6 +11,7 @@ import { MergeSurvivorSelectionReasonSchema, } from '../domain/merge-selection.ts'; import { PartyRefSchema } from './party.ts'; +import type { PartyRef } from './party.ts'; import { PartyRegistryResourceIdJsonSchema, PartyRegistryTenantIdJsonSchema, @@ -24,6 +25,21 @@ export const PartyMergeRefSchema = Schema.Struct({ }); export type PartyMergeRef = typeof PartyMergeRefSchema.Type; +const selectionEvidenceIsInvalid = ( + selectionEvidenceChain: readonly (typeof MergeSelectionEvidenceStepSchema.Type)[], + selectionReason: typeof MergeSurvivorSelectionReasonSchema.Type, + survivorPartyRef: PartyRef, +): boolean => { + const finalSelectionStep = selectionEvidenceChain.at(-1); + return ( + selectionEvidenceChain[0]?.criterion !== 'CONFIRMED_DUPLICATE_SET' || + selectionEvidenceChain[1]?.criterion !== 'IDENTITY_SAFETY' || + finalSelectionStep?.criterion !== selectionReason || + finalSelectionStep.winnerPartyRef?.resourceId !== survivorPartyRef.resourceId || + finalSelectionStep.winnerPartyRef.tenantId !== survivorPartyRef.tenantId + ); +}; + /** * Prepared merge evidence only. PREPARED is deliberately the sole V1 state: no executable merge * lifecycle is published until the dependency cone and wrong-merge recovery are proven. @@ -72,14 +88,7 @@ export const PartyMergeSchema = Schema.Struct({ path: ['absorbedPartyRefs'], }); } - const finalSelectionStep = selectionEvidenceChain.at(-1); - if ( - selectionEvidenceChain[0]?.criterion !== 'CONFIRMED_DUPLICATE_SET' || - selectionEvidenceChain[1]?.criterion !== 'IDENTITY_SAFETY' || - finalSelectionStep?.criterion !== selectionReason || - finalSelectionStep.winnerPartyRef?.resourceId !== survivorPartyRef.resourceId || - finalSelectionStep.winnerPartyRef.tenantId !== survivorPartyRef.tenantId - ) { + if (selectionEvidenceIsInvalid(selectionEvidenceChain, selectionReason, survivorPartyRef)) { issues.push({ issue: 'selection evidence must prove confirmation, safety, and the recorded survivor reason', diff --git a/app/verticals/party-registry/shared/resources/resource-ref-identifiers.ts b/app/verticals/party-registry/shared/resources/resource-ref-identifiers.ts index b4ac9215d..3baa8c8f4 100644 --- a/app/verticals/party-registry/shared/resources/resource-ref-identifiers.ts +++ b/app/verticals/party-registry/shared/resources/resource-ref-identifiers.ts @@ -1,10 +1,10 @@ import { Schema } from 'effect'; -export const PartyRegistryResourceIdSchema = Schema.String.check( +const PartyRegistryResourceIdSchema = Schema.String.check( Schema.isMinLength(1), Schema.isMaxLength(300), ).pipe(Schema.brand('PartyRegistryResourceId')); -export const PartyRegistryTenantIdSchema = Schema.String.check(Schema.isUUID()).pipe( +const PartyRegistryTenantIdSchema = Schema.String.check(Schema.isUUID()).pipe( Schema.brand('TenantId'), ); diff --git a/app/verticals/party-registry/shared/ultramodern-build.ts b/app/verticals/party-registry/shared/ultramodern-build.ts index 80a1563db..33e72685a 100644 --- a/app/verticals/party-registry/shared/ultramodern-build.ts +++ b/app/verticals/party-registry/shared/ultramodern-build.ts @@ -1,3 +1,5 @@ +import { withUltramodernBuildIdentity } from '../../../packages/shared-contracts/src/ultramodern-build.ts'; + declare const ULTRAMODERN_BUILD_MARKER: string; declare const ULTRAMODERN_SOURCE_REVISION: string; @@ -63,29 +65,11 @@ const readInjectedSourceRevision = (): string => { const ultramodernBuildMarker = readInjectedBuildMarker(); const ultramodernSourceRevision = readInjectedSourceRevision(); -const ultramodernBuildArtifact = { - ...ultramodernGeneratedBuildArtifact, - deliveryUnit: { - ...ultramodernGeneratedBuildArtifact.deliveryUnit, - build: ultramodernBuildMarker, - buildMarker: ultramodernBuildMarker, - sourceRevision: ultramodernSourceRevision, - }, - surfaces: { - api: { - ...ultramodernGeneratedBuildArtifact.surfaces.api, - build: ultramodernBuildMarker, - buildMarker: ultramodernBuildMarker, - sourceRevision: ultramodernSourceRevision, - }, - ui: { - ...ultramodernGeneratedBuildArtifact.surfaces.ui, - build: ultramodernBuildMarker, - buildMarker: ultramodernBuildMarker, - sourceRevision: ultramodernSourceRevision, - }, - }, -} as const; +const ultramodernBuildArtifact = withUltramodernBuildIdentity( + ultramodernGeneratedBuildArtifact, + ultramodernBuildMarker, + ultramodernSourceRevision, +); export { ultramodernBuildArtifact }; diff --git a/app/verticals/party-registry/src/actions/add-contact-point.action.ts b/app/verticals/party-registry/src/actions/add-contact-point.action.ts index eca84aa0b..40d913a4d 100644 --- a/app/verticals/party-registry/src/actions/add-contact-point.action.ts +++ b/app/verticals/party-registry/src/actions/add-contact-point.action.ts @@ -33,16 +33,10 @@ import { } from '../../shared/actions/add-contact-point.ts'; import type { AddContactPointPayload } from '../../shared/actions/add-contact-point.ts'; -export { - AddContactPointPayloadSchema, - AddContactPointResultSchema, -} from '../../shared/actions/add-contact-point.ts'; -export type { - AddContactPointPayload, - AddContactPointResult, -} from '../../shared/actions/add-contact-point.ts'; +export { AddContactPointPayloadSchema } from '../../shared/actions/add-contact-point.ts'; +export type { AddContactPointPayload } from '../../shared/actions/add-contact-point.ts'; -export const AddContactPointErrorSchema = Schema.Union([ +const AddContactPointErrorSchema = Schema.Union([ PartyAliasWriteRejected, PartyContactPointPartyNotFound, PartyContactPointAlreadyExists, @@ -179,11 +173,3 @@ export const addContactPointAction = defineAction( add: (command: AddContactPointCommand) => addContactPointRecord(transaction, scope, command), }), ); - -// -export { AddContactPointPartyRegistryContactPointAddedV1OutboxPayloadSchema } from './add-contact-point.party-registry-contact-point-added-v1.outbox-message.ts'; -export { AddContactPointPartyRegistryContactPointAddedV1OutboxProducerModuleKey } from './add-contact-point.party-registry-contact-point-added-v1.outbox-message.ts'; -export { AddContactPointPartyRegistryContactPointAddedV1OutboxTopic } from './add-contact-point.party-registry-contact-point-added-v1.outbox-message.ts'; -export { createAddContactPointPartyRegistryContactPointAddedV1OutboxMessage } from './add-contact-point.party-registry-contact-point-added-v1.outbox-message.ts'; -export type { AddContactPointPartyRegistryContactPointAddedV1OutboxPayload } from './add-contact-point.party-registry-contact-point-added-v1.outbox-message.ts'; -// diff --git a/app/verticals/party-registry/src/actions/add-contact-point.party-registry-contact-point-added-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/add-contact-point.party-registry-contact-point-added-v1.outbox-message.ts index 1c98379b8..7e760fbd4 100644 --- a/app/verticals/party-registry/src/actions/add-contact-point.party-registry-contact-point-added-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/add-contact-point.party-registry-contact-point-added-v1.outbox-message.ts @@ -1,23 +1,14 @@ -/* eslint-disable unicorn/prefer-export-from -- Codesmith keeps stable action-local aliases for the public outbox contract. expires: 2026-12-31. */ import type { OutboxMessage } from '@app/core-runtime'; import { - OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-contact-point-added-v1'; import type { OutboxPayload } from '@app/party-registry/outbox/party-registry-contact-point-added-v1'; -export const AddContactPointPartyRegistryContactPointAddedV1OutboxPayloadSchema = - OutboxPayloadSchema; -export type AddContactPointPartyRegistryContactPointAddedV1OutboxPayload = OutboxPayload; -export const AddContactPointPartyRegistryContactPointAddedV1OutboxProducerModuleKey = - outboxProducerModuleKey; -export const AddContactPointPartyRegistryContactPointAddedV1OutboxTopic = outboxTopic; - export const createAddContactPointPartyRegistryContactPointAddedV1OutboxMessage = ( payload: OutboxPayload, ): OutboxMessage => ({ payloadJson: payload, - producerModuleKey: AddContactPointPartyRegistryContactPointAddedV1OutboxProducerModuleKey, - topic: AddContactPointPartyRegistryContactPointAddedV1OutboxTopic, + producerModuleKey: outboxProducerModuleKey, + topic: outboxTopic, }); diff --git a/app/verticals/party-registry/src/actions/add-party-official-identifier.action.ts b/app/verticals/party-registry/src/actions/add-party-official-identifier.action.ts index 2e30fdec6..ef7b150c9 100644 --- a/app/verticals/party-registry/src/actions/add-party-official-identifier.action.ts +++ b/app/verticals/party-registry/src/actions/add-party-official-identifier.action.ts @@ -48,14 +48,7 @@ import type { AddPartyOfficialIdentifierResult, } from '../../shared/actions/add-party-official-identifier.ts'; -export { - AddPartyOfficialIdentifierPayloadSchema, - AddPartyOfficialIdentifierResultSchema, -} from '../../shared/actions/add-party-official-identifier.ts'; -export type { - AddPartyOfficialIdentifierPayload, - AddPartyOfficialIdentifierResult, -} from '../../shared/actions/add-party-official-identifier.ts'; +export type { AddPartyOfficialIdentifierPayload } from '../../shared/actions/add-party-official-identifier.ts'; const ErrorSchema = Schema.Union([ PartyNotFound, OfficialIdentifierClaimConflict, @@ -236,10 +229,3 @@ export const addPartyOfficialIdentifierAction = defineAction( }), }), ); -export { createAddPartyOfficialIdentifierPartyRegistryOfficialIdentifierAddedV1OutboxMessage } from './add-party-official-identifier.party-registry-official-identifier-added-v1.outbox-message.ts'; -export { - AddPartyOfficialIdentifierPartyRegistryOfficialIdentifierAddedV1OutboxPayloadSchema, - AddPartyOfficialIdentifierPartyRegistryOfficialIdentifierAddedV1OutboxProducerModuleKey, - AddPartyOfficialIdentifierPartyRegistryOfficialIdentifierAddedV1OutboxTopic, -} from './add-party-official-identifier.party-registry-official-identifier-added-v1.outbox-message.ts'; -export type { AddPartyOfficialIdentifierPartyRegistryOfficialIdentifierAddedV1OutboxPayload } from './add-party-official-identifier.party-registry-official-identifier-added-v1.outbox-message.ts'; diff --git a/app/verticals/party-registry/src/actions/add-party-official-identifier.party-registry-official-identifier-added-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/add-party-official-identifier.party-registry-official-identifier-added-v1.outbox-message.ts index ce0fbaf18..71adab453 100644 --- a/app/verticals/party-registry/src/actions/add-party-official-identifier.party-registry-official-identifier-added-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/add-party-official-identifier.party-registry-official-identifier-added-v1.outbox-message.ts @@ -1,26 +1,14 @@ -/* eslint-disable unicorn/prefer-export-from -- Codesmith keeps stable action-local aliases for the public outbox contract. expires: 2026-12-31. */ import type { OutboxMessage } from '@app/core-runtime'; import { - OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-official-identifier-added-v1'; import type { OutboxPayload } from '@app/party-registry/outbox/party-registry-official-identifier-added-v1'; -export const AddPartyOfficialIdentifierPartyRegistryOfficialIdentifierAddedV1OutboxPayloadSchema = - OutboxPayloadSchema; -export type AddPartyOfficialIdentifierPartyRegistryOfficialIdentifierAddedV1OutboxPayload = - OutboxPayload; -export const AddPartyOfficialIdentifierPartyRegistryOfficialIdentifierAddedV1OutboxProducerModuleKey = - outboxProducerModuleKey; -export const AddPartyOfficialIdentifierPartyRegistryOfficialIdentifierAddedV1OutboxTopic = - outboxTopic; - export const createAddPartyOfficialIdentifierPartyRegistryOfficialIdentifierAddedV1OutboxMessage = ( payload: OutboxPayload, ): OutboxMessage => ({ payloadJson: payload, - producerModuleKey: - AddPartyOfficialIdentifierPartyRegistryOfficialIdentifierAddedV1OutboxProducerModuleKey, - topic: AddPartyOfficialIdentifierPartyRegistryOfficialIdentifierAddedV1OutboxTopic, + producerModuleKey: outboxProducerModuleKey, + topic: outboxTopic, }); diff --git a/app/verticals/party-registry/src/actions/archive-organization-engagement.action.ts b/app/verticals/party-registry/src/actions/archive-organization-engagement.action.ts index 7c5c4dffa..0d938ee96 100644 --- a/app/verticals/party-registry/src/actions/archive-organization-engagement.action.ts +++ b/app/verticals/party-registry/src/actions/archive-organization-engagement.action.ts @@ -24,9 +24,9 @@ import { transitionOrganizationEngagementProfile } from '../services/engagement- import type { LifecycleResult } from '../services/engagement-profile-persistence.service.ts'; import { resolveEngagementLifecycle } from './engagement-lifecycle.ts'; -export const ArchiveOrganizationEngagementPayload = OrganizationEngagementLifecyclePayloadSchema; -export const ArchiveOrganizationEngagementResult = OrganizationEngagementProfileSchema; -export const ArchiveOrganizationEngagementError = Schema.Union([ +const ArchiveOrganizationEngagementPayload = OrganizationEngagementLifecyclePayloadSchema; +const ArchiveOrganizationEngagementResult = OrganizationEngagementProfileSchema; +const ArchiveOrganizationEngagementError = Schema.Union([ EngagementProfileConflict, EngagementProfileNotFound, EngagementProfilePersistenceUnavailable, diff --git a/app/verticals/party-registry/src/actions/archive-party.action.ts b/app/verticals/party-registry/src/actions/archive-party.action.ts index e3b707b02..4ce3a6728 100644 --- a/app/verticals/party-registry/src/actions/archive-party.action.ts +++ b/app/verticals/party-registry/src/actions/archive-party.action.ts @@ -1,10 +1,13 @@ // @generated by OntOS Codesmith Action v1 // @ontos-action-owner party.registry // @ontos-action-slug archive-party -import { createHash } from 'node:crypto'; +import { + recordPartyInvariantAccess, + resolvePartyLifecycle, +} from './party-lifecycle-action-helpers.ts'; import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ActionHandlerContext } from '@app/core-runtime'; -import { Effect, Match, Schema } from 'effect'; +import { Effect, Schema } from 'effect'; import { PartyAliasResolutionBrokenChain, PartyAliasResolutionCrossTenant, @@ -13,11 +16,9 @@ import { PartyAliasWriteRejected, } from '../../shared/domain/merge-alias-resolution.ts'; import { - partyIdFromString, PartyLifecycleConflict, PartyNotFound, PartyPersistenceUnavailable, - PartySchema, } from '../../shared/domain/identity-contracts.ts'; import { PartyRefSchema } from '../../shared/resources/party.ts'; import { transitionPartyRecord } from '../services/party-identity-persistence.service.ts'; @@ -29,14 +30,7 @@ import { } from '../../shared/actions/archive-party.ts'; import type { ArchivePartyPayload } from '../../shared/actions/archive-party.ts'; -export { - ArchivePartyPayloadSchema, - ArchivePartyResultSchema, -} from '../../shared/actions/archive-party.ts'; -export type { - ArchivePartyPayload, - ArchivePartyResult, -} from '../../shared/actions/archive-party.ts'; +export type { ArchivePartyPayload } from '../../shared/actions/archive-party.ts'; const ErrorSchema = Schema.Union([ PartyAliasResolutionBrokenChain, PartyAliasResolutionCrossTenant, @@ -53,60 +47,17 @@ const domainEvents = { interface Services { readonly transition: (payload: ArchivePartyPayload) => ReturnType; } -type PersistedParty = typeof PartySchema.Type | typeof PartySchema.Encoded; -const decodeParty = (party: PersistedParty) => - Schema.is(PartySchema)(party) - ? Effect.succeed(party) - : Schema.decodeUnknownEffect(PartySchema)(party).pipe( - Effect.mapError((cause) => - Object.defineProperty( - new PartyPersistenceUnavailable({ - code: 'party_persistence_unavailable', - reason: 'The stored Party could not be decoded', - }), - 'cause', - { configurable: true, value: cause }, - ), - ), - ); const handle = Effect.fn('ArchivePartyAction.handle')(function* archiveParty( payload: ArchivePartyPayload, context: ActionHandlerContext, ) { const persistenceResult = yield* context.services.transition(payload); - const result = yield* Match.value(persistenceResult).pipe( - Match.tag('not_found', () => - Effect.fail( - new PartyNotFound({ - code: 'party_not_found', - partyId: partyIdFromString(payload.partyRef.resourceId), - reason: 'The Party does not exist', - }), - ), - ), - Match.tag('conflict', () => - Effect.fail( - new PartyLifecycleConflict({ - code: 'party_lifecycle_conflict', - reason: 'The Party is already archived or its revision is stale', - requestedState: 'ARCHIVED', - }), - ), - ), - Match.tag('found', ({ value }) => decodeParty(value)), - Match.exhaustive, - ); - yield* context.recordDataAccess({ - accessKind: 'read', - queryHash: createHash('sha256') - .update(`party-archive-invariants:${result.partyRef.resourceId}`) - .digest('hex'), - resultCount: 1, - servingModuleKey: 'party.registry', - targetModuleKey: 'party.registry', - targetResourceId: result.partyRef.resourceId, - targetResourceType: result.partyRef.resourceType, + const result = yield* resolvePartyLifecycle(persistenceResult, payload.partyRef.resourceId, { + code: 'party_lifecycle_conflict', + reason: 'The Party is already archived or its revision is stale', + requestedState: 'ARCHIVED', }); + yield* recordPartyInvariantAccess(context, result, 'party-archive-invariants'); const event = yield* context.addDomainEvent({ eventType: 'party.registry.party-archived.v1', payloadJson: { partyRef: result.partyRef }, @@ -162,10 +113,3 @@ export const archivePartyAction = defineAction( ), }), ); -export { createArchivePartyPartyRegistryPartyArchivedV1OutboxMessage } from './archive-party.party-registry-party-archived-v1.outbox-message.ts'; -export { - ArchivePartyPartyRegistryPartyArchivedV1OutboxPayloadSchema, - ArchivePartyPartyRegistryPartyArchivedV1OutboxProducerModuleKey, - ArchivePartyPartyRegistryPartyArchivedV1OutboxTopic, -} from './archive-party.party-registry-party-archived-v1.outbox-message.ts'; -export type { ArchivePartyPartyRegistryPartyArchivedV1OutboxPayload } from './archive-party.party-registry-party-archived-v1.outbox-message.ts'; diff --git a/app/verticals/party-registry/src/actions/archive-party.party-registry-party-archived-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/archive-party.party-registry-party-archived-v1.outbox-message.ts index 79b2a6cfa..4965cc77f 100644 --- a/app/verticals/party-registry/src/actions/archive-party.party-registry-party-archived-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/archive-party.party-registry-party-archived-v1.outbox-message.ts @@ -1,17 +1,12 @@ -/* eslint-disable unicorn/prefer-export-from -- Codesmith keeps stable action-local aliases for the public outbox contract. expires: 2026-12-31. */ import type { OutboxMessage } from '@app/core-runtime'; import { - OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-party-archived-v1'; import type { OutboxPayload } from '@app/party-registry/outbox/party-registry-party-archived-v1'; -export const ArchivePartyPartyRegistryPartyArchivedV1OutboxPayloadSchema = OutboxPayloadSchema; -export type ArchivePartyPartyRegistryPartyArchivedV1OutboxPayload = OutboxPayload; -export const ArchivePartyPartyRegistryPartyArchivedV1OutboxProducerModuleKey = - outboxProducerModuleKey; -export const ArchivePartyPartyRegistryPartyArchivedV1OutboxTopic = outboxTopic; +const ArchivePartyPartyRegistryPartyArchivedV1OutboxProducerModuleKey = outboxProducerModuleKey; +const ArchivePartyPartyRegistryPartyArchivedV1OutboxTopic = outboxTopic; export const createArchivePartyPartyRegistryPartyArchivedV1OutboxMessage = ( payload: OutboxPayload, diff --git a/app/verticals/party-registry/src/actions/archive-person-engagement.action.ts b/app/verticals/party-registry/src/actions/archive-person-engagement.action.ts index f101ce5c1..09716f826 100644 --- a/app/verticals/party-registry/src/actions/archive-person-engagement.action.ts +++ b/app/verticals/party-registry/src/actions/archive-person-engagement.action.ts @@ -24,9 +24,9 @@ import { transitionPersonEngagementProfile } from '../services/engagement-profil import type { LifecycleResult } from '../services/engagement-profile-persistence.service.ts'; import { resolveEngagementLifecycle } from './engagement-lifecycle.ts'; -export const ArchivePersonEngagementPayload = PersonEngagementLifecyclePayloadSchema; -export const ArchivePersonEngagementResult = PersonEngagementProfileSchema; -export const ArchivePersonEngagementError = Schema.Union([ +const ArchivePersonEngagementPayload = PersonEngagementLifecyclePayloadSchema; +const ArchivePersonEngagementResult = PersonEngagementProfileSchema; +const ArchivePersonEngagementError = Schema.Union([ EngagementProfileConflict, EngagementProfileNotFound, EngagementProfilePersistenceUnavailable, diff --git a/app/verticals/party-registry/src/actions/attach-organization-engagement.action.ts b/app/verticals/party-registry/src/actions/attach-organization-engagement.action.ts index 780b306ce..f0cc09c2d 100644 --- a/app/verticals/party-registry/src/actions/attach-organization-engagement.action.ts +++ b/app/verticals/party-registry/src/actions/attach-organization-engagement.action.ts @@ -25,9 +25,9 @@ import { validatePartyRegistryReferences, } from '../services/engagement-reference-validation.service.ts'; -export const AttachOrganizationEngagementPayload = AttachOrganizationEngagementPayloadSchema; -export const AttachOrganizationEngagementResult = OrganizationEngagementProfileSchema; -export const AttachOrganizationEngagementError = Schema.Union([ +const AttachOrganizationEngagementPayload = AttachOrganizationEngagementPayloadSchema; +const AttachOrganizationEngagementResult = OrganizationEngagementProfileSchema; +const AttachOrganizationEngagementError = Schema.Union([ EngagementProfileConflict, EngagementProfilePersistenceUnavailable, PartyRegistryReferenceUnavailable, diff --git a/app/verticals/party-registry/src/actions/attach-person-engagement.action.ts b/app/verticals/party-registry/src/actions/attach-person-engagement.action.ts index cd333ca14..5149d08de 100644 --- a/app/verticals/party-registry/src/actions/attach-person-engagement.action.ts +++ b/app/verticals/party-registry/src/actions/attach-person-engagement.action.ts @@ -25,9 +25,9 @@ import { validatePartyRegistryReferences, } from '../services/engagement-reference-validation.service.ts'; -export const AttachPersonEngagementPayload = AttachPersonEngagementPayloadSchema; -export const AttachPersonEngagementResult = PersonEngagementProfileSchema; -export const AttachPersonEngagementError = Schema.Union([ +const AttachPersonEngagementPayload = AttachPersonEngagementPayloadSchema; +const AttachPersonEngagementResult = PersonEngagementProfileSchema; +const AttachPersonEngagementError = Schema.Union([ EngagementProfileConflict, EngagementProfilePersistenceUnavailable, PartyRegistryReferenceUnavailable, diff --git a/app/verticals/party-registry/src/actions/confirm-duplicate-parties.action.ts b/app/verticals/party-registry/src/actions/confirm-duplicate-parties.action.ts index 80405aa91..4a0504c27 100644 --- a/app/verticals/party-registry/src/actions/confirm-duplicate-parties.action.ts +++ b/app/verticals/party-registry/src/actions/confirm-duplicate-parties.action.ts @@ -1,9 +1,7 @@ // @generated by OntOS Codesmith Action v1 // @ontos-action-owner party.registry // @ontos-action-slug confirm-duplicate-parties -import { createHash } from 'node:crypto'; import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; -import type { ActionHandlerContext } from '@app/core-runtime'; import { Effect, Schema } from 'effect'; import { PartyPersistenceUnavailable } from '../../shared/domain/identity-contracts.ts'; import { DuplicateCandidateConflict } from '../../shared/domain/matching-contracts.ts'; @@ -14,40 +12,10 @@ import { } from '../../shared/actions/confirm-duplicate-parties.ts'; import type { ConfirmDuplicatePartiesPayload } from '../../shared/actions/confirm-duplicate-parties.ts'; -export { - ConfirmDuplicatePartiesPayloadSchema, - ConfirmDuplicatePartiesResultSchema, -} from '../../shared/actions/confirm-duplicate-parties.ts'; -export type { - ConfirmDuplicatePartiesPayload, - ConfirmDuplicatePartiesResult, -} from '../../shared/actions/confirm-duplicate-parties.ts'; +import { handleDuplicateCaseResolution } from './duplicate-case-resolution-handler.ts'; + +export type { ConfirmDuplicatePartiesPayload } from '../../shared/actions/confirm-duplicate-parties.ts'; const ErrorSchema = Schema.Union([DuplicateCandidateConflict, PartyPersistenceUnavailable]); -interface Services { - readonly resolve: ( - payload: ConfirmDuplicatePartiesPayload, - invocationId: string, - ) => ReturnType; -} -const handle = ( - payload: ConfirmDuplicatePartiesPayload, - context: ActionHandlerContext>, Services>, -) => - context.services.resolve(payload, context.actionInvocationId).pipe( - Effect.tap((result) => - context.recordDataAccess({ - accessKind: 'read', - queryHash: createHash('sha256') - .update(`duplicate-case-invariants:${payload.caseRef.resourceId}`) - .digest('hex'), - resultCount: 1, - servingModuleKey: 'party.registry', - targetModuleKey: 'party.registry', - targetResourceId: result.caseRef.resourceId, - targetResourceType: result.caseRef.resourceType, - }), - ), - ); export const confirmDuplicatePartiesAction = defineAction( { accessEvidencePolicy: { @@ -74,7 +42,7 @@ export const confirmDuplicatePartiesAction = defineAction( schemaVersion: '1', tenantPermission: () => 'review_party_identity', }, - handle, + handleDuplicateCaseResolution, (transaction, scope) => Effect.succeed({ resolve: (payload: ConfirmDuplicatePartiesPayload, invocationId: string) => diff --git a/app/verticals/party-registry/src/actions/correct-party-fact.action.ts b/app/verticals/party-registry/src/actions/correct-party-fact.action.ts index 3204d151b..343ec5acf 100644 --- a/app/verticals/party-registry/src/actions/correct-party-fact.action.ts +++ b/app/verticals/party-registry/src/actions/correct-party-fact.action.ts @@ -20,14 +20,6 @@ import { CorrectPartyFactResultSchema, } from '../../shared/actions/correct-party-fact.ts'; -export { - CorrectPartyFactPayloadSchema, - CorrectPartyFactResultSchema, -} from '../../shared/actions/correct-party-fact.ts'; -export type { - CorrectPartyFactPayload, - CorrectPartyFactResult, -} from '../../shared/actions/correct-party-fact.ts'; const ErrorSchema = Schema.Union([ PartyCorrectionConflict, PartyPersistenceUnavailable, @@ -114,10 +106,3 @@ export const correctPartyFactAction = defineAction( }), }), ); -export { createCorrectPartyFactPartyRegistryPartyFactCorrectedV1OutboxMessage } from './correct-party-fact.party-registry-party-fact-corrected-v1.outbox-message.ts'; -export { - CorrectPartyFactPartyRegistryPartyFactCorrectedV1OutboxPayloadSchema, - CorrectPartyFactPartyRegistryPartyFactCorrectedV1OutboxProducerModuleKey, - CorrectPartyFactPartyRegistryPartyFactCorrectedV1OutboxTopic, -} from './correct-party-fact.party-registry-party-fact-corrected-v1.outbox-message.ts'; -export type { CorrectPartyFactPartyRegistryPartyFactCorrectedV1OutboxPayload } from './correct-party-fact.party-registry-party-fact-corrected-v1.outbox-message.ts'; diff --git a/app/verticals/party-registry/src/actions/correct-party-fact.party-registry-party-fact-corrected-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/correct-party-fact.party-registry-party-fact-corrected-v1.outbox-message.ts index eed9c000e..667204a05 100644 --- a/app/verticals/party-registry/src/actions/correct-party-fact.party-registry-party-fact-corrected-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/correct-party-fact.party-registry-party-fact-corrected-v1.outbox-message.ts @@ -1,18 +1,13 @@ -/* eslint-disable unicorn/prefer-export-from -- Codesmith keeps stable action-local aliases for the public outbox contract. expires: 2026-12-31. */ import type { OutboxMessage } from '@app/core-runtime'; import { - OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-party-fact-corrected-v1'; import type { OutboxPayload } from '@app/party-registry/outbox/party-registry-party-fact-corrected-v1'; -export const CorrectPartyFactPartyRegistryPartyFactCorrectedV1OutboxPayloadSchema = - OutboxPayloadSchema; -export type CorrectPartyFactPartyRegistryPartyFactCorrectedV1OutboxPayload = OutboxPayload; -export const CorrectPartyFactPartyRegistryPartyFactCorrectedV1OutboxProducerModuleKey = +const CorrectPartyFactPartyRegistryPartyFactCorrectedV1OutboxProducerModuleKey = outboxProducerModuleKey; -export const CorrectPartyFactPartyRegistryPartyFactCorrectedV1OutboxTopic = outboxTopic; +const CorrectPartyFactPartyRegistryPartyFactCorrectedV1OutboxTopic = outboxTopic; export const createCorrectPartyFactPartyRegistryPartyFactCorrectedV1OutboxMessage = ( payload: OutboxPayload, diff --git a/app/verticals/party-registry/src/actions/counterparty-create.action.ts b/app/verticals/party-registry/src/actions/counterparty-create.action.ts index 67fe006ec..bebb5c06c 100644 --- a/app/verticals/party-registry/src/actions/counterparty-create.action.ts +++ b/app/verticals/party-registry/src/actions/counterparty-create.action.ts @@ -33,12 +33,9 @@ export { CounterpartyCreatePayloadSchema, CounterpartyCreateResultSchema, } from '../../shared/actions/counterparty-create.ts'; -export type { - CounterpartyCreatePayload, - CounterpartyCreateResult, -} from '../../shared/actions/counterparty-create.ts'; +export type { CounterpartyCreatePayload } from '../../shared/actions/counterparty-create.ts'; -export const CounterpartyCreateError = Schema.Union([ +const CounterpartyCreateError = Schema.Union([ PartyAliasWriteRejected, CounterpartyEvidenceInsufficient, CounterpartyPartyArchived, @@ -209,11 +206,3 @@ export const counterpartyCreateAction = defineAction( }); }, ); - -// -export { CounterpartyCreatePartyRegistryCounterpartyCreatedV1OutboxPayloadSchema } from './counterparty-create.party-registry-counterparty-created-v1.outbox-message.ts'; -export { CounterpartyCreatePartyRegistryCounterpartyCreatedV1OutboxProducerModuleKey } from './counterparty-create.party-registry-counterparty-created-v1.outbox-message.ts'; -export { CounterpartyCreatePartyRegistryCounterpartyCreatedV1OutboxTopic } from './counterparty-create.party-registry-counterparty-created-v1.outbox-message.ts'; -export { createCounterpartyCreatePartyRegistryCounterpartyCreatedV1OutboxMessage } from './counterparty-create.party-registry-counterparty-created-v1.outbox-message.ts'; -export type { CounterpartyCreatePartyRegistryCounterpartyCreatedV1OutboxPayload } from './counterparty-create.party-registry-counterparty-created-v1.outbox-message.ts'; -// diff --git a/app/verticals/party-registry/src/actions/counterparty-create.party-registry-counterparty-created-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/counterparty-create.party-registry-counterparty-created-v1.outbox-message.ts index 45627e5da..a8718ddaf 100644 --- a/app/verticals/party-registry/src/actions/counterparty-create.party-registry-counterparty-created-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/counterparty-create.party-registry-counterparty-created-v1.outbox-message.ts @@ -1,18 +1,13 @@ -/* eslint-disable unicorn/prefer-export-from -- Codesmith keeps stable action-local aliases for the public outbox contract. expires: 2026-12-31. */ import type { OutboxMessage } from '@app/core-runtime'; import { - OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-counterparty-created-v1'; import type { OutboxPayload } from '@app/party-registry/outbox/party-registry-counterparty-created-v1'; -export const CounterpartyCreatePartyRegistryCounterpartyCreatedV1OutboxPayloadSchema = - OutboxPayloadSchema; -export type CounterpartyCreatePartyRegistryCounterpartyCreatedV1OutboxPayload = OutboxPayload; -export const CounterpartyCreatePartyRegistryCounterpartyCreatedV1OutboxProducerModuleKey = +const CounterpartyCreatePartyRegistryCounterpartyCreatedV1OutboxProducerModuleKey = outboxProducerModuleKey; -export const CounterpartyCreatePartyRegistryCounterpartyCreatedV1OutboxTopic = outboxTopic; +const CounterpartyCreatePartyRegistryCounterpartyCreatedV1OutboxTopic = outboxTopic; export const createCounterpartyCreatePartyRegistryCounterpartyCreatedV1OutboxMessage = ( payload: OutboxPayload, diff --git a/app/verticals/party-registry/src/actions/counterparty-role-add.action.ts b/app/verticals/party-registry/src/actions/counterparty-role-add.action.ts index b363a0243..ddbf6e738 100644 --- a/app/verticals/party-registry/src/actions/counterparty-role-add.action.ts +++ b/app/verticals/party-registry/src/actions/counterparty-role-add.action.ts @@ -35,12 +35,9 @@ export { CounterpartyRoleAddPayloadSchema, CounterpartyRoleAddResultSchema, } from '../../shared/actions/counterparty-role-add.ts'; -export type { - CounterpartyRoleAddPayload, - CounterpartyRoleAddResult, -} from '../../shared/actions/counterparty-role-add.ts'; +export type { CounterpartyRoleAddPayload } from '../../shared/actions/counterparty-role-add.ts'; -export const CounterpartyRoleAddError = Schema.Union([ +const CounterpartyRoleAddError = Schema.Union([ CounterpartyEvidenceInsufficient, CounterpartyNotFound, CounterpartyPartyArchived, @@ -216,11 +213,3 @@ export const counterpartyRoleAddAction = defineAction( }); }, ); - -// -export { CounterpartyRoleAddPartyRegistryCounterpartyRoleAddedV1OutboxPayloadSchema } from './counterparty-role-add.party-registry-counterparty-role-added-v1.outbox-message.ts'; -export { CounterpartyRoleAddPartyRegistryCounterpartyRoleAddedV1OutboxProducerModuleKey } from './counterparty-role-add.party-registry-counterparty-role-added-v1.outbox-message.ts'; -export { CounterpartyRoleAddPartyRegistryCounterpartyRoleAddedV1OutboxTopic } from './counterparty-role-add.party-registry-counterparty-role-added-v1.outbox-message.ts'; -export { createCounterpartyRoleAddPartyRegistryCounterpartyRoleAddedV1OutboxMessage } from './counterparty-role-add.party-registry-counterparty-role-added-v1.outbox-message.ts'; -export type { CounterpartyRoleAddPartyRegistryCounterpartyRoleAddedV1OutboxPayload } from './counterparty-role-add.party-registry-counterparty-role-added-v1.outbox-message.ts'; -// diff --git a/app/verticals/party-registry/src/actions/counterparty-role-add.party-registry-counterparty-role-added-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/counterparty-role-add.party-registry-counterparty-role-added-v1.outbox-message.ts index 925fcaeb4..32f6c03bb 100644 --- a/app/verticals/party-registry/src/actions/counterparty-role-add.party-registry-counterparty-role-added-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/counterparty-role-add.party-registry-counterparty-role-added-v1.outbox-message.ts @@ -1,18 +1,13 @@ -/* eslint-disable unicorn/prefer-export-from -- Codesmith keeps stable action-local aliases for the public outbox contract. expires: 2026-12-31. */ import type { OutboxMessage } from '@app/core-runtime'; import { - OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-counterparty-role-added-v1'; import type { OutboxPayload } from '@app/party-registry/outbox/party-registry-counterparty-role-added-v1'; -export const CounterpartyRoleAddPartyRegistryCounterpartyRoleAddedV1OutboxPayloadSchema = - OutboxPayloadSchema; -export type CounterpartyRoleAddPartyRegistryCounterpartyRoleAddedV1OutboxPayload = OutboxPayload; -export const CounterpartyRoleAddPartyRegistryCounterpartyRoleAddedV1OutboxProducerModuleKey = +const CounterpartyRoleAddPartyRegistryCounterpartyRoleAddedV1OutboxProducerModuleKey = outboxProducerModuleKey; -export const CounterpartyRoleAddPartyRegistryCounterpartyRoleAddedV1OutboxTopic = outboxTopic; +const CounterpartyRoleAddPartyRegistryCounterpartyRoleAddedV1OutboxTopic = outboxTopic; export const createCounterpartyRoleAddPartyRegistryCounterpartyRoleAddedV1OutboxMessage = ( payload: OutboxPayload, diff --git a/app/verticals/party-registry/src/actions/counterparty-role-end.action.ts b/app/verticals/party-registry/src/actions/counterparty-role-end.action.ts index e459fecf1..316766006 100644 --- a/app/verticals/party-registry/src/actions/counterparty-role-end.action.ts +++ b/app/verticals/party-registry/src/actions/counterparty-role-end.action.ts @@ -34,12 +34,9 @@ export { CounterpartyRoleEndPayloadSchema, CounterpartyRoleEndResultSchema, } from '../../shared/actions/counterparty-role-end.ts'; -export type { - CounterpartyRoleEndPayload, - CounterpartyRoleEndResult, -} from '../../shared/actions/counterparty-role-end.ts'; +export type { CounterpartyRoleEndPayload } from '../../shared/actions/counterparty-role-end.ts'; -export const CounterpartyRoleEndError = Schema.Union([ +const CounterpartyRoleEndError = Schema.Union([ CounterpartyEvidenceInsufficient, CounterpartyNotFound, CounterpartyPersistenceUnavailable, @@ -235,11 +232,3 @@ export const counterpartyRoleEndAction = defineAction( }); }, ); - -// -export { CounterpartyRoleEndPartyRegistryCounterpartyRoleEndedV1OutboxPayloadSchema } from './counterparty-role-end.party-registry-counterparty-role-ended-v1.outbox-message.ts'; -export { CounterpartyRoleEndPartyRegistryCounterpartyRoleEndedV1OutboxProducerModuleKey } from './counterparty-role-end.party-registry-counterparty-role-ended-v1.outbox-message.ts'; -export { CounterpartyRoleEndPartyRegistryCounterpartyRoleEndedV1OutboxTopic } from './counterparty-role-end.party-registry-counterparty-role-ended-v1.outbox-message.ts'; -export { createCounterpartyRoleEndPartyRegistryCounterpartyRoleEndedV1OutboxMessage } from './counterparty-role-end.party-registry-counterparty-role-ended-v1.outbox-message.ts'; -export type { CounterpartyRoleEndPartyRegistryCounterpartyRoleEndedV1OutboxPayload } from './counterparty-role-end.party-registry-counterparty-role-ended-v1.outbox-message.ts'; -// diff --git a/app/verticals/party-registry/src/actions/counterparty-role-end.party-registry-counterparty-role-ended-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/counterparty-role-end.party-registry-counterparty-role-ended-v1.outbox-message.ts index 68a5a1431..68976958d 100644 --- a/app/verticals/party-registry/src/actions/counterparty-role-end.party-registry-counterparty-role-ended-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/counterparty-role-end.party-registry-counterparty-role-ended-v1.outbox-message.ts @@ -1,18 +1,13 @@ -/* eslint-disable unicorn/prefer-export-from -- Codesmith keeps stable action-local aliases for the public outbox contract. expires: 2026-12-31. */ import type { OutboxMessage } from '@app/core-runtime'; import { - OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-counterparty-role-ended-v1'; import type { OutboxPayload } from '@app/party-registry/outbox/party-registry-counterparty-role-ended-v1'; -export const CounterpartyRoleEndPartyRegistryCounterpartyRoleEndedV1OutboxPayloadSchema = - OutboxPayloadSchema; -export type CounterpartyRoleEndPartyRegistryCounterpartyRoleEndedV1OutboxPayload = OutboxPayload; -export const CounterpartyRoleEndPartyRegistryCounterpartyRoleEndedV1OutboxProducerModuleKey = +const CounterpartyRoleEndPartyRegistryCounterpartyRoleEndedV1OutboxProducerModuleKey = outboxProducerModuleKey; -export const CounterpartyRoleEndPartyRegistryCounterpartyRoleEndedV1OutboxTopic = outboxTopic; +const CounterpartyRoleEndPartyRegistryCounterpartyRoleEndedV1OutboxTopic = outboxTopic; export const createCounterpartyRoleEndPartyRegistryCounterpartyRoleEndedV1OutboxMessage = ( payload: OutboxPayload, diff --git a/app/verticals/party-registry/src/actions/create-party-relationship.action.ts b/app/verticals/party-registry/src/actions/create-party-relationship.action.ts index 49075f0df..921a4d59c 100644 --- a/app/verticals/party-registry/src/actions/create-party-relationship.action.ts +++ b/app/verticals/party-registry/src/actions/create-party-relationship.action.ts @@ -13,7 +13,6 @@ import { } from '../../shared/domain/relationship-contract.ts'; import type { CreatePartyRelationshipPayload as Payload, - CreatePartyRelationshipResult as Result, PartyRelationshipLifecycleEventPayload, } from '../../shared/domain/relationship-contract.ts'; import { createPartyRelationshipRecord } from '../services/party-relationship-persistence.service.ts'; @@ -23,9 +22,6 @@ import type { } from '../services/party-relationship-persistence.service.ts'; import { createCreatePartyRelationshipPartyRegistryRelationshipCreatedV1OutboxMessage } from './create-party-relationship.party-registry-relationship-created-v1.outbox-message.ts'; -export type CreatePartyRelationshipPayload = Payload; -export type CreatePartyRelationshipResult = Result; - const eventPayload = ( result: RelationshipCreateResult, ): PartyRelationshipLifecycleEventPayload => ({ @@ -132,11 +128,3 @@ export const createPartyRelationshipAction = defineAction( ), }), ); - -// -export { createCreatePartyRelationshipPartyRegistryRelationshipCreatedV1OutboxMessage } from './create-party-relationship.party-registry-relationship-created-v1.outbox-message.ts'; -export { CreatePartyRelationshipPartyRegistryRelationshipCreatedV1OutboxPayloadSchema } from './create-party-relationship.party-registry-relationship-created-v1.outbox-message.ts'; -export { CreatePartyRelationshipPartyRegistryRelationshipCreatedV1OutboxProducerModuleKey } from './create-party-relationship.party-registry-relationship-created-v1.outbox-message.ts'; -export { CreatePartyRelationshipPartyRegistryRelationshipCreatedV1OutboxTopic } from './create-party-relationship.party-registry-relationship-created-v1.outbox-message.ts'; -export type { CreatePartyRelationshipPartyRegistryRelationshipCreatedV1OutboxPayload } from './create-party-relationship.party-registry-relationship-created-v1.outbox-message.ts'; -// diff --git a/app/verticals/party-registry/src/actions/create-party-relationship.party-registry-relationship-created-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/create-party-relationship.party-registry-relationship-created-v1.outbox-message.ts index ec15e80fb..940bb48b7 100644 --- a/app/verticals/party-registry/src/actions/create-party-relationship.party-registry-relationship-created-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/create-party-relationship.party-registry-relationship-created-v1.outbox-message.ts @@ -1,21 +1,13 @@ -/* eslint-disable unicorn/prefer-export-from -- Generated action aliases intentionally bind stable action-specific names. expires: 2026-12-31. */ import type { OutboxMessage } from '@app/core-runtime'; import { - OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-relationship-created-v1'; -import type { - OutboxPayload, - OutboxPayloadJson, -} from '@app/party-registry/outbox/party-registry-relationship-created-v1'; +import type { OutboxPayloadJson } from '@app/party-registry/outbox/party-registry-relationship-created-v1'; -export const CreatePartyRelationshipPartyRegistryRelationshipCreatedV1OutboxPayloadSchema = - OutboxPayloadSchema; -export type CreatePartyRelationshipPartyRegistryRelationshipCreatedV1OutboxPayload = OutboxPayload; -export const CreatePartyRelationshipPartyRegistryRelationshipCreatedV1OutboxProducerModuleKey = +const CreatePartyRelationshipPartyRegistryRelationshipCreatedV1OutboxProducerModuleKey = outboxProducerModuleKey; -export const CreatePartyRelationshipPartyRegistryRelationshipCreatedV1OutboxTopic = outboxTopic; +const CreatePartyRelationshipPartyRegistryRelationshipCreatedV1OutboxTopic = outboxTopic; export const createCreatePartyRelationshipPartyRegistryRelationshipCreatedV1OutboxMessage = ( payload: OutboxPayloadJson, diff --git a/app/verticals/party-registry/src/actions/create-party.action.ts b/app/verticals/party-registry/src/actions/create-party.action.ts index 470be3edb..fd6142f5a 100644 --- a/app/verticals/party-registry/src/actions/create-party.action.ts +++ b/app/verticals/party-registry/src/actions/create-party.action.ts @@ -24,12 +24,8 @@ import { } from '../../shared/actions/create-party.ts'; import type { CreatePartyPayload } from '../../shared/actions/create-party.ts'; -export { - CreatePartyPayloadSchema, - CreatePartyResultSchema, -} from '../../shared/actions/create-party.ts'; -export type { CreatePartyPayload, CreatePartyResult } from '../../shared/actions/create-party.ts'; -export const CreatePartyErrorSchema = Schema.Union([ +export type { CreatePartyPayload } from '../../shared/actions/create-party.ts'; +const CreatePartyErrorSchema = Schema.Union([ PartyEvidenceInsufficient, PartyPersistenceUnavailable, ]); @@ -149,11 +145,3 @@ export const createPartyAction = defineAction( }), }), ); - -export { createCreatePartyPartyRegistryPartyCreatedV1OutboxMessage } from './create-party.party-registry-party-created-v1.outbox-message.ts'; -export { - CreatePartyPartyRegistryPartyCreatedV1OutboxPayloadSchema, - CreatePartyPartyRegistryPartyCreatedV1OutboxProducerModuleKey, - CreatePartyPartyRegistryPartyCreatedV1OutboxTopic, -} from './create-party.party-registry-party-created-v1.outbox-message.ts'; -export type { CreatePartyPartyRegistryPartyCreatedV1OutboxPayload } from './create-party.party-registry-party-created-v1.outbox-message.ts'; diff --git a/app/verticals/party-registry/src/actions/create-party.party-registry-party-created-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/create-party.party-registry-party-created-v1.outbox-message.ts index d2b9eff7c..4aaab4167 100644 --- a/app/verticals/party-registry/src/actions/create-party.party-registry-party-created-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/create-party.party-registry-party-created-v1.outbox-message.ts @@ -1,17 +1,12 @@ -/* eslint-disable unicorn/prefer-export-from -- Codesmith keeps stable action-local aliases for the public outbox contract. expires: 2026-12-31. */ import type { OutboxMessage } from '@app/core-runtime'; import { - OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-party-created-v1'; import type { OutboxPayload } from '@app/party-registry/outbox/party-registry-party-created-v1'; -export const CreatePartyPartyRegistryPartyCreatedV1OutboxPayloadSchema = OutboxPayloadSchema; -export type CreatePartyPartyRegistryPartyCreatedV1OutboxPayload = OutboxPayload; -export const CreatePartyPartyRegistryPartyCreatedV1OutboxProducerModuleKey = - outboxProducerModuleKey; -export const CreatePartyPartyRegistryPartyCreatedV1OutboxTopic = outboxTopic; +const CreatePartyPartyRegistryPartyCreatedV1OutboxProducerModuleKey = outboxProducerModuleKey; +const CreatePartyPartyRegistryPartyCreatedV1OutboxTopic = outboxTopic; export const createCreatePartyPartyRegistryPartyCreatedV1OutboxMessage = ( payload: OutboxPayload, diff --git a/app/verticals/party-registry/src/actions/dismiss-duplicate-candidate.action.ts b/app/verticals/party-registry/src/actions/dismiss-duplicate-candidate.action.ts index 7dc944450..d8ed35484 100644 --- a/app/verticals/party-registry/src/actions/dismiss-duplicate-candidate.action.ts +++ b/app/verticals/party-registry/src/actions/dismiss-duplicate-candidate.action.ts @@ -1,9 +1,7 @@ // @generated by OntOS Codesmith Action v1 // @ontos-action-owner party.registry // @ontos-action-slug dismiss-duplicate-candidate -import { createHash } from 'node:crypto'; import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; -import type { ActionHandlerContext } from '@app/core-runtime'; import { Effect, Schema } from 'effect'; import { PartyPersistenceUnavailable } from '../../shared/domain/identity-contracts.ts'; import { DuplicateCandidateConflict } from '../../shared/domain/matching-contracts.ts'; @@ -14,40 +12,10 @@ import { } from '../../shared/actions/dismiss-duplicate-candidate.ts'; import type { DismissDuplicateCandidatePayload } from '../../shared/actions/dismiss-duplicate-candidate.ts'; -export { - DismissDuplicateCandidatePayloadSchema, - DismissDuplicateCandidateResultSchema, -} from '../../shared/actions/dismiss-duplicate-candidate.ts'; -export type { - DismissDuplicateCandidatePayload, - DismissDuplicateCandidateResult, -} from '../../shared/actions/dismiss-duplicate-candidate.ts'; +import { handleDuplicateCaseResolution } from './duplicate-case-resolution-handler.ts'; + +export type { DismissDuplicateCandidatePayload } from '../../shared/actions/dismiss-duplicate-candidate.ts'; const ErrorSchema = Schema.Union([DuplicateCandidateConflict, PartyPersistenceUnavailable]); -interface Services { - readonly resolve: ( - payload: DismissDuplicateCandidatePayload, - invocationId: string, - ) => ReturnType; -} -const handle = ( - payload: DismissDuplicateCandidatePayload, - context: ActionHandlerContext>, Services>, -) => - context.services.resolve(payload, context.actionInvocationId).pipe( - Effect.tap((result) => - context.recordDataAccess({ - accessKind: 'read', - queryHash: createHash('sha256') - .update(`duplicate-case-invariants:${payload.caseRef.resourceId}`) - .digest('hex'), - resultCount: 1, - servingModuleKey: 'party.registry', - targetModuleKey: 'party.registry', - targetResourceId: result.caseRef.resourceId, - targetResourceType: result.caseRef.resourceType, - }), - ), - ); export const dismissDuplicateCandidateAction = defineAction( { accessEvidencePolicy: { @@ -74,7 +42,7 @@ export const dismissDuplicateCandidateAction = defineAction( schemaVersion: '1', tenantPermission: () => 'review_party_identity', }, - handle, + handleDuplicateCaseResolution, (transaction, scope) => Effect.succeed({ resolve: (payload: DismissDuplicateCandidatePayload, invocationId: string) => diff --git a/app/verticals/party-registry/src/actions/duplicate-case-resolution-handler.ts b/app/verticals/party-registry/src/actions/duplicate-case-resolution-handler.ts new file mode 100644 index 000000000..2f05ef3be --- /dev/null +++ b/app/verticals/party-registry/src/actions/duplicate-case-resolution-handler.ts @@ -0,0 +1,31 @@ +import { createHash } from 'node:crypto'; +import type { ActionHandlerContext } from '@app/core-runtime'; +import { Effect } from 'effect'; +import type { ConfirmDuplicatePartiesPayload } from '../../shared/actions/confirm-duplicate-parties.ts'; +import type { transitionDuplicateCandidateCase } from '../services/party-matching-persistence.service.ts'; + +interface Services { + readonly resolve: ( + payload: ConfirmDuplicatePartiesPayload, + invocationId: string, + ) => ReturnType; +} +export const handleDuplicateCaseResolution = ( + payload: ConfirmDuplicatePartiesPayload, + context: ActionHandlerContext>, Services>, +) => + context.services.resolve(payload, context.actionInvocationId).pipe( + Effect.tap((result) => + context.recordDataAccess({ + accessKind: 'read', + queryHash: createHash('sha256') + .update(`duplicate-case-invariants:${payload.caseRef.resourceId}`) + .digest('hex'), + resultCount: 1, + servingModuleKey: 'party.registry', + targetModuleKey: 'party.registry', + targetResourceId: result.caseRef.resourceId, + targetResourceType: result.caseRef.resourceType, + }), + ), + ); diff --git a/app/verticals/party-registry/src/actions/end-contact-point.action.ts b/app/verticals/party-registry/src/actions/end-contact-point.action.ts index 7dfb9233a..eac3b6f19 100644 --- a/app/verticals/party-registry/src/actions/end-contact-point.action.ts +++ b/app/verticals/party-registry/src/actions/end-contact-point.action.ts @@ -29,16 +29,10 @@ import { } from '../../shared/actions/end-contact-point.ts'; import type { EndContactPointPayload } from '../../shared/actions/end-contact-point.ts'; -export { - EndContactPointPayloadSchema, - EndContactPointResultSchema, -} from '../../shared/actions/end-contact-point.ts'; -export type { - EndContactPointPayload, - EndContactPointResult, -} from '../../shared/actions/end-contact-point.ts'; +export { EndContactPointPayloadSchema } from '../../shared/actions/end-contact-point.ts'; +export type { EndContactPointPayload } from '../../shared/actions/end-contact-point.ts'; -export const EndContactPointErrorSchema = Schema.Union([ +const EndContactPointErrorSchema = Schema.Union([ PartyContactPointNotFound, PartyContactPointInvalid, PartyContactPointLifecycleConflict, @@ -152,11 +146,3 @@ export const endContactPointAction = defineAction( end: (command: EndContactPointCommand) => endContactPointRecord(transaction, scope, command), }), ); - -// -export { createEndContactPointPartyRegistryContactPointEndedV1OutboxMessage } from './end-contact-point.party-registry-contact-point-ended-v1.outbox-message.ts'; -export { EndContactPointPartyRegistryContactPointEndedV1OutboxPayloadSchema } from './end-contact-point.party-registry-contact-point-ended-v1.outbox-message.ts'; -export { EndContactPointPartyRegistryContactPointEndedV1OutboxProducerModuleKey } from './end-contact-point.party-registry-contact-point-ended-v1.outbox-message.ts'; -export { EndContactPointPartyRegistryContactPointEndedV1OutboxTopic } from './end-contact-point.party-registry-contact-point-ended-v1.outbox-message.ts'; -export type { EndContactPointPartyRegistryContactPointEndedV1OutboxPayload } from './end-contact-point.party-registry-contact-point-ended-v1.outbox-message.ts'; -// diff --git a/app/verticals/party-registry/src/actions/end-contact-point.party-registry-contact-point-ended-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/end-contact-point.party-registry-contact-point-ended-v1.outbox-message.ts index dbdda5d16..7941d45f8 100644 --- a/app/verticals/party-registry/src/actions/end-contact-point.party-registry-contact-point-ended-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/end-contact-point.party-registry-contact-point-ended-v1.outbox-message.ts @@ -1,23 +1,14 @@ -/* eslint-disable unicorn/prefer-export-from -- Codesmith keeps stable action-local aliases for the public outbox contract. expires: 2026-12-31. */ import type { OutboxMessage } from '@app/core-runtime'; import { - OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-contact-point-ended-v1'; import type { OutboxPayload } from '@app/party-registry/outbox/party-registry-contact-point-ended-v1'; -export const EndContactPointPartyRegistryContactPointEndedV1OutboxPayloadSchema = - OutboxPayloadSchema; -export type EndContactPointPartyRegistryContactPointEndedV1OutboxPayload = OutboxPayload; -export const EndContactPointPartyRegistryContactPointEndedV1OutboxProducerModuleKey = - outboxProducerModuleKey; -export const EndContactPointPartyRegistryContactPointEndedV1OutboxTopic = outboxTopic; - export const createEndContactPointPartyRegistryContactPointEndedV1OutboxMessage = ( payload: OutboxPayload, ): OutboxMessage => ({ payloadJson: payload, - producerModuleKey: EndContactPointPartyRegistryContactPointEndedV1OutboxProducerModuleKey, - topic: EndContactPointPartyRegistryContactPointEndedV1OutboxTopic, + producerModuleKey: outboxProducerModuleKey, + topic: outboxTopic, }); diff --git a/app/verticals/party-registry/src/actions/end-party-official-identifier.action.ts b/app/verticals/party-registry/src/actions/end-party-official-identifier.action.ts index c5a699a74..38f081d31 100644 --- a/app/verticals/party-registry/src/actions/end-party-official-identifier.action.ts +++ b/app/verticals/party-registry/src/actions/end-party-official-identifier.action.ts @@ -37,14 +37,7 @@ import type { EndPartyOfficialIdentifierResult, } from '../../shared/actions/end-party-official-identifier.ts'; -export { - EndPartyOfficialIdentifierPayloadSchema, - EndPartyOfficialIdentifierResultSchema, -} from '../../shared/actions/end-party-official-identifier.ts'; -export type { - EndPartyOfficialIdentifierPayload, - EndPartyOfficialIdentifierResult, -} from '../../shared/actions/end-party-official-identifier.ts'; +export type { EndPartyOfficialIdentifierPayload } from '../../shared/actions/end-party-official-identifier.ts'; const ErrorSchema = Schema.Union([ PartyNotFound, OfficialIdentifierClaimConflict, @@ -183,10 +176,3 @@ export const endPartyOfficialIdentifierAction = defineAction( (transaction, scope) => Effect.succeed({ end: makeEndService(transaction, scope.tenantId) } satisfies Services), ); -export { createEndPartyOfficialIdentifierPartyRegistryOfficialIdentifierEndedV1OutboxMessage } from './end-party-official-identifier.party-registry-official-identifier-ended-v1.outbox-message.ts'; -export { - EndPartyOfficialIdentifierPartyRegistryOfficialIdentifierEndedV1OutboxPayloadSchema, - EndPartyOfficialIdentifierPartyRegistryOfficialIdentifierEndedV1OutboxProducerModuleKey, - EndPartyOfficialIdentifierPartyRegistryOfficialIdentifierEndedV1OutboxTopic, -} from './end-party-official-identifier.party-registry-official-identifier-ended-v1.outbox-message.ts'; -export type { EndPartyOfficialIdentifierPartyRegistryOfficialIdentifierEndedV1OutboxPayload } from './end-party-official-identifier.party-registry-official-identifier-ended-v1.outbox-message.ts'; diff --git a/app/verticals/party-registry/src/actions/end-party-official-identifier.party-registry-official-identifier-ended-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/end-party-official-identifier.party-registry-official-identifier-ended-v1.outbox-message.ts index c1f81cd51..e08e49534 100644 --- a/app/verticals/party-registry/src/actions/end-party-official-identifier.party-registry-official-identifier-ended-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/end-party-official-identifier.party-registry-official-identifier-ended-v1.outbox-message.ts @@ -1,26 +1,14 @@ -/* eslint-disable unicorn/prefer-export-from -- Codesmith keeps stable action-local aliases for the public outbox contract. expires: 2026-12-31. */ import type { OutboxMessage } from '@app/core-runtime'; import { - OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-official-identifier-ended-v1'; import type { OutboxPayload } from '@app/party-registry/outbox/party-registry-official-identifier-ended-v1'; -export const EndPartyOfficialIdentifierPartyRegistryOfficialIdentifierEndedV1OutboxPayloadSchema = - OutboxPayloadSchema; -export type EndPartyOfficialIdentifierPartyRegistryOfficialIdentifierEndedV1OutboxPayload = - OutboxPayload; -export const EndPartyOfficialIdentifierPartyRegistryOfficialIdentifierEndedV1OutboxProducerModuleKey = - outboxProducerModuleKey; -export const EndPartyOfficialIdentifierPartyRegistryOfficialIdentifierEndedV1OutboxTopic = - outboxTopic; - export const createEndPartyOfficialIdentifierPartyRegistryOfficialIdentifierEndedV1OutboxMessage = ( payload: OutboxPayload, ): OutboxMessage => ({ payloadJson: payload, - producerModuleKey: - EndPartyOfficialIdentifierPartyRegistryOfficialIdentifierEndedV1OutboxProducerModuleKey, - topic: EndPartyOfficialIdentifierPartyRegistryOfficialIdentifierEndedV1OutboxTopic, + producerModuleKey: outboxProducerModuleKey, + topic: outboxTopic, }); diff --git a/app/verticals/party-registry/src/actions/end-party-relationship.action.ts b/app/verticals/party-registry/src/actions/end-party-relationship.action.ts index ca3ee9d0b..7c0fe10f2 100644 --- a/app/verticals/party-registry/src/actions/end-party-relationship.action.ts +++ b/app/verticals/party-registry/src/actions/end-party-relationship.action.ts @@ -14,7 +14,6 @@ import { PartyRelationshipMutationErrorSchema, } from '../../shared/domain/relationship-contract.ts'; import type { - ChangePartyRelationshipResult as Result, EndPartyRelationshipPayload as Payload, PartyRelationshipLifecycleEventPayload, } from '../../shared/domain/relationship-contract.ts'; @@ -25,9 +24,7 @@ import type { } from '../services/party-relationship-persistence.service.ts'; import { createEndPartyRelationshipPartyRegistryRelationshipEndedV1OutboxMessage } from './end-party-relationship.party-registry-relationship-ended-v1.outbox-message.ts'; -export type EndPartyRelationshipPayload = Payload; -export const EndPartyRelationshipResultSchema = ChangePartyRelationshipResultSchema; -export type EndPartyRelationshipResult = Result; +const EndPartyRelationshipResultSchema = ChangePartyRelationshipResultSchema; const eventPayload = ( result: RelationshipChangeResult, @@ -144,11 +141,3 @@ export const endPartyRelationshipAction = defineAction( ), }), ); - -// -export { createEndPartyRelationshipPartyRegistryRelationshipEndedV1OutboxMessage } from './end-party-relationship.party-registry-relationship-ended-v1.outbox-message.ts'; -export { EndPartyRelationshipPartyRegistryRelationshipEndedV1OutboxPayloadSchema } from './end-party-relationship.party-registry-relationship-ended-v1.outbox-message.ts'; -export { EndPartyRelationshipPartyRegistryRelationshipEndedV1OutboxProducerModuleKey } from './end-party-relationship.party-registry-relationship-ended-v1.outbox-message.ts'; -export { EndPartyRelationshipPartyRegistryRelationshipEndedV1OutboxTopic } from './end-party-relationship.party-registry-relationship-ended-v1.outbox-message.ts'; -export type { EndPartyRelationshipPartyRegistryRelationshipEndedV1OutboxPayload } from './end-party-relationship.party-registry-relationship-ended-v1.outbox-message.ts'; -// diff --git a/app/verticals/party-registry/src/actions/end-party-relationship.party-registry-relationship-ended-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/end-party-relationship.party-registry-relationship-ended-v1.outbox-message.ts index b307733dc..e71162c67 100644 --- a/app/verticals/party-registry/src/actions/end-party-relationship.party-registry-relationship-ended-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/end-party-relationship.party-registry-relationship-ended-v1.outbox-message.ts @@ -1,21 +1,13 @@ -/* eslint-disable unicorn/prefer-export-from -- Generated action aliases intentionally bind stable action-specific names. expires: 2026-12-31. */ import type { OutboxMessage } from '@app/core-runtime'; import { - OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-relationship-ended-v1'; -import type { - OutboxPayload, - OutboxPayloadJson, -} from '@app/party-registry/outbox/party-registry-relationship-ended-v1'; +import type { OutboxPayloadJson } from '@app/party-registry/outbox/party-registry-relationship-ended-v1'; -export const EndPartyRelationshipPartyRegistryRelationshipEndedV1OutboxPayloadSchema = - OutboxPayloadSchema; -export type EndPartyRelationshipPartyRegistryRelationshipEndedV1OutboxPayload = OutboxPayload; -export const EndPartyRelationshipPartyRegistryRelationshipEndedV1OutboxProducerModuleKey = +const EndPartyRelationshipPartyRegistryRelationshipEndedV1OutboxProducerModuleKey = outboxProducerModuleKey; -export const EndPartyRelationshipPartyRegistryRelationshipEndedV1OutboxTopic = outboxTopic; +const EndPartyRelationshipPartyRegistryRelationshipEndedV1OutboxTopic = outboxTopic; export const createEndPartyRelationshipPartyRegistryRelationshipEndedV1OutboxMessage = ( payload: OutboxPayloadJson, diff --git a/app/verticals/party-registry/src/actions/mark-duplicate-candidate-needs-evidence.action.ts b/app/verticals/party-registry/src/actions/mark-duplicate-candidate-needs-evidence.action.ts index ee243f1c2..6a08da97a 100644 --- a/app/verticals/party-registry/src/actions/mark-duplicate-candidate-needs-evidence.action.ts +++ b/app/verticals/party-registry/src/actions/mark-duplicate-candidate-needs-evidence.action.ts @@ -1,9 +1,7 @@ // @generated by OntOS Codesmith Action v1 // @ontos-action-owner party.registry // @ontos-action-slug mark-duplicate-candidate-needs-evidence -import { createHash } from 'node:crypto'; import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; -import type { ActionHandlerContext } from '@app/core-runtime'; import { Effect, Schema } from 'effect'; import { PartyPersistenceUnavailable } from '../../shared/domain/identity-contracts.ts'; import { DuplicateCandidateConflict } from '../../shared/domain/matching-contracts.ts'; @@ -14,40 +12,10 @@ import { } from '../../shared/actions/mark-duplicate-candidate-needs-evidence.ts'; import type { MarkDuplicateCandidateNeedsEvidencePayload } from '../../shared/actions/mark-duplicate-candidate-needs-evidence.ts'; -export { - MarkDuplicateCandidateNeedsEvidencePayloadSchema, - MarkDuplicateCandidateNeedsEvidenceResultSchema, -} from '../../shared/actions/mark-duplicate-candidate-needs-evidence.ts'; -export type { - MarkDuplicateCandidateNeedsEvidencePayload, - MarkDuplicateCandidateNeedsEvidenceResult, -} from '../../shared/actions/mark-duplicate-candidate-needs-evidence.ts'; +import { handleDuplicateCaseResolution } from './duplicate-case-resolution-handler.ts'; + +export type { MarkDuplicateCandidateNeedsEvidencePayload } from '../../shared/actions/mark-duplicate-candidate-needs-evidence.ts'; const ErrorSchema = Schema.Union([DuplicateCandidateConflict, PartyPersistenceUnavailable]); -interface Services { - readonly resolve: ( - payload: MarkDuplicateCandidateNeedsEvidencePayload, - invocationId: string, - ) => ReturnType; -} -const handle = ( - payload: MarkDuplicateCandidateNeedsEvidencePayload, - context: ActionHandlerContext>, Services>, -) => - context.services.resolve(payload, context.actionInvocationId).pipe( - Effect.tap((result) => - context.recordDataAccess({ - accessKind: 'read', - queryHash: createHash('sha256') - .update(`duplicate-case-invariants:${payload.caseRef.resourceId}`) - .digest('hex'), - resultCount: 1, - servingModuleKey: 'party.registry', - targetModuleKey: 'party.registry', - targetResourceId: result.caseRef.resourceId, - targetResourceType: result.caseRef.resourceType, - }), - ), - ); export const markDuplicateCandidateNeedsEvidenceAction = defineAction( { accessEvidencePolicy: { @@ -74,7 +42,7 @@ export const markDuplicateCandidateNeedsEvidenceAction = defineAction( schemaVersion: '1', tenantPermission: () => 'review_party_identity', }, - handle, + handleDuplicateCaseResolution, (transaction, scope) => Effect.succeed({ resolve: (payload: MarkDuplicateCandidateNeedsEvidencePayload, invocationId: string) => diff --git a/app/verticals/party-registry/src/actions/match-party.action.ts b/app/verticals/party-registry/src/actions/match-party.action.ts index af1f763a0..892406200 100644 --- a/app/verticals/party-registry/src/actions/match-party.action.ts +++ b/app/verticals/party-registry/src/actions/match-party.action.ts @@ -21,11 +21,7 @@ import { } from '../../shared/actions/match-party.ts'; import type { MatchPartyPayload } from '../../shared/actions/match-party.ts'; -export { - MatchPartyPayloadSchema, - MatchPartyResultSchema, -} from '../../shared/actions/match-party.ts'; -export type { MatchPartyPayload, MatchPartyResult } from '../../shared/actions/match-party.ts'; +export type { MatchPartyPayload } from '../../shared/actions/match-party.ts'; interface Services { readonly match: ( diff --git a/app/verticals/party-registry/src/actions/party-lifecycle-action-helpers.ts b/app/verticals/party-registry/src/actions/party-lifecycle-action-helpers.ts new file mode 100644 index 000000000..b385a8dd8 --- /dev/null +++ b/app/verticals/party-registry/src/actions/party-lifecycle-action-helpers.ts @@ -0,0 +1,65 @@ +import { createHash } from 'node:crypto'; +import type { ActionHandlerContext } from '@app/core-runtime'; +import { Effect, Match, Schema } from 'effect'; +import { + partyIdFromString, + PartyLifecycleConflict, + PartyNotFound, + PartyPersistenceUnavailable, + PartySchema, +} from '../../shared/domain/identity-contracts.ts'; +import type { PartyLifecycle } from '../services/party-identity-persistence.service.ts'; + +type PersistedParty = typeof PartySchema.Type | typeof PartySchema.Encoded; +export const decodeParty = (party: PersistedParty) => + Schema.is(PartySchema)(party) + ? Effect.succeed(party) + : Schema.decodeUnknownEffect(PartySchema)(party).pipe( + Effect.mapError((cause) => + Object.defineProperty( + new PartyPersistenceUnavailable({ + code: 'party_persistence_unavailable', + reason: 'The stored Party could not be decoded', + }), + 'cause', + { configurable: true, value: cause }, + ), + ), + ); + +export const resolvePartyLifecycle = ( + persistenceResult: PartyLifecycle, + resourceId: string, + conflict: ConstructorParameters[0], +) => + Match.value(persistenceResult).pipe( + Match.tag('not_found', () => + Effect.fail( + new PartyNotFound({ + code: 'party_not_found', + partyId: partyIdFromString(resourceId), + reason: 'The Party does not exist', + }), + ), + ), + Match.tag('conflict', () => Effect.fail(new PartyLifecycleConflict(conflict))), + Match.tag('found', ({ value }) => decodeParty(value)), + Match.exhaustive, + ); + +export const recordPartyInvariantAccess = ( + context: Pick>>, 'recordDataAccess'>, + party: typeof PartySchema.Type, + queryPrefix: string, +) => + context.recordDataAccess({ + accessKind: 'read', + queryHash: createHash('sha256') + .update(`${queryPrefix}:${party.partyRef.resourceId}`) + .digest('hex'), + resultCount: 1, + servingModuleKey: 'party.registry', + targetModuleKey: 'party.registry', + targetResourceId: party.partyRef.resourceId, + targetResourceType: party.partyRef.resourceType, + }); diff --git a/app/verticals/party-registry/src/actions/request-search-rebuild.action.ts b/app/verticals/party-registry/src/actions/request-search-rebuild.action.ts index e23e53418..29ce7dfe5 100644 --- a/app/verticals/party-registry/src/actions/request-search-rebuild.action.ts +++ b/app/verticals/party-registry/src/actions/request-search-rebuild.action.ts @@ -14,21 +14,14 @@ import { import type { RequestSearchRebuildPayload } from '../../shared/actions/request-search-rebuild.ts'; import { ActionInvocationIdSchema } from '../../shared/domain/correction-contracts.ts'; -export { - RequestSearchRebuildPayloadSchema, - RequestSearchRebuildResultSchema, -} from '../../shared/actions/request-search-rebuild.ts'; -export type { - RequestSearchRebuildPayload, - RequestSearchRebuildResult, -} from '../../shared/actions/request-search-rebuild.ts'; +export type { RequestSearchRebuildPayload } from '../../shared/actions/request-search-rebuild.ts'; const domainEvents = { 'party.registry.search-rebuild-requested.v1': OutboxPayloadSchema, } as const; /** Queues committed intent only; projection I/O belongs to the post-commit Worker. */ -export const handleRequestSearchRebuild = Effect.fn( +const handleRequestSearchRebuild = Effect.fn( 'RequestSearchRebuildAction.handleRequestSearchRebuild', )(function* requestSearchRebuild( _payload: RequestSearchRebuildPayload, @@ -79,11 +72,3 @@ export const requestSearchRebuildAction = defineAction( }, handleRequestSearchRebuild, ); - -// -export { createRequestSearchRebuildPartyRegistrySearchRebuildRequestedV1OutboxMessage } from './request-search-rebuild.party-registry-search-rebuild-requested-v1.outbox-message.ts'; -export { RequestSearchRebuildPartyRegistrySearchRebuildRequestedV1OutboxPayloadSchema } from './request-search-rebuild.party-registry-search-rebuild-requested-v1.outbox-message.ts'; -export { RequestSearchRebuildPartyRegistrySearchRebuildRequestedV1OutboxProducerModuleKey } from './request-search-rebuild.party-registry-search-rebuild-requested-v1.outbox-message.ts'; -export { RequestSearchRebuildPartyRegistrySearchRebuildRequestedV1OutboxTopic } from './request-search-rebuild.party-registry-search-rebuild-requested-v1.outbox-message.ts'; -export type { RequestSearchRebuildPartyRegistrySearchRebuildRequestedV1OutboxPayload } from './request-search-rebuild.party-registry-search-rebuild-requested-v1.outbox-message.ts'; -// diff --git a/app/verticals/party-registry/src/actions/request-search-rebuild.party-registry-search-rebuild-requested-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/request-search-rebuild.party-registry-search-rebuild-requested-v1.outbox-message.ts index cbbd5849f..b56f4ef68 100644 --- a/app/verticals/party-registry/src/actions/request-search-rebuild.party-registry-search-rebuild-requested-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/request-search-rebuild.party-registry-search-rebuild-requested-v1.outbox-message.ts @@ -5,17 +5,10 @@ import { } from '@app/party-registry/outbox/party-registry-search-rebuild-requested-v1'; import type { OutboxPayload } from '@app/party-registry/outbox/party-registry-search-rebuild-requested-v1'; -export { OutboxPayloadSchema as RequestSearchRebuildPartyRegistrySearchRebuildRequestedV1OutboxPayloadSchema } from '@app/party-registry/outbox/party-registry-search-rebuild-requested-v1'; -export type RequestSearchRebuildPartyRegistrySearchRebuildRequestedV1OutboxPayload = OutboxPayload; -export const RequestSearchRebuildPartyRegistrySearchRebuildRequestedV1OutboxProducerModuleKey = - outboxProducerModuleKey; -export const RequestSearchRebuildPartyRegistrySearchRebuildRequestedV1OutboxTopic = outboxTopic; - export const createRequestSearchRebuildPartyRegistrySearchRebuildRequestedV1OutboxMessage = ( payload: OutboxPayload, ): OutboxMessage => ({ payloadJson: payload, - producerModuleKey: - RequestSearchRebuildPartyRegistrySearchRebuildRequestedV1OutboxProducerModuleKey, - topic: RequestSearchRebuildPartyRegistrySearchRebuildRequestedV1OutboxTopic, + producerModuleKey: outboxProducerModuleKey, + topic: outboxTopic, }); diff --git a/app/verticals/party-registry/src/actions/resolve-duplicate-candidate-create.action.ts b/app/verticals/party-registry/src/actions/resolve-duplicate-candidate-create.action.ts index 7d9da6ca8..c39410306 100644 --- a/app/verticals/party-registry/src/actions/resolve-duplicate-candidate-create.action.ts +++ b/app/verticals/party-registry/src/actions/resolve-duplicate-candidate-create.action.ts @@ -19,14 +19,7 @@ import { } from '../../shared/actions/resolve-duplicate-candidate-create.ts'; import type { ResolveDuplicateCandidateCreatePayload } from '../../shared/actions/resolve-duplicate-candidate-create.ts'; -export { - ResolveDuplicateCandidateCreatePayloadSchema, - ResolveDuplicateCandidateCreateResultSchema, -} from '../../shared/actions/resolve-duplicate-candidate-create.ts'; -export type { - ResolveDuplicateCandidateCreatePayload, - ResolveDuplicateCandidateCreateResult, -} from '../../shared/actions/resolve-duplicate-candidate-create.ts'; +export type { ResolveDuplicateCandidateCreatePayload } from '../../shared/actions/resolve-duplicate-candidate-create.ts'; const ErrorSchema = Schema.Union([ DuplicateCandidateConflict, PartyEvidenceInsufficient, diff --git a/app/verticals/party-registry/src/actions/resolve-duplicate-candidate-match.action.ts b/app/verticals/party-registry/src/actions/resolve-duplicate-candidate-match.action.ts index 805672c24..c42e2cda8 100644 --- a/app/verticals/party-registry/src/actions/resolve-duplicate-candidate-match.action.ts +++ b/app/verticals/party-registry/src/actions/resolve-duplicate-candidate-match.action.ts @@ -26,14 +26,7 @@ import { } from '../../shared/actions/resolve-duplicate-candidate-match.ts'; import type { ResolveDuplicateCandidateMatchPayload } from '../../shared/actions/resolve-duplicate-candidate-match.ts'; -export { - ResolveDuplicateCandidateMatchPayloadSchema, - ResolveDuplicateCandidateMatchResultSchema, -} from '../../shared/actions/resolve-duplicate-candidate-match.ts'; -export type { - ResolveDuplicateCandidateMatchPayload, - ResolveDuplicateCandidateMatchResult, -} from '../../shared/actions/resolve-duplicate-candidate-match.ts'; +export type { ResolveDuplicateCandidateMatchPayload } from '../../shared/actions/resolve-duplicate-candidate-match.ts'; const ErrorSchema = Schema.Union([ ClaimOwnedByDifferentParty, DuplicateCandidateConflict, diff --git a/app/verticals/party-registry/src/actions/unarchive-organization-engagement.action.ts b/app/verticals/party-registry/src/actions/unarchive-organization-engagement.action.ts index 420d27ec6..d11b30c09 100644 --- a/app/verticals/party-registry/src/actions/unarchive-organization-engagement.action.ts +++ b/app/verticals/party-registry/src/actions/unarchive-organization-engagement.action.ts @@ -24,9 +24,9 @@ import { transitionOrganizationEngagementProfile } from '../services/engagement- import type { LifecycleResult } from '../services/engagement-profile-persistence.service.ts'; import { resolveEngagementLifecycle } from './engagement-lifecycle.ts'; -export const UnarchiveOrganizationEngagementPayload = OrganizationEngagementLifecyclePayloadSchema; -export const UnarchiveOrganizationEngagementResult = OrganizationEngagementProfileSchema; -export const UnarchiveOrganizationEngagementError = Schema.Union([ +const UnarchiveOrganizationEngagementPayload = OrganizationEngagementLifecyclePayloadSchema; +const UnarchiveOrganizationEngagementResult = OrganizationEngagementProfileSchema; +const UnarchiveOrganizationEngagementError = Schema.Union([ EngagementProfileConflict, EngagementProfileNotFound, EngagementProfilePersistenceUnavailable, diff --git a/app/verticals/party-registry/src/actions/unarchive-party.action.ts b/app/verticals/party-registry/src/actions/unarchive-party.action.ts index ca2416b53..afd8c5a57 100644 --- a/app/verticals/party-registry/src/actions/unarchive-party.action.ts +++ b/app/verticals/party-registry/src/actions/unarchive-party.action.ts @@ -1,7 +1,7 @@ // @generated by OntOS Codesmith Action v1 // @ontos-action-owner party.registry // @ontos-action-slug unarchive-party -import { createHash } from 'node:crypto'; +import { decodeParty, recordPartyInvariantAccess } from './party-lifecycle-action-helpers.ts'; import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ActionHandlerContext } from '@app/core-runtime'; import { Effect, Match, Schema } from 'effect'; @@ -10,7 +10,6 @@ import { PartyLifecycleConflict, PartyNotFound, PartyPersistenceUnavailable, - PartySchema, } from '../../shared/domain/identity-contracts.ts'; import { PartyRefSchema } from '../../shared/resources/party.ts'; import { @@ -29,14 +28,7 @@ import { } from '../../shared/actions/unarchive-party.ts'; import type { UnarchivePartyPayload } from '../../shared/actions/unarchive-party.ts'; -export { - UnarchivePartyPayloadSchema, - UnarchivePartyResultSchema, -} from '../../shared/actions/unarchive-party.ts'; -export type { - UnarchivePartyPayload, - UnarchivePartyResult, -} from '../../shared/actions/unarchive-party.ts'; +export type { UnarchivePartyPayload } from '../../shared/actions/unarchive-party.ts'; const ErrorSchema = Schema.Union([ PartyNotFound, PartyLifecycleConflict, @@ -56,22 +48,6 @@ export interface UnarchivePartyServices { actionInvocationId: string, ) => ReturnType; } -type PersistedParty = typeof PartySchema.Type | typeof PartySchema.Encoded; -const decodeParty = (party: PersistedParty) => - Schema.is(PartySchema)(party) - ? Effect.succeed(party) - : Schema.decodeUnknownEffect(PartySchema)(party).pipe( - Effect.mapError((cause) => - Object.defineProperty( - new PartyPersistenceUnavailable({ - code: 'party_persistence_unavailable', - reason: 'The stored Party could not be decoded', - }), - 'cause', - { configurable: true, value: cause }, - ), - ), - ); const handle = Effect.fn('UnarchivePartyAction.handle')(function* unarchiveParty( payload: UnarchivePartyPayload, context: ActionHandlerContext, @@ -117,17 +93,7 @@ const handle = Effect.fn('UnarchivePartyAction.handle')(function* unarchiveParty ), Match.exhaustive, ); - yield* context.recordDataAccess({ - accessKind: 'read', - queryHash: createHash('sha256') - .update(`party-unarchive-invariants:${result.party.partyRef.resourceId}`) - .digest('hex'), - resultCount: 1, - servingModuleKey: 'party.registry', - targetModuleKey: 'party.registry', - targetResourceId: result.party.partyRef.resourceId, - targetResourceType: result.party.partyRef.resourceType, - }); + yield* recordPartyInvariantAccess(context, result.party, 'party-unarchive-invariants'); if (result.changed) { const event = yield* context.addDomainEvent({ eventType: 'party.registry.party-unarchived.v1', @@ -185,10 +151,3 @@ export const unarchivePartyAction = defineAction( ), }), ); -export { createUnarchivePartyPartyRegistryPartyUnarchivedV1OutboxMessage } from './unarchive-party.party-registry-party-unarchived-v1.outbox-message.ts'; -export { - UnarchivePartyPartyRegistryPartyUnarchivedV1OutboxPayloadSchema, - UnarchivePartyPartyRegistryPartyUnarchivedV1OutboxProducerModuleKey, - UnarchivePartyPartyRegistryPartyUnarchivedV1OutboxTopic, -} from './unarchive-party.party-registry-party-unarchived-v1.outbox-message.ts'; -export type { UnarchivePartyPartyRegistryPartyUnarchivedV1OutboxPayload } from './unarchive-party.party-registry-party-unarchived-v1.outbox-message.ts'; diff --git a/app/verticals/party-registry/src/actions/unarchive-party.party-registry-party-unarchived-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/unarchive-party.party-registry-party-unarchived-v1.outbox-message.ts index 46e72af8b..34cfa9933 100644 --- a/app/verticals/party-registry/src/actions/unarchive-party.party-registry-party-unarchived-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/unarchive-party.party-registry-party-unarchived-v1.outbox-message.ts @@ -1,17 +1,12 @@ -/* eslint-disable unicorn/prefer-export-from -- Codesmith keeps stable action-local aliases for the public outbox contract. expires: 2026-12-31. */ import type { OutboxMessage } from '@app/core-runtime'; import { - OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-party-unarchived-v1'; import type { OutboxPayload } from '@app/party-registry/outbox/party-registry-party-unarchived-v1'; -export const UnarchivePartyPartyRegistryPartyUnarchivedV1OutboxPayloadSchema = OutboxPayloadSchema; -export type UnarchivePartyPartyRegistryPartyUnarchivedV1OutboxPayload = OutboxPayload; -export const UnarchivePartyPartyRegistryPartyUnarchivedV1OutboxProducerModuleKey = - outboxProducerModuleKey; -export const UnarchivePartyPartyRegistryPartyUnarchivedV1OutboxTopic = outboxTopic; +const UnarchivePartyPartyRegistryPartyUnarchivedV1OutboxProducerModuleKey = outboxProducerModuleKey; +const UnarchivePartyPartyRegistryPartyUnarchivedV1OutboxTopic = outboxTopic; export const createUnarchivePartyPartyRegistryPartyUnarchivedV1OutboxMessage = ( payload: OutboxPayload, diff --git a/app/verticals/party-registry/src/actions/unarchive-person-engagement.action.ts b/app/verticals/party-registry/src/actions/unarchive-person-engagement.action.ts index 4b50c10ea..1dfe33b68 100644 --- a/app/verticals/party-registry/src/actions/unarchive-person-engagement.action.ts +++ b/app/verticals/party-registry/src/actions/unarchive-person-engagement.action.ts @@ -24,9 +24,9 @@ import { transitionPersonEngagementProfile } from '../services/engagement-profil import type { LifecycleResult } from '../services/engagement-profile-persistence.service.ts'; import { resolveEngagementLifecycle } from './engagement-lifecycle.ts'; -export const UnarchivePersonEngagementPayload = PersonEngagementLifecyclePayloadSchema; -export const UnarchivePersonEngagementResult = PersonEngagementProfileSchema; -export const UnarchivePersonEngagementError = Schema.Union([ +const UnarchivePersonEngagementPayload = PersonEngagementLifecyclePayloadSchema; +const UnarchivePersonEngagementResult = PersonEngagementProfileSchema; +const UnarchivePersonEngagementError = Schema.Union([ EngagementProfileConflict, EngagementProfileNotFound, EngagementProfilePersistenceUnavailable, diff --git a/app/verticals/party-registry/src/actions/update-contact-point.action.ts b/app/verticals/party-registry/src/actions/update-contact-point.action.ts index 9d673fe15..9280d2e31 100644 --- a/app/verticals/party-registry/src/actions/update-contact-point.action.ts +++ b/app/verticals/party-registry/src/actions/update-contact-point.action.ts @@ -39,18 +39,10 @@ import { } from '../../shared/actions/update-contact-point.ts'; import type { UpdateContactPointPayload } from '../../shared/actions/update-contact-point.ts'; -export { - ContactPointMetadataChangeSchema, - UpdateContactPointPayloadSchema, - UpdateContactPointResultSchema, -} from '../../shared/actions/update-contact-point.ts'; -export type { - ContactPointMetadataChange, - UpdateContactPointPayload, - UpdateContactPointResult, -} from '../../shared/actions/update-contact-point.ts'; +export { UpdateContactPointPayloadSchema } from '../../shared/actions/update-contact-point.ts'; +export type { UpdateContactPointPayload } from '../../shared/actions/update-contact-point.ts'; -export const UpdateContactPointErrorSchema = Schema.Union([ +const UpdateContactPointErrorSchema = Schema.Union([ PartyAliasWriteRejected, PartyContactPointNotFound, PartyContactPointAlreadyExists, @@ -283,11 +275,3 @@ export const updateContactPointAction = defineAction( ), }), ); - -// -export { createUpdateContactPointPartyRegistryContactPointUpdatedV1OutboxMessage } from './update-contact-point.party-registry-contact-point-updated-v1.outbox-message.ts'; -export { UpdateContactPointPartyRegistryContactPointUpdatedV1OutboxPayloadSchema } from './update-contact-point.party-registry-contact-point-updated-v1.outbox-message.ts'; -export { UpdateContactPointPartyRegistryContactPointUpdatedV1OutboxProducerModuleKey } from './update-contact-point.party-registry-contact-point-updated-v1.outbox-message.ts'; -export { UpdateContactPointPartyRegistryContactPointUpdatedV1OutboxTopic } from './update-contact-point.party-registry-contact-point-updated-v1.outbox-message.ts'; -export type { UpdateContactPointPartyRegistryContactPointUpdatedV1OutboxPayload } from './update-contact-point.party-registry-contact-point-updated-v1.outbox-message.ts'; -// diff --git a/app/verticals/party-registry/src/actions/update-contact-point.party-registry-contact-point-updated-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/update-contact-point.party-registry-contact-point-updated-v1.outbox-message.ts index f916c5f02..4d662f3df 100644 --- a/app/verticals/party-registry/src/actions/update-contact-point.party-registry-contact-point-updated-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/update-contact-point.party-registry-contact-point-updated-v1.outbox-message.ts @@ -1,23 +1,14 @@ -/* eslint-disable unicorn/prefer-export-from -- Codesmith keeps stable action-local aliases for the public outbox contract. expires: 2026-12-31. */ import type { OutboxMessage } from '@app/core-runtime'; import { - OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-contact-point-updated-v1'; import type { OutboxPayload } from '@app/party-registry/outbox/party-registry-contact-point-updated-v1'; -export const UpdateContactPointPartyRegistryContactPointUpdatedV1OutboxPayloadSchema = - OutboxPayloadSchema; -export type UpdateContactPointPartyRegistryContactPointUpdatedV1OutboxPayload = OutboxPayload; -export const UpdateContactPointPartyRegistryContactPointUpdatedV1OutboxProducerModuleKey = - outboxProducerModuleKey; -export const UpdateContactPointPartyRegistryContactPointUpdatedV1OutboxTopic = outboxTopic; - export const createUpdateContactPointPartyRegistryContactPointUpdatedV1OutboxMessage = ( payload: OutboxPayload, ): OutboxMessage => ({ payloadJson: payload, - producerModuleKey: UpdateContactPointPartyRegistryContactPointUpdatedV1OutboxProducerModuleKey, - topic: UpdateContactPointPartyRegistryContactPointUpdatedV1OutboxTopic, + producerModuleKey: outboxProducerModuleKey, + topic: outboxTopic, }); diff --git a/app/verticals/party-registry/src/actions/update-party-official-identifier.action.ts b/app/verticals/party-registry/src/actions/update-party-official-identifier.action.ts index e028ee512..1e27f4f86 100644 --- a/app/verticals/party-registry/src/actions/update-party-official-identifier.action.ts +++ b/app/verticals/party-registry/src/actions/update-party-official-identifier.action.ts @@ -48,14 +48,8 @@ import type { } from '../../shared/actions/update-party-official-identifier.ts'; import { createUpdatePartyOfficialIdentifierPartyRegistryOfficialIdentifierUpdatedV1OutboxMessage } from './update-party-official-identifier.party-registry-official-identifier-updated-v1.outbox-message.ts'; -export { - UpdatePartyOfficialIdentifierPayloadSchema, - UpdatePartyOfficialIdentifierResultSchema, -} from '../../shared/actions/update-party-official-identifier.ts'; -export type { - UpdatePartyOfficialIdentifierPayload, - UpdatePartyOfficialIdentifierResult, -} from '../../shared/actions/update-party-official-identifier.ts'; +export { UpdatePartyOfficialIdentifierPayloadSchema } from '../../shared/actions/update-party-official-identifier.ts'; +export type { UpdatePartyOfficialIdentifierPayload } from '../../shared/actions/update-party-official-identifier.ts'; const PartyOfficialIdentifierNotFoundContract = Schema.TaggedStruct( 'PartyOfficialIdentifierNotFound', @@ -64,12 +58,10 @@ const PartyOfficialIdentifierNotFoundContract = Schema.TaggedStruct( reason: Schema.String, }, ); -export const PartyOfficialIdentifierNotFound = Schema.TaggedError< +const PartyOfficialIdentifierNotFound = Schema.TaggedError< typeof PartyOfficialIdentifierNotFoundContract.Type >()('PartyOfficialIdentifierNotFound', PartyOfficialIdentifierNotFoundContract.fields); -export type PartyOfficialIdentifierNotFoundError = InstanceType< - typeof PartyOfficialIdentifierNotFound ->; +type PartyOfficialIdentifierNotFoundError = InstanceType; const PartyOfficialIdentifierUpdateConflictContract = Schema.TaggedStruct( 'PartyOfficialIdentifierUpdateConflict', @@ -78,10 +70,10 @@ const PartyOfficialIdentifierUpdateConflictContract = Schema.TaggedStruct( reason: Schema.String, }, ); -export const PartyOfficialIdentifierUpdateConflict = Schema.TaggedError< +const PartyOfficialIdentifierUpdateConflict = Schema.TaggedError< typeof PartyOfficialIdentifierUpdateConflictContract.Type >()('PartyOfficialIdentifierUpdateConflict', PartyOfficialIdentifierUpdateConflictContract.fields); -export type PartyOfficialIdentifierUpdateConflictError = InstanceType< +type PartyOfficialIdentifierUpdateConflictError = InstanceType< typeof PartyOfficialIdentifierUpdateConflict >; @@ -288,11 +280,3 @@ export const updatePartyOfficialIdentifierAction = defineAction( }), } satisfies Services), ); - -// -export { createUpdatePartyOfficialIdentifierPartyRegistryOfficialIdentifierUpdatedV1OutboxMessage } from './update-party-official-identifier.party-registry-official-identifier-updated-v1.outbox-message.ts'; -export { UpdatePartyOfficialIdentifierPartyRegistryOfficialIdentifierUpdatedV1OutboxPayloadSchema } from './update-party-official-identifier.party-registry-official-identifier-updated-v1.outbox-message.ts'; -export { UpdatePartyOfficialIdentifierPartyRegistryOfficialIdentifierUpdatedV1OutboxProducerModuleKey } from './update-party-official-identifier.party-registry-official-identifier-updated-v1.outbox-message.ts'; -export { UpdatePartyOfficialIdentifierPartyRegistryOfficialIdentifierUpdatedV1OutboxTopic } from './update-party-official-identifier.party-registry-official-identifier-updated-v1.outbox-message.ts'; -export type { UpdatePartyOfficialIdentifierPartyRegistryOfficialIdentifierUpdatedV1OutboxPayload } from './update-party-official-identifier.party-registry-official-identifier-updated-v1.outbox-message.ts'; -// diff --git a/app/verticals/party-registry/src/actions/update-party-official-identifier.party-registry-official-identifier-updated-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/update-party-official-identifier.party-registry-official-identifier-updated-v1.outbox-message.ts index 22b82f9a2..ae09a5844 100644 --- a/app/verticals/party-registry/src/actions/update-party-official-identifier.party-registry-official-identifier-updated-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/update-party-official-identifier.party-registry-official-identifier-updated-v1.outbox-message.ts @@ -1,25 +1,13 @@ -/* eslint-disable unicorn/prefer-export-from -- Codesmith keeps stable action-local aliases for the public outbox contract. expires: 2026-12-31. */ import type { OutboxMessage } from '@app/core-runtime'; import { - OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-official-identifier-updated-v1'; import type { OutboxPayload } from '@app/party-registry/outbox/party-registry-official-identifier-updated-v1'; -export const UpdatePartyOfficialIdentifierPartyRegistryOfficialIdentifierUpdatedV1OutboxPayloadSchema = - OutboxPayloadSchema; -export type UpdatePartyOfficialIdentifierPartyRegistryOfficialIdentifierUpdatedV1OutboxPayload = - OutboxPayload; -export const UpdatePartyOfficialIdentifierPartyRegistryOfficialIdentifierUpdatedV1OutboxProducerModuleKey = - outboxProducerModuleKey; -export const UpdatePartyOfficialIdentifierPartyRegistryOfficialIdentifierUpdatedV1OutboxTopic = - outboxTopic; - export const createUpdatePartyOfficialIdentifierPartyRegistryOfficialIdentifierUpdatedV1OutboxMessage = (payload: OutboxPayload): OutboxMessage => ({ payloadJson: payload, - producerModuleKey: - UpdatePartyOfficialIdentifierPartyRegistryOfficialIdentifierUpdatedV1OutboxProducerModuleKey, - topic: UpdatePartyOfficialIdentifierPartyRegistryOfficialIdentifierUpdatedV1OutboxTopic, + producerModuleKey: outboxProducerModuleKey, + topic: outboxTopic, }); diff --git a/app/verticals/party-registry/src/actions/update-party-relationship.action.ts b/app/verticals/party-registry/src/actions/update-party-relationship.action.ts index 07ca47927..9fb1fbaa9 100644 --- a/app/verticals/party-registry/src/actions/update-party-relationship.action.ts +++ b/app/verticals/party-registry/src/actions/update-party-relationship.action.ts @@ -14,7 +14,6 @@ import { UpdatePartyRelationshipPayloadSchema, } from '../../shared/domain/relationship-contract.ts'; import type { - ChangePartyRelationshipResult as Result, PartyRelationshipLifecycleEventPayload, UpdatePartyRelationshipPayload as Payload, } from '../../shared/domain/relationship-contract.ts'; @@ -25,9 +24,7 @@ import type { } from '../services/party-relationship-persistence.service.ts'; import { createUpdatePartyRelationshipPartyRegistryRelationshipUpdatedV1OutboxMessage } from './update-party-relationship.party-registry-relationship-updated-v1.outbox-message.ts'; -export type UpdatePartyRelationshipPayload = Payload; -export const UpdatePartyRelationshipResultSchema = ChangePartyRelationshipResultSchema; -export type UpdatePartyRelationshipResult = Result; +const UpdatePartyRelationshipResultSchema = ChangePartyRelationshipResultSchema; const eventPayload = ( result: RelationshipChangeResult, @@ -149,11 +146,3 @@ export const updatePartyRelationshipAction = defineAction( ), }), ); - -// -export { createUpdatePartyRelationshipPartyRegistryRelationshipUpdatedV1OutboxMessage } from './update-party-relationship.party-registry-relationship-updated-v1.outbox-message.ts'; -export { UpdatePartyRelationshipPartyRegistryRelationshipUpdatedV1OutboxPayloadSchema } from './update-party-relationship.party-registry-relationship-updated-v1.outbox-message.ts'; -export { UpdatePartyRelationshipPartyRegistryRelationshipUpdatedV1OutboxProducerModuleKey } from './update-party-relationship.party-registry-relationship-updated-v1.outbox-message.ts'; -export { UpdatePartyRelationshipPartyRegistryRelationshipUpdatedV1OutboxTopic } from './update-party-relationship.party-registry-relationship-updated-v1.outbox-message.ts'; -export type { UpdatePartyRelationshipPartyRegistryRelationshipUpdatedV1OutboxPayload } from './update-party-relationship.party-registry-relationship-updated-v1.outbox-message.ts'; -// diff --git a/app/verticals/party-registry/src/actions/update-party-relationship.party-registry-relationship-updated-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/update-party-relationship.party-registry-relationship-updated-v1.outbox-message.ts index 518a75713..2dfeaff24 100644 --- a/app/verticals/party-registry/src/actions/update-party-relationship.party-registry-relationship-updated-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/update-party-relationship.party-registry-relationship-updated-v1.outbox-message.ts @@ -1,27 +1,14 @@ -/* eslint-disable unicorn/prefer-export-from -- Generated action aliases intentionally bind stable action-specific names. expires: 2026-12-31. */ import type { OutboxMessage } from '@app/core-runtime'; import { - OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-relationship-updated-v1'; -import type { - OutboxPayload, - OutboxPayloadJson, -} from '@app/party-registry/outbox/party-registry-relationship-updated-v1'; - -export const UpdatePartyRelationshipPartyRegistryRelationshipUpdatedV1OutboxPayloadSchema = - OutboxPayloadSchema; -export type UpdatePartyRelationshipPartyRegistryRelationshipUpdatedV1OutboxPayload = OutboxPayload; -export const UpdatePartyRelationshipPartyRegistryRelationshipUpdatedV1OutboxProducerModuleKey = - outboxProducerModuleKey; -export const UpdatePartyRelationshipPartyRegistryRelationshipUpdatedV1OutboxTopic = outboxTopic; +import type { OutboxPayloadJson } from '@app/party-registry/outbox/party-registry-relationship-updated-v1'; export const createUpdatePartyRelationshipPartyRegistryRelationshipUpdatedV1OutboxMessage = ( payload: OutboxPayloadJson, ): OutboxMessage => ({ payloadJson: payload, - producerModuleKey: - UpdatePartyRelationshipPartyRegistryRelationshipUpdatedV1OutboxProducerModuleKey, - topic: UpdatePartyRelationshipPartyRegistryRelationshipUpdatedV1OutboxTopic, + producerModuleKey: outboxProducerModuleKey, + topic: outboxTopic, }); diff --git a/app/verticals/party-registry/src/actions/update-party.action.ts b/app/verticals/party-registry/src/actions/update-party.action.ts index 9c88787a3..6fe2cf5b3 100644 --- a/app/verticals/party-registry/src/actions/update-party.action.ts +++ b/app/verticals/party-registry/src/actions/update-party.action.ts @@ -1,10 +1,13 @@ // @generated by OntOS Codesmith Action v1 // @ontos-action-owner party.registry // @ontos-action-slug update-party -import { createHash } from 'node:crypto'; +import { + recordPartyInvariantAccess, + resolvePartyLifecycle, +} from './party-lifecycle-action-helpers.ts'; import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ActionHandlerContext } from '@app/core-runtime'; -import { DateTime, Effect, Match, Schema } from 'effect'; +import { DateTime, Effect, Schema } from 'effect'; import { PartyAliasResolutionBrokenChain, PartyAliasResolutionCrossTenant, @@ -14,12 +17,10 @@ import { } from '../../shared/domain/merge-alias-resolution.ts'; import type { PartyAliasResolutionError } from '../../shared/domain/merge-alias-resolution.ts'; import { - partyIdFromString, PartyLifecycleConflict, PartyNotFound, PartyPersistenceUnavailable, PartyEvidenceInsufficient, - PartySchema, } from '../../shared/domain/identity-contracts.ts'; import type { PartyEvidenceInsufficientError, @@ -36,11 +37,7 @@ import { } from '../../shared/actions/update-party.ts'; import type { UpdatePartyPayload } from '../../shared/actions/update-party.ts'; -export { - UpdatePartyPayloadSchema, - UpdatePartyResultSchema, -} from '../../shared/actions/update-party.ts'; -export type { UpdatePartyPayload, UpdatePartyResult } from '../../shared/actions/update-party.ts'; +export type { UpdatePartyPayload } from '../../shared/actions/update-party.ts'; const ErrorSchema = Schema.Union([ PartyNotFound, PartyLifecycleConflict, @@ -72,62 +69,18 @@ interface Services { ) => Effect.Effect; } -type PersistedParty = typeof PartySchema.Type | typeof PartySchema.Encoded; -const decodeParty = (party: PersistedParty) => - Schema.is(PartySchema)(party) - ? Effect.succeed(party) - : Schema.decodeUnknownEffect(PartySchema)(party).pipe( - Effect.mapError((cause) => - Object.defineProperty( - new PartyPersistenceUnavailable({ - code: 'party_persistence_unavailable', - reason: 'The stored Party could not be decoded', - }), - 'cause', - { configurable: true, value: cause }, - ), - ), - ); - const handle = Effect.fn('UpdatePartyAction.handle')(function* updateParty( payload: UpdatePartyPayload, context: ActionHandlerContext, ) { const persistenceResult = yield* context.services.update(payload, context.actionInvocationId); - const result = yield* Match.value(persistenceResult).pipe( - Match.tag('not_found', () => - Effect.fail( - new PartyNotFound({ - code: 'party_not_found', - partyId: partyIdFromString(payload.partyRef.resourceId), - reason: 'The Party does not exist', - }), - ), - ), - Match.tag('conflict', () => - Effect.fail( - new PartyLifecycleConflict({ - code: 'party_lifecycle_conflict', - reason: - 'The Party revision is stale, archived, or the requested change requires correction or unsupported future scheduling', - requestedState: 'ACTIVE', - }), - ), - ), - Match.tag('found', ({ value }) => decodeParty(value)), - Match.exhaustive, - ); - yield* context.recordDataAccess({ - accessKind: 'read', - queryHash: createHash('sha256') - .update(`party-update-invariants:${result.partyRef.resourceId}`) - .digest('hex'), - resultCount: 1, - servingModuleKey: 'party.registry', - targetModuleKey: 'party.registry', - targetResourceId: result.partyRef.resourceId, - targetResourceType: result.partyRef.resourceType, + const result = yield* resolvePartyLifecycle(persistenceResult, payload.partyRef.resourceId, { + code: 'party_lifecycle_conflict', + reason: + 'The Party revision is stale, archived, or the requested change requires correction or unsupported future scheduling', + requestedState: 'ACTIVE', }); + yield* recordPartyInvariantAccess(context, result, 'party-update-invariants'); const event = yield* context.addDomainEvent({ eventType: 'party.registry.party-updated.v1', payloadJson: { partyRef: result.partyRef }, @@ -189,11 +142,3 @@ export const updatePartyAction = defineAction( }), }), ); - -export { createUpdatePartyPartyRegistryPartyUpdatedV1OutboxMessage } from './update-party.party-registry-party-updated-v1.outbox-message.ts'; -export { - UpdatePartyPartyRegistryPartyUpdatedV1OutboxPayloadSchema, - UpdatePartyPartyRegistryPartyUpdatedV1OutboxProducerModuleKey, - UpdatePartyPartyRegistryPartyUpdatedV1OutboxTopic, -} from './update-party.party-registry-party-updated-v1.outbox-message.ts'; -export type { UpdatePartyPartyRegistryPartyUpdatedV1OutboxPayload } from './update-party.party-registry-party-updated-v1.outbox-message.ts'; diff --git a/app/verticals/party-registry/src/actions/update-party.party-registry-party-updated-v1.outbox-message.ts b/app/verticals/party-registry/src/actions/update-party.party-registry-party-updated-v1.outbox-message.ts index 3b3cbb017..df95208df 100644 --- a/app/verticals/party-registry/src/actions/update-party.party-registry-party-updated-v1.outbox-message.ts +++ b/app/verticals/party-registry/src/actions/update-party.party-registry-party-updated-v1.outbox-message.ts @@ -1,17 +1,12 @@ -/* eslint-disable unicorn/prefer-export-from -- Codesmith keeps stable action-local aliases for the public outbox contract. expires: 2026-12-31. */ import type { OutboxMessage } from '@app/core-runtime'; import { - OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-party-updated-v1'; import type { OutboxPayload } from '@app/party-registry/outbox/party-registry-party-updated-v1'; -export const UpdatePartyPartyRegistryPartyUpdatedV1OutboxPayloadSchema = OutboxPayloadSchema; -export type UpdatePartyPartyRegistryPartyUpdatedV1OutboxPayload = OutboxPayload; -export const UpdatePartyPartyRegistryPartyUpdatedV1OutboxProducerModuleKey = - outboxProducerModuleKey; -export const UpdatePartyPartyRegistryPartyUpdatedV1OutboxTopic = outboxTopic; +const UpdatePartyPartyRegistryPartyUpdatedV1OutboxProducerModuleKey = outboxProducerModuleKey; +const UpdatePartyPartyRegistryPartyUpdatedV1OutboxTopic = outboxTopic; export const createUpdatePartyPartyRegistryPartyUpdatedV1OutboxMessage = ( payload: OutboxPayload, diff --git a/app/verticals/party-registry/src/api/action-gateway.ts b/app/verticals/party-registry/src/api/action-gateway.ts index e3b40bf04..8d0c42a47 100644 --- a/app/verticals/party-registry/src/api/action-gateway.ts +++ b/app/verticals/party-registry/src/api/action-gateway.ts @@ -69,9 +69,7 @@ export const makeActionGateway = (acquire: ActionGatewayIssuer = issueGatewayCon ), }); -export const actionGateway = makeActionGateway(); -export const makeOperationGateway = makeActionGateway; -export const operationGateway = actionGateway; +export const operationGateway = makeActionGateway(); export const { deriveAresCorrectionReviewHandoffs, prefillPartyCandidateFromAres } = AresApplication; @@ -266,6 +264,45 @@ const sameParty = (left: PartyRef, right: PartyRef): boolean => left.moduleId === right.moduleId && left.resourceType === right.resourceType; +const hasSupportedCanonicalRoute = (selection: AresApplySelection): boolean => + !( + !Schema.is(AresApplication.AresCanonicalRouteSchema)(selection.route) || + (selection.route === 'PARTY_UPDATE' && selection.fact !== 'BUSINESS_NAME') || + (selection.route === 'IDENTIFIER_ADD' && selection.fact !== 'ICO') || + (selection.route === 'CONTACT_POINT_ADD' && selection.fact !== 'REGISTERED_ADDRESS') + ); + +const validateSelection = (selection: AresApplySelection, partyRef: PartyRef | null) => { + if (!hasSupportedCanonicalRoute(selection)) { + return Effect.fail(invalidSelection('The selected fact requires a supported canonical route')); + } + if (selection.idempotencyKey.trim().length === 0) { + return Effect.fail( + invalidSelection('Every selected Action requires its own stable idempotency key'), + ); + } + if (partyRef === null) { + return Effect.fail(invalidSelection('Enrichment requires one explicit existing Party')); + } else if (selection.route === 'PARTY_CORRECTION') { + const expectedFactKind = + selection.fact === 'BUSINESS_NAME' ? 'DISPLAY_NAME' : 'OFFICIAL_IDENTIFIER'; + if (selection.payload.factKind !== expectedFactKind) { + return Effect.fail( + invalidSelection('Correction review must nominate the selected supported fact'), + ); + } + if (selection.payload.partyId !== partyRef.resourceId) { + return Effect.fail(invalidSelection('Correction target does not match the selected Party')); + } + } else if (!sameParty(selection.payload.partyRef, partyRef)) { + return Effect.fail( + invalidSelection('All selected facts must target the same tenant-qualified Party'), + ); + } + + return Effect.void; +}; + const validateRequest = (request: AresApplyRequest) => { if (!request.userConfirmed || request.correlationId.trim().length === 0) { return Effect.fail( @@ -276,43 +313,16 @@ const validateRequest = (request: AresApplyRequest) => { if (facts.length > 4 || new Set(facts).size !== facts.length) { return Effect.fail(invalidSelection('Select each bounded ARES fact at most once')); } - for (const selection of request.selections) { - if ( - !Schema.is(AresApplication.AresCanonicalRouteSchema)(selection.route) || - (selection.route === 'PARTY_UPDATE' && selection.fact !== 'BUSINESS_NAME') || - (selection.route === 'IDENTIFIER_ADD' && selection.fact !== 'ICO') || - (selection.route === 'CONTACT_POINT_ADD' && selection.fact !== 'REGISTERED_ADDRESS') - ) { - return Effect.fail( - invalidSelection('The selected fact requires a supported canonical route'), - ); - } - if (selection.idempotencyKey.trim().length === 0) { - return Effect.fail( - invalidSelection('Every selected Action requires its own stable idempotency key'), - ); - } - if (request.partyRef === null) { - return Effect.fail(invalidSelection('Enrichment requires one explicit existing Party')); - } else if (selection.route === 'PARTY_CORRECTION') { - const expectedFactKind = - selection.fact === 'BUSINESS_NAME' ? 'DISPLAY_NAME' : 'OFFICIAL_IDENTIFIER'; - if (selection.payload.factKind !== expectedFactKind) { - return Effect.fail( - invalidSelection('Correction review must nominate the selected supported fact'), - ); - } - if (selection.payload.partyId !== request.partyRef.resourceId) { - return Effect.fail(invalidSelection('Correction target does not match the selected Party')); - } - } else if (!sameParty(selection.payload.partyRef, request.partyRef)) { - return Effect.fail( - invalidSelection('All selected facts must target the same tenant-qualified Party'), - ); - } - } - return Schema.decodeUnknownEffect(AresSubjectEvidenceSchema)(request.observation).pipe( - Effect.mapError(invalidObservation), + return Effect.forEach( + request.selections, + (selection) => validateSelection(selection, request.partyRef), + { concurrency: 1, discard: true }, + ).pipe( + Effect.andThen(() => + Schema.decodeUnknownEffect(AresSubjectEvidenceSchema)(request.observation).pipe( + Effect.mapError(invalidObservation), + ), + ), ); }; @@ -475,62 +485,159 @@ const invokeSelection = ( Match.exhaustive, ); -/** - * Refreshes provider evidence and canonical facts through governed Reads, evaluates the closed - * owner policy, then dispatches only explicitly selected standard Actions. Conflicts are deferred - * to the ordinary reviewed workflow; a caller-supplied route never proves historical error. - * Each Action persists its bounded external evidence and owns its independent idempotent commit. - */ -export const applyAresObservationWithActions = Effect.fn( - 'ActionGateway.applyAresObservationWithActions', -)( - // eslint-disable-next-line complexity -- one closed coordinator preserves fail-stop ordering - function* applyAresObservationWithActionsEffect( - request: AresApplyRequest, - invoker: PartyRegistryStandardActionInvoker, - options: AresApplyOptions = {}, - ) { - if (request.selections.length === 0) { +const observationIsStale = ( + supplied: AresSubjectEvidence, + observation: AresSubjectEvidence, + decisionTime: DateTime.Utc, +): boolean => { + const ageMillis = + DateTime.toEpochMillis(decisionTime) - DateTime.toEpochMillis(observation.observedAt); + const suppliedAgeMillis = + DateTime.toEpochMillis(decisionTime) - DateTime.toEpochMillis(supplied.observedAt); + return ( + ageMillis < 0 || + ageMillis > 300_000 || + suppliedAgeMillis < 0 || + suppliedAgeMillis > 300_000 || + DateTime.toEpochMillis(supplied.servedAt) > DateTime.toEpochMillis(decisionTime) + ); +}; + +const refreshedObservationChanged = ( + request: AresApplyRequest, + suppliedInput: typeof AresSubjectEvidenceSchema.Encoded, + observationInput: typeof AresSubjectEvidenceSchema.Encoded, + observation: AresSubjectEvidence, +): boolean => + suppliedInput.providerChangedOn !== observationInput.providerChangedOn || + suppliedInput.queryIco !== observationInput.queryIco || + request.selections.some((selection) => { + if (selection.route !== 'PARTY_CORRECTION') { + return !matchesObservation(selection, observation); + } + return selection.fact === 'BUSINESS_NAME' + ? suppliedInput.subject.businessName !== observationInput.subject.businessName + : suppliedInput.subject.ico !== observationInput.subject.ico; + }); + +const correctionTargetChanged = ( + selection: AresApplySelection, + candidates: readonly AresCorrectionReviewHandoff[], +): boolean => + selection.route === 'PARTY_CORRECTION' && + candidates.some( + (candidate) => + candidate.fact === selection.fact && + candidate.targetAssertionId !== selection.payload.targetAssertionId, + ); + +const selectionRevisionChanged = ( + selection: ExecutableSelection, + revision: number | null, +): boolean => selection.route === 'PARTY_UPDATE' && selection.payload.expectedRevision !== revision; + +const permitsSelectedAction = ( + decision: AresEvidenceApplication['factDecisions'][number], + selection: ExecutableSelection, +): boolean => decision.outcome === 'APPLY_ENRICHMENT' && decision.route === selection.route; + +const planSelectedActions = ( + request: AresApplyRequest, + application: AresEvidenceApplication, + observation: AresSubjectEvidence, + correctionCandidates: readonly AresCorrectionReviewHandoff[], + revision: number | null, +) => { + const executable: ExecutableSelection[] = []; + const skipped: AresSkippedAction[] = []; + for (const selection of request.selections) { + const decision = application.factDecisions.find(({ fact }) => fact === selection.fact); + if (correctionTargetChanged(selection, correctionCandidates)) { return { - _tag: 'AresApplyNotRequested' as const, - completed: [] as const, - skipped: [] as const, + executable, + skipped, + deferred: { + _tag: 'AresApplyDeferred' as const, + application: needsConfirmation(application, 'canonical_assertion_changed'), + completed: [] as const, + correctionCandidates: [], + skipped, + }, }; } - const supplied = yield* validateRequest(request); - yield* validateSelectedValues(request, supplied); - const gateway = options.gateway ?? actionGateway; - const reads = options.reads ?? (yield* loadDefaultReads()); - const clientOptions = options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl }; - const loadedObservation = yield* gateway.invoke( - (authorization) => - reads.observation( - { ico: supplied.queryIco }, - authorization, - request.correlationId, - clientOptions, - ), - options.gatewayContext, - ); - const observation = yield* decodeReadObservation(loadedObservation).pipe( - Effect.mapError(invalidObservation), - ); - const [suppliedInput, observationInput] = yield* Effect.all( - [ - Schema.encodeEffect(AresSubjectEvidenceSchema)(supplied), - Schema.encodeEffect(AresSubjectEvidenceSchema)(observation), - ], - { concurrency: 2 }, - ).pipe( - Effect.mapError((error) => - invalidSelection('ARES observation cannot be encoded for policy evaluation', error), - ), - ); - const decisionTime = yield* DateTime.now; - const decisionEpochMillis = DateTime.toEpochMillis(decisionTime); - const decidedAt = DateTime.formatIso(decisionTime); - let canonical: AresCanonicalSnapshot | null = null; - let revision: number | null = null; + if (selection.route === 'PARTY_CORRECTION' || decision === undefined) { + return { + executable, + skipped, + deferred: { + _tag: 'AresApplyDeferred' as const, + application, + completed: [] as const, + correctionCandidates, + skipped, + }, + }; + } + if (decision.outcome === 'NO_CHANGE' && matchesObservation(selection, observation)) { + skipped.push({ fact: selection.fact, reason: 'ALREADY_SATISFIED', route: selection.route }); + continue; + } + const permitted = permitsSelectedAction(decision, selection); + if (!permitted) { + return { + executable, + skipped, + deferred: { + _tag: 'AresApplyDeferred' as const, + application, + completed: [] as const, + correctionCandidates, + skipped, + }, + }; + } + if (!matchesObservation(selection, observation)) { + return { + executable, + skipped, + deferred: { + _tag: 'AresApplyDeferred' as const, + application: needsConfirmation(application, 'refreshed_observation_changed'), + completed: [] as const, + correctionCandidates, + skipped, + }, + }; + } + if (selectionRevisionChanged(selection, revision)) { + return { + executable, + skipped, + deferred: { + _tag: 'AresApplyDeferred' as const, + application: needsConfirmation(application, 'canonical_revision_changed'), + completed: [] as const, + correctionCandidates, + skipped, + }, + }; + } + executable.push(selection); + } + return { executable, skipped, deferred: null }; +}; + +const loadCanonicalSnapshot = Effect.fn('AresApply.loadCanonicalSnapshot')( + function* loadCanonicalSnapshotEffect( + request: AresApplyRequest, + observation: AresSubjectEvidence, + reads: AresApplyReads, + gateway: ReturnType, + options: AresApplyOptions, + decidedAt: string, + decisionEpochMillis: number, + clientOptions: { readonly baseUrl?: string | URL }, + ) { if (request.partyRef !== null) { const { partyRef } = request; const detail = yield* gateway.invoke( @@ -585,7 +692,7 @@ export const applyAresObservationWithActions = Effect.fn( ? [structuredAddress(point.value.address)] : [], ); - canonical = { + const canonical: AresCanonicalSnapshot = { archived: Option.isSome(party.archivedAt), displayName: Option.getOrNull(party.displayName), factEvidence: [ @@ -639,203 +746,186 @@ export const applyAresObservationWithActions = Effect.fn( partyType: party.partyType, registeredAddresses, }; - revision = loadedRevision; + return { canonical, revision: loadedRevision }; } - const application = yield* Effect.try({ - catch: (error) => - invalidSelection( - 'The trusted ARES evidence cannot be evaluated under the owner policy', - error, - ), - try: () => - AresApplication.deriveAresEvidenceApplication({ - canonical, - decidedAt, - evidence: observationInput, - selectedFacts: request.selections.map(({ fact }) => fact), - userConfirmed: request.userConfirmed, - }), - }); - const correctionCandidates = - canonical === null - ? [] - : AresApplication.deriveAresCorrectionReviewHandoffs(application, canonical); - const ageMillis = - DateTime.toEpochMillis(decisionTime) - DateTime.toEpochMillis(observation.observedAt); - const suppliedAgeMillis = - DateTime.toEpochMillis(decisionTime) - DateTime.toEpochMillis(supplied.observedAt); - if ( - ageMillis < 0 || - ageMillis > 300_000 || - suppliedAgeMillis < 0 || - suppliedAgeMillis > 300_000 || - DateTime.toEpochMillis(supplied.servedAt) > DateTime.toEpochMillis(decisionTime) - ) { - return { - _tag: 'AresApplyDeferred' as const, - application: needsConfirmation(application, 'observation_not_fresh'), - completed: [] as const, - correctionCandidates: [], - skipped: [] as const, - }; - } - if ( - suppliedInput.providerChangedOn !== observationInput.providerChangedOn || - suppliedInput.queryIco !== observationInput.queryIco || - request.selections.some((selection) => { - if (selection.route !== 'PARTY_CORRECTION') { - return !matchesObservation(selection, observation); - } - return selection.fact === 'BUSINESS_NAME' - ? suppliedInput.subject.businessName !== observationInput.subject.businessName - : suppliedInput.subject.ico !== observationInput.subject.ico; - }) - ) { - return { - _tag: 'AresApplyDeferred' as const, - application: needsConfirmation(application, 'refreshed_observation_changed'), - completed: [] as const, - correctionCandidates: [], - skipped: [], - }; - } - const executable: ExecutableSelection[] = []; - const skipped: AresSkippedAction[] = []; - for (const selection of request.selections) { - const decision = application.factDecisions.find(({ fact }) => fact === selection.fact); - if ( - selection.route === 'PARTY_CORRECTION' && - correctionCandidates.some( - (candidate) => - candidate.fact === selection.fact && - candidate.targetAssertionId !== selection.payload.targetAssertionId, - ) - ) { - return { - _tag: 'AresApplyDeferred' as const, - application: needsConfirmation(application, 'canonical_assertion_changed'), - completed: [] as const, - correctionCandidates: [], - skipped, - }; - } - if (selection.route === 'PARTY_CORRECTION' || decision === undefined) { - return { - _tag: 'AresApplyDeferred' as const, - application, - completed: [] as const, - correctionCandidates, - skipped, - }; - } - if (decision.outcome === 'NO_CHANGE' && matchesObservation(selection, observation)) { - skipped.push({ fact: selection.fact, reason: 'ALREADY_SATISFIED', route: selection.route }); - continue; - } - const permitted = - decision.outcome === 'APPLY_ENRICHMENT' && decision.route === selection.route; - if (!permitted) { - return { - _tag: 'AresApplyDeferred' as const, - application, - completed: [] as const, - correctionCandidates, - skipped, - }; - } - if (!matchesObservation(selection, observation)) { - return { - _tag: 'AresApplyDeferred' as const, - application: needsConfirmation(application, 'refreshed_observation_changed'), - completed: [] as const, - correctionCandidates, - skipped, - }; + return { canonical: null, revision: null }; + }, +); + +/** + * Refreshes provider evidence and canonical facts through governed Reads, evaluates the closed + * owner policy, then dispatches only explicitly selected standard Actions. Conflicts are deferred + * to the ordinary reviewed workflow; a caller-supplied route never proves historical error. + * Each Action persists its bounded external evidence and owns its independent idempotent commit. + */ +export const applyAresObservationWithActions = Effect.fn( + 'ActionGateway.applyAresObservationWithActions', +)(function* applyAresObservationWithActionsEffect( + request: AresApplyRequest, + invoker: PartyRegistryStandardActionInvoker, + options: AresApplyOptions = {}, +) { + if (request.selections.length === 0) { + return { + _tag: 'AresApplyNotRequested' as const, + completed: [] as const, + skipped: [] as const, + }; + } + const supplied = yield* validateRequest(request); + yield* validateSelectedValues(request, supplied); + const gateway = options.gateway ?? operationGateway; + const reads = options.reads ?? (yield* loadDefaultReads()); + const clientOptions = options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl }; + const loadedObservation = yield* gateway.invoke( + (authorization) => + reads.observation( + { ico: supplied.queryIco }, + authorization, + request.correlationId, + clientOptions, + ), + options.gatewayContext, + ); + const observation = yield* decodeReadObservation(loadedObservation).pipe( + Effect.mapError(invalidObservation), + ); + const [suppliedInput, observationInput] = yield* Effect.all( + [ + Schema.encodeEffect(AresSubjectEvidenceSchema)(supplied), + Schema.encodeEffect(AresSubjectEvidenceSchema)(observation), + ], + { concurrency: 2 }, + ).pipe( + Effect.mapError((error) => + invalidSelection('ARES observation cannot be encoded for policy evaluation', error), + ), + ); + const decisionTime = yield* DateTime.now; + const decisionEpochMillis = DateTime.toEpochMillis(decisionTime); + const decidedAt = DateTime.formatIso(decisionTime); + const { canonical, revision } = yield* loadCanonicalSnapshot( + request, + observation, + reads, + gateway, + options, + decidedAt, + decisionEpochMillis, + clientOptions, + ); + const application = yield* Effect.try({ + catch: (error) => + invalidSelection( + 'The trusted ARES evidence cannot be evaluated under the owner policy', + error, + ), + try: () => + AresApplication.deriveAresEvidenceApplication({ + canonical, + decidedAt, + evidence: observationInput, + selectedFacts: request.selections.map(({ fact }) => fact), + userConfirmed: request.userConfirmed, + }), + }); + const correctionCandidates = + canonical === null + ? [] + : AresApplication.deriveAresCorrectionReviewHandoffs(application, canonical); + if (observationIsStale(supplied, observation, decisionTime)) { + return { + _tag: 'AresApplyDeferred' as const, + application: needsConfirmation(application, 'observation_not_fresh'), + completed: [] as const, + correctionCandidates: [], + skipped: [] as const, + }; + } + if (refreshedObservationChanged(request, suppliedInput, observationInput, observation)) { + return { + _tag: 'AresApplyDeferred' as const, + application: needsConfirmation(application, 'refreshed_observation_changed'), + completed: [] as const, + correctionCandidates: [], + skipped: [], + }; + } + const { executable, skipped, deferred } = planSelectedActions( + request, + application, + observation, + correctionCandidates, + revision, + ); + if (deferred !== null) { + return deferred; + } + type PartialCompletion = Extract< + AresApplyOutcome, + { readonly _tag: 'AresApplyPartiallyCompleted' } + >; + interface ExecutionState { + readonly completed: readonly AresAppliedAction[]; + readonly partial: PartialCompletion | null; + } + const initial: ExecutionState = { completed: [], partial: null }; + const execution = yield* Effect.reduce( + executable, + () => initial, + (state, selection) => { + if (state.partial !== null) { + return Effect.succeed(state); } - if (selection.route === 'PARTY_UPDATE' && selection.payload.expectedRevision !== revision) { - return { - _tag: 'AresApplyDeferred' as const, - application: needsConfirmation(application, 'canonical_revision_changed'), - completed: [] as const, - correctionCandidates, - skipped, - }; + const decision = application.factDecisions.find(({ fact }) => fact === selection.fact); + if (decision === undefined) { + return Effect.fail(invalidSelection('Selected fact has no owner decision')); } - executable.push(selection); - } - type PartialCompletion = Extract< - AresApplyOutcome, - { readonly _tag: 'AresApplyPartiallyCompleted' } - >; - interface ExecutionState { - readonly completed: readonly AresAppliedAction[]; - readonly partial: PartialCompletion | null; - } - const initial: ExecutionState = { completed: [], partial: null }; - const execution = yield* Effect.reduce( - executable, - () => initial, - (state, selection) => { - if (state.partial !== null) { - return Effect.succeed(state); - } - const decision = application.factDecisions.find(({ fact }) => fact === selection.fact); - if (decision === undefined) { - return Effect.fail(invalidSelection('Selected fact has no owner decision')); - } - // Logical as-of time of the confirmed observation, stable across delivery retries. - // The standard Action records its trusted actual acceptance time independently. - const evidence = AresApplication.makeAresAppliedEvidence( - { ...application, decidedAt: supplied.servedAt, evidence: supplied }, - decision, - ); - return invokeSelection( - selection, - evidence, - invoker, - gateway, - options.gatewayContext ?? {}, - { - ...clientOptions, - correlationId: request.correlationId, - idempotencyKey: selection.idempotencyKey, - }, - ).pipe( - Effect.result, - Effect.map((attempt): ExecutionState => { - if ('failure' in attempt) { - return { + // Logical as-of time of the confirmed observation, stable across delivery retries. + // The standard Action records its trusted actual acceptance time independently. + const evidence = AresApplication.makeAresAppliedEvidence( + { ...application, decidedAt: supplied.servedAt, evidence: supplied }, + decision, + ); + return invokeSelection(selection, evidence, invoker, gateway, options.gatewayContext ?? {}, { + ...clientOptions, + correlationId: request.correlationId, + idempotencyKey: selection.idempotencyKey, + }).pipe( + Effect.result, + Effect.map((attempt): ExecutionState => { + if ('failure' in attempt) { + return { + completed: state.completed, + partial: { + _tag: 'AresApplyPartiallyCompleted' as const, + application, completed: state.completed, - partial: { - _tag: 'AresApplyPartiallyCompleted' as const, - application, - completed: state.completed, - failed: { - error: attempt.failure, - fact: selection.fact, - idempotencyKey: selection.idempotencyKey, - recovery: 'RESOLVE_STANDARD_ACTION_BEFORE_RETRY' as const, - route: selection.route, - }, - skipped, + failed: { + error: attempt.failure, + fact: selection.fact, + idempotencyKey: selection.idempotencyKey, + recovery: 'RESOLVE_STANDARD_ACTION_BEFORE_RETRY' as const, + route: selection.route, }, - }; - } - return { completed: [...state.completed, attempt.success], partial: null }; - }), - ); - }, - ); - return ( - execution.partial ?? { - _tag: 'AresApplyCompleted' as const, - application, - completed: execution.completed, - skipped, - } - ); - }, -); + skipped, + }, + }; + } + return { completed: [...state.completed, attempt.success], partial: null }; + }), + ); + }, + ); + return ( + execution.partial ?? { + _tag: 'AresApplyCompleted' as const, + application, + completed: execution.completed, + skipped, + } + ); +}); /** Production coordinator: mutations use only the explicit, authenticated standard command API. */ export const applyAresObservation = ( diff --git a/app/verticals/party-registry/src/api/counterparty-read.read.ts b/app/verticals/party-registry/src/api/counterparty-read.read.ts index cbe6aa538..04cf17692 100644 --- a/app/verticals/party-registry/src/api/counterparty-read.read.ts +++ b/app/verticals/party-registry/src/api/counterparty-read.read.ts @@ -19,7 +19,7 @@ import type { CounterpartyPersistenceUnavailable } from '../../shared/domain/cou import { findCounterpartyRecord } from '../services/counterparty-persistence.service.ts'; import type { LookupResult } from '../services/counterparty-persistence.service.ts'; -export const counterpartyReadEntrypoint = defineTenantModuleEntrypoint({ +const counterpartyReadEntrypoint = defineTenantModuleEntrypoint({ access: 'read', authorization: { kind: 'context_permission', permission: 'module.access' }, entrypointKey: 'party.registry.api.counterparty-read', diff --git a/app/verticals/party-registry/src/api/counterparty-role-history.read.ts b/app/verticals/party-registry/src/api/counterparty-role-history.read.ts index 251635ac7..7eccda583 100644 --- a/app/verticals/party-registry/src/api/counterparty-role-history.read.ts +++ b/app/verticals/party-registry/src/api/counterparty-role-history.read.ts @@ -19,7 +19,7 @@ import type { CounterpartyPersistenceUnavailable } from '../../shared/domain/cou import { listCounterpartyRoleHistory } from '../services/counterparty-persistence.service.ts'; import type { LookupResult } from '../services/counterparty-persistence.service.ts'; -export const counterpartyRoleHistoryEntrypoint = defineTenantModuleEntrypoint({ +const counterpartyRoleHistoryEntrypoint = defineTenantModuleEntrypoint({ access: 'read', authorization: { kind: 'context_permission', permission: 'module.access' }, entrypointKey: 'party.registry.api.counterparty-role-history', diff --git a/app/verticals/party-registry/src/api/duplicate-candidate-detail.read.ts b/app/verticals/party-registry/src/api/duplicate-candidate-detail.read.ts index 61f5ff09d..e57e8078a 100644 --- a/app/verticals/party-registry/src/api/duplicate-candidate-detail.read.ts +++ b/app/verticals/party-registry/src/api/duplicate-candidate-detail.read.ts @@ -13,7 +13,7 @@ import { } from '../../shared/apis/duplicate-candidate-detail.ts'; import { findDuplicateCandidateCase } from '../services/party-matching-persistence.service.ts'; -export const duplicateCandidateDetailEntrypoint = defineTenantModuleEntrypoint({ +const duplicateCandidateDetailEntrypoint = defineTenantModuleEntrypoint({ access: 'read', authorization: { kind: 'context_permission', permission: 'module.access' }, entrypointKey: 'party.registry.api.duplicate-candidate-detail', diff --git a/app/verticals/party-registry/src/api/engagement-profile-client.ts b/app/verticals/party-registry/src/api/engagement-profile-client.ts index e75052aff..e6941a2cb 100644 --- a/app/verticals/party-registry/src/api/engagement-profile-client.ts +++ b/app/verticals/party-registry/src/api/engagement-profile-client.ts @@ -15,7 +15,7 @@ import type { PartyRegistryReadiness, PersonEngagementLifecyclePayload, } from '../../shared/api.ts'; -import { actionGateway } from './action-gateway.ts'; +import { operationGateway } from './action-gateway.ts'; import { authenticatePartyRegistryHttpRequest, createPartyRegistryHttpClient, @@ -62,7 +62,7 @@ const invoke = ( context: OperationContext, operation: (client: ContactsClient) => Effect.Effect, ) => - actionGateway.invoke((authorization) => { + operationGateway.invoke((authorization) => { const operationContext = options.operationContext ?? context; const requestContext = authenticatePartyRegistryHttpRequest( partyRegistryHttpRequestContext({ ...options, operationContext }), diff --git a/app/verticals/party-registry/src/api/organization-engagement-profile.read.ts b/app/verticals/party-registry/src/api/organization-engagement-profile.read.ts index f311800f8..e4b9e5655 100644 --- a/app/verticals/party-registry/src/api/organization-engagement-profile.read.ts +++ b/app/verticals/party-registry/src/api/organization-engagement-profile.read.ts @@ -19,7 +19,7 @@ import type { import { findOrganizationEngagementProfile } from '../services/engagement-profile-persistence.service.ts'; import type { LookupResult } from '../services/engagement-profile-persistence.service.ts'; -export const organizationEngagementProfileEntrypoint = defineTenantModuleEntrypoint({ +const organizationEngagementProfileEntrypoint = defineTenantModuleEntrypoint({ access: 'read', authorization: { kind: 'context_permission', permission: 'module.access' }, entrypointKey: 'party.registry.api.organization-engagement-profile', diff --git a/app/verticals/party-registry/src/api/party-command-client.ts b/app/verticals/party-registry/src/api/party-command-client.ts index 23cfa1e07..7f090a9a9 100644 --- a/app/verticals/party-registry/src/api/party-command-client.ts +++ b/app/verticals/party-registry/src/api/party-command-client.ts @@ -166,7 +166,7 @@ const invoke = ( operation: (gatewayAssertion: string) => Effect.Effect, ) => Effect.promise(() => import('./action-gateway.ts')).pipe( - Effect.flatMap(({ actionGateway }) => actionGateway.invoke(operation, options.gateway)), + Effect.flatMap(({ operationGateway }) => operationGateway.invoke(operation, options.gateway)), ); export const resolvePartyCommandCommitWithAuthorization = ( diff --git a/app/verticals/party-registry/src/api/party-contact-point-detail.read.ts b/app/verticals/party-registry/src/api/party-contact-point-detail.read.ts index 1cf26636d..89f3f42e2 100644 --- a/app/verticals/party-registry/src/api/party-contact-point-detail.read.ts +++ b/app/verticals/party-registry/src/api/party-contact-point-detail.read.ts @@ -15,7 +15,7 @@ import type { PartyContactPoint } from '../../shared/domain/contact-point.ts'; import type { PartyContactPointPersistenceUnavailable } from '../../shared/domain/contact-point-errors.ts'; import { findPartyContactPointRecord } from '../services/party-contact-point-persistence.service.ts'; -export const partyContactPointDetailEntrypoint = defineTenantModuleEntrypoint({ +const partyContactPointDetailEntrypoint = defineTenantModuleEntrypoint({ access: 'read', authorization: { kind: 'context_permission', permission: 'module.access' }, entrypointKey: 'party.registry.api.party-contact-point-detail', diff --git a/app/verticals/party-registry/src/api/party-contact-points.read.ts b/app/verticals/party-registry/src/api/party-contact-points.read.ts index 74da165a6..34369ffd5 100644 --- a/app/verticals/party-registry/src/api/party-contact-points.read.ts +++ b/app/verticals/party-registry/src/api/party-contact-points.read.ts @@ -17,7 +17,7 @@ import type { import type { PartyContactPointPersistenceUnavailable } from '../../shared/domain/contact-point-errors.ts'; import { listPartyContactPointRecords } from '../services/party-contact-point-persistence.service.ts'; -export const partyContactPointsEntrypoint = defineTenantModuleEntrypoint({ +const partyContactPointsEntrypoint = defineTenantModuleEntrypoint({ access: 'read', authorization: { kind: 'context_permission', permission: 'module.access' }, entrypointKey: 'party.registry.api.party-contact-points', diff --git a/app/verticals/party-registry/src/api/party-correction.read.ts b/app/verticals/party-registry/src/api/party-correction.read.ts index 977b9e7f0..0c833635b 100644 --- a/app/verticals/party-registry/src/api/party-correction.read.ts +++ b/app/verticals/party-registry/src/api/party-correction.read.ts @@ -13,7 +13,7 @@ import { } from '../../shared/apis/party-correction.ts'; import { findPartyCorrection } from '../services/party-correction.service.ts'; -export const partyCorrectionEntrypoint = defineTenantModuleEntrypoint({ +const partyCorrectionEntrypoint = defineTenantModuleEntrypoint({ authorization: { kind: 'context_permission', permission: 'module.access' }, access: 'historical_read', entrypointKey: 'party.registry.api.party-correction', diff --git a/app/verticals/party-registry/src/api/party-detail.read.ts b/app/verticals/party-registry/src/api/party-detail.read.ts index 2a72c9872..5a47dc1f2 100644 --- a/app/verticals/party-registry/src/api/party-detail.read.ts +++ b/app/verticals/party-registry/src/api/party-detail.read.ts @@ -106,7 +106,7 @@ export const readPartyDetailFromServices = Effect.fn('PartyDetailRead.readPartyD }, ); -export const partyDetailEntrypoint = defineTenantModuleEntrypoint({ +const partyDetailEntrypoint = defineTenantModuleEntrypoint({ authorization: { kind: 'context_permission', permission: 'module.access' }, access: 'read', entrypointKey: 'party.registry.api.party-detail', diff --git a/app/verticals/party-registry/src/api/party-match-decision.read.ts b/app/verticals/party-registry/src/api/party-match-decision.read.ts index 0ee4b67a6..30fa4b7b1 100644 --- a/app/verticals/party-registry/src/api/party-match-decision.read.ts +++ b/app/verticals/party-registry/src/api/party-match-decision.read.ts @@ -13,7 +13,7 @@ import { } from '../../shared/apis/party-match-decision.ts'; import { findMatchDecision } from '../services/party-matching-persistence.service.ts'; -export const partyMatchDecisionEntrypoint = defineTenantModuleEntrypoint({ +const partyMatchDecisionEntrypoint = defineTenantModuleEntrypoint({ authorization: { kind: 'context_permission', permission: 'module.access' }, access: 'read', entrypointKey: 'party.registry.api.party-match-decision', diff --git a/app/verticals/party-registry/src/api/party-match.read.ts b/app/verticals/party-registry/src/api/party-match.read.ts index 64dcb2bf5..917c665dd 100644 --- a/app/verticals/party-registry/src/api/party-match.read.ts +++ b/app/verticals/party-registry/src/api/party-match.read.ts @@ -13,7 +13,7 @@ import { import type { PartyCandidate } from '../../shared/domain/identity-contracts.ts'; import { previewPartyMatch } from '../services/party-matching-persistence.service.ts'; -export const partyMatchEntrypoint = defineTenantModuleEntrypoint({ +const partyMatchEntrypoint = defineTenantModuleEntrypoint({ authorization: { kind: 'context_permission', permission: 'module.access' }, access: 'read', entrypointKey: 'party.registry.api.party-match', diff --git a/app/verticals/party-registry/src/api/party-merge-readiness.read.ts b/app/verticals/party-registry/src/api/party-merge-readiness.read.ts index 7eb08d2d9..b974127f9 100644 --- a/app/verticals/party-registry/src/api/party-merge-readiness.read.ts +++ b/app/verticals/party-registry/src/api/party-merge-readiness.read.ts @@ -7,7 +7,7 @@ import { } from '../../shared/apis/party-merge-readiness.ts'; import { evaluateDisabledMergeReadiness } from '../merge/merge-readiness.ts'; -export const partyMergeReadinessEntrypoint = defineTenantModuleEntrypoint({ +const partyMergeReadinessEntrypoint = defineTenantModuleEntrypoint({ authorization: { kind: 'context_permission', permission: 'module.access' }, access: 'read', entrypointKey: 'party.registry.api.party-merge-readiness', diff --git a/app/verticals/party-registry/src/api/party-official-identifier-detail.read.ts b/app/verticals/party-registry/src/api/party-official-identifier-detail.read.ts index bfc4ce617..d0afdac70 100644 --- a/app/verticals/party-registry/src/api/party-official-identifier-detail.read.ts +++ b/app/verticals/party-registry/src/api/party-official-identifier-detail.read.ts @@ -13,7 +13,7 @@ import { } from '../../shared/apis/party-official-identifier-detail.ts'; import { findOfficialIdentifierRecord } from '../services/party-official-identifier-persistence.service.ts'; -export const partyOfficialIdentifierDetailEntrypoint = defineTenantModuleEntrypoint({ +const partyOfficialIdentifierDetailEntrypoint = defineTenantModuleEntrypoint({ authorization: { kind: 'context_permission', permission: 'module.access' }, access: 'read', entrypointKey: 'party.registry.api.party-official-identifier-detail', diff --git a/app/verticals/party-registry/src/api/party-official-identifier-history.read.ts b/app/verticals/party-registry/src/api/party-official-identifier-history.read.ts index f91fe78f7..1baf9c04c 100644 --- a/app/verticals/party-registry/src/api/party-official-identifier-history.read.ts +++ b/app/verticals/party-registry/src/api/party-official-identifier-history.read.ts @@ -12,7 +12,7 @@ import { } from '../../shared/apis/party-official-identifier-history.ts'; import { listOfficialIdentifierHistory } from '../services/party-official-identifier-persistence.service.ts'; -export const partyOfficialIdentifierHistoryEntrypoint = defineTenantModuleEntrypoint({ +const partyOfficialIdentifierHistoryEntrypoint = defineTenantModuleEntrypoint({ authorization: { kind: 'context_permission', permission: 'module.access' }, access: 'historical_read', entrypointKey: 'party.registry.api.party-official-identifier-history', diff --git a/app/verticals/party-registry/src/api/party-registry-http-client.ts b/app/verticals/party-registry/src/api/party-registry-http-client.ts index 8a9dee86f..5376b6e82 100644 --- a/app/verticals/party-registry/src/api/party-registry-http-client.ts +++ b/app/verticals/party-registry/src/api/party-registry-http-client.ts @@ -21,7 +21,7 @@ export type PartyRegistryHttpClient = HttpApiClient.Client< Extract >; -export const traceparentOption = 'traceparent' as const; +const traceparentOption = 'traceparent' as const; const requestCorrelationHeaderName = 'x-correlation-id' as const; export interface PartyRegistryHttpClientOptions { diff --git a/app/verticals/party-registry/src/api/party-relationship-detail.read.ts b/app/verticals/party-registry/src/api/party-relationship-detail.read.ts index 11f1bcdee..d90f9789d 100644 --- a/app/verticals/party-registry/src/api/party-relationship-detail.read.ts +++ b/app/verticals/party-registry/src/api/party-relationship-detail.read.ts @@ -18,7 +18,7 @@ import type { } from '../../shared/domain/relationship-contract.ts'; import { findPartyRelationshipRecord } from '../services/party-relationship-persistence.service.ts'; -export const partyRelationshipDetailEntrypoint = defineTenantModuleEntrypoint({ +const partyRelationshipDetailEntrypoint = defineTenantModuleEntrypoint({ authorization: { kind: 'context_permission', permission: 'module.access' }, access: 'read', entrypointKey: 'party.registry.api.party-relationship-detail', diff --git a/app/verticals/party-registry/src/api/person-engagement-profile.read.ts b/app/verticals/party-registry/src/api/person-engagement-profile.read.ts index baa1af533..1eeeef01d 100644 --- a/app/verticals/party-registry/src/api/person-engagement-profile.read.ts +++ b/app/verticals/party-registry/src/api/person-engagement-profile.read.ts @@ -19,7 +19,7 @@ import type { import { findPersonEngagementProfile } from '../services/engagement-profile-persistence.service.ts'; import type { LookupResult } from '../services/engagement-profile-persistence.service.ts'; -export const personEngagementProfileEntrypoint = defineTenantModuleEntrypoint({ +const personEngagementProfileEntrypoint = defineTenantModuleEntrypoint({ access: 'read', authorization: { kind: 'context_permission', permission: 'module.access' }, entrypointKey: 'party.registry.api.person-engagement-profile', diff --git a/app/verticals/party-registry/src/db/engagement-schema.ts b/app/verticals/party-registry/src/db/engagement-schema.ts index 44f6211da..64b6d7392 100644 --- a/app/verticals/party-registry/src/db/engagement-schema.ts +++ b/app/verticals/party-registry/src/db/engagement-schema.ts @@ -122,7 +122,7 @@ export const gatewayAssertionRedemptions = contactsSchema.table( ], ); -export const contactsDatabaseSchema = { +const contactsDatabaseSchema = { gatewayAssertionRedemptions, organizationEngagementProfiles, personEngagementProfiles, @@ -136,10 +136,7 @@ export const CONTACTS_TABLES = [ export type OrganizationEngagementProfileRecord = typeof organizationEngagementProfiles.$inferSelect; -export type NewOrganizationEngagementProfileRecord = - typeof organizationEngagementProfiles.$inferInsert; export type PersonEngagementProfileRecord = typeof personEngagementProfiles.$inferSelect; -export type NewPersonEngagementProfileRecord = typeof personEngagementProfiles.$inferInsert; /** Relational Queries v2 entry point for the Contacts owner. */ export const contactsRelations = defineRelations(contactsDatabaseSchema); diff --git a/app/verticals/party-registry/src/db/engagement-types.ts b/app/verticals/party-registry/src/db/engagement-types.ts index bb8d12a8a..24708f274 100644 --- a/app/verticals/party-registry/src/db/engagement-types.ts +++ b/app/verticals/party-registry/src/db/engagement-types.ts @@ -1,7 +1,7 @@ import type { EffectPgDatabase } from 'drizzle-orm/effect-postgres'; import type { contactsRelations } from './engagement-schema.ts'; -export type ContactsDatabaseExecutor = EffectPgDatabase; +type ContactsDatabaseExecutor = EffectPgDatabase; type ContactsTransactionCallback = Parameters[0]; diff --git a/app/verticals/party-registry/src/db/schema.ts b/app/verticals/party-registry/src/db/schema.ts index 1ce8a9cee..4a6669f58 100644 --- a/app/verticals/party-registry/src/db/schema.ts +++ b/app/verticals/party-registry/src/db/schema.ts @@ -59,6 +59,40 @@ const updatedAt = () => timestamp('updated_at', { withTimezone: true }).defaultN const recordedAt = () => timestamp('recorded_at', { withTimezone: true }).defaultNow().notNull(); const validFrom = () => timestamp('valid_from', { withTimezone: true }).notNull(); const validTo = () => timestamp('valid_to', { withTimezone: true }); +const provenanceColumns = () => ({ + provenanceSource: text('provenance_source').notNull(), + provenanceMethod: text('provenance_method').notNull(), + externalEvidence: jsonb('external_evidence').$type(), + provenanceAuthoritative: boolean('provenance_authoritative').default(false).notNull(), + evidenceReference: text('evidence_reference'), +}); + +const verificationColumns = () => ({ + verificationState: text('verification_state').default('UNVERIFIED').notNull(), + verificationMethod: text('verification_method'), + verifierReference: text('verifier_reference'), + verifiedByPrincipalId: uuid('verified_by_principal_id'), + verifiedAt: timestamp('verified_at', { withTimezone: true }), + acceptedByActionInvocationId: uuid('accepted_by_action_invocation_id').notNull(), + acceptedByPrincipalId: uuid('accepted_by_principal_id').notNull(), + policyVersion: text('policy_version').notNull(), +}); + +const endedPeriodColumns = () => ({ + validFrom: validFrom(), + validTo: validTo(), + recordedAt: recordedAt(), + state: text('state').default('ACTIVE').notNull(), + isCurrent: boolean('is_current').default(true).notNull(), + endReason: text('end_reason'), + endProvenanceSource: text('end_provenance_source'), + endProvenanceMethod: text('end_provenance_method'), + endEvidenceRefs: jsonb('end_evidence_refs').$type(), + endedByActionInvocationId: uuid('ended_by_action_invocation_id'), + endedByPrincipalId: uuid('ended_by_principal_id'), + endedRecordedAt: timestamp('ended_recorded_at', { withTimezone: true }), +}); + const enableGovernedRls = (table: { readonly enableRLS: () => Table }): Table => table.enableRLS(); @@ -175,19 +209,8 @@ export const partyFactAssertions = enableGovernedRls( recordedAt: recordedAt(), state: text('state').default('ACTIVE').notNull(), isCurrent: boolean('is_current').default(true).notNull(), - provenanceSource: text('provenance_source').notNull(), - provenanceMethod: text('provenance_method').notNull(), - externalEvidence: jsonb('external_evidence').$type(), - provenanceAuthoritative: boolean('provenance_authoritative').default(false).notNull(), - evidenceReference: text('evidence_reference'), - verificationState: text('verification_state').default('UNVERIFIED').notNull(), - verificationMethod: text('verification_method'), - verifierReference: text('verifier_reference'), - verifiedByPrincipalId: uuid('verified_by_principal_id'), - verifiedAt: timestamp('verified_at', { withTimezone: true }), - acceptedByActionInvocationId: uuid('accepted_by_action_invocation_id').notNull(), - acceptedByPrincipalId: uuid('accepted_by_principal_id').notNull(), - policyVersion: text('policy_version').notNull(), + ...provenanceColumns(), + ...verificationColumns(), supersedesAssertionId: uuid('supersedes_assertion_id'), retractsAssertionId: uuid('retracts_assertion_id'), }, @@ -388,35 +411,13 @@ export const partyContactPoints = enableGovernedRls( privacyClassification: text('privacy_classification').notNull(), preferred: boolean('preferred').default(false).notNull(), revision: integer('revision').default(1).notNull(), - validFrom: validFrom(), - validTo: validTo(), - recordedAt: recordedAt(), - state: text('state').default('ACTIVE').notNull(), - isCurrent: boolean('is_current').default(true).notNull(), - endReason: text('end_reason'), - endProvenanceSource: text('end_provenance_source'), - endProvenanceMethod: text('end_provenance_method'), - endEvidenceRefs: jsonb('end_evidence_refs').$type(), - endedByActionInvocationId: uuid('ended_by_action_invocation_id'), - endedByPrincipalId: uuid('ended_by_principal_id'), - endedRecordedAt: timestamp('ended_recorded_at', { withTimezone: true }), - provenanceSource: text('provenance_source').notNull(), - provenanceMethod: text('provenance_method').notNull(), - externalEvidence: jsonb('external_evidence').$type(), - provenanceAuthoritative: boolean('provenance_authoritative').default(false).notNull(), - evidenceReference: text('evidence_reference'), + ...endedPeriodColumns(), + ...provenanceColumns(), additionalEvidenceRefs: jsonb('additional_evidence_refs') .$type() .default([]) .notNull(), - verificationState: text('verification_state').default('UNVERIFIED').notNull(), - verificationMethod: text('verification_method'), - verifierReference: text('verifier_reference'), - verifiedByPrincipalId: uuid('verified_by_principal_id'), - verifiedAt: timestamp('verified_at', { withTimezone: true }), - acceptedByActionInvocationId: uuid('accepted_by_action_invocation_id').notNull(), - acceptedByPrincipalId: uuid('accepted_by_principal_id').notNull(), - policyVersion: text('policy_version').notNull(), + ...verificationColumns(), supersedesContactPointId: uuid('supersedes_contact_point_id'), retractsContactPointId: uuid('retracts_contact_point_id'), }, @@ -504,31 +505,9 @@ export const partyContactPointPurposes = enableGovernedRls( registryContext: text('registry_context').default('GENERAL').notNull(), jurisdiction: text('jurisdiction').default('ZZ').notNull(), preferred: boolean('preferred').default(false).notNull(), - validFrom: validFrom(), - validTo: validTo(), - recordedAt: recordedAt(), - state: text('state').default('ACTIVE').notNull(), - isCurrent: boolean('is_current').default(true).notNull(), - endReason: text('end_reason'), - endProvenanceSource: text('end_provenance_source'), - endProvenanceMethod: text('end_provenance_method'), - endEvidenceRefs: jsonb('end_evidence_refs').$type(), - endedByActionInvocationId: uuid('ended_by_action_invocation_id'), - endedByPrincipalId: uuid('ended_by_principal_id'), - endedRecordedAt: timestamp('ended_recorded_at', { withTimezone: true }), - provenanceSource: text('provenance_source').notNull(), - provenanceMethod: text('provenance_method').notNull(), - externalEvidence: jsonb('external_evidence').$type(), - provenanceAuthoritative: boolean('provenance_authoritative').default(false).notNull(), - evidenceReference: text('evidence_reference'), - verificationState: text('verification_state').default('UNVERIFIED').notNull(), - verificationMethod: text('verification_method'), - verifierReference: text('verifier_reference'), - verifiedByPrincipalId: uuid('verified_by_principal_id'), - verifiedAt: timestamp('verified_at', { withTimezone: true }), - acceptedByActionInvocationId: uuid('accepted_by_action_invocation_id').notNull(), - acceptedByPrincipalId: uuid('accepted_by_principal_id').notNull(), - policyVersion: text('policy_version').notNull(), + ...endedPeriodColumns(), + ...provenanceColumns(), + ...verificationColumns(), revision: integer('revision').default(1).notNull(), }, (table) => [ diff --git a/app/verticals/party-registry/src/federation/page-contacts.tsx b/app/verticals/party-registry/src/federation/page-contacts.tsx index 1640d1d8a..a2cc06e91 100644 --- a/app/verticals/party-registry/src/federation/page-contacts.tsx +++ b/app/verticals/party-registry/src/federation/page-contacts.tsx @@ -1,6 +1,6 @@ import { FederatedI18nBoundary } from '@modern-js/plugin-i18n/runtime'; import { partyRegistryI18nResources } from '../i18n/resources'; -import { ContactsPage } from '../routes/[lang]/contacts/page'; +import ContactsPage from '../routes/[lang]/contacts/page'; const ContactsFederatedPage = () => ( reason: 'ARES returned an unsupported subject response', }); -const classifyStatus = (status: number): AresSubjectError => { - switch (status) { - case 400: { - return responseInvalid(); - } - case 401: - case 403: { - return denied(); - } - case 404: { - return notFound(); - } - case 408: - case 425: - case 429: { - return throttled(); - } - case 500: - case 502: - case 503: - case 504: { - return unavailable(); - } - default: { - return responseInvalid(); - } - } -}; +const statusFailures = new Map AresSubjectError>([ + [400, responseInvalid], + [401, denied], + [403, denied], + [404, notFound], + [408, throttled], + [425, throttled], + [429, throttled], + [500, unavailable], + [502, unavailable], + [503, unavailable], + [504, unavailable], +]); + +const classifyStatus = (status: number): AresSubjectError => + (statusFailures.get(status) ?? responseInvalid)(); const AresRetryableErrorSchema = Schema.Union([ AresSubjectThrottled, diff --git a/app/verticals/party-registry/src/merge/canonical-survivor-selection.ts b/app/verticals/party-registry/src/merge/canonical-survivor-selection.ts index fc2d52fbf..6ffe261c5 100644 --- a/app/verticals/party-registry/src/merge/canonical-survivor-selection.ts +++ b/app/verticals/party-registry/src/merge/canonical-survivor-selection.ts @@ -16,7 +16,7 @@ import { PartyRefSchema } from '../../shared/resources/party.ts'; import type { PartyRef } from '../../shared/resources/party.ts'; import { Schema } from 'effect'; -export const CanonicalSurvivorSelectionSchema = Schema.Union([ +const CanonicalSurvivorSelectionSchema = Schema.Union([ Schema.TaggedStruct('CanonicalSurvivorSelected', { confirmedDuplicateDecisionId: Schema.toEncoded(ConfirmedDuplicateDecisionIdSchema), decidingCriterion: MergeSurvivorSelectionReasonSchema, @@ -112,6 +112,101 @@ const criteria = [ reason: MergeSurvivorSelectionReason; }>[]; +const selectionEvidence = ( + candidates: readonly MergeSurvivorCandidate[], + confirmation: ConfirmedDuplicateSet, + survivor: MergeSurvivorCandidate, + decidingCriterion: MergeSurvivorSelectionReason, +): readonly MergeSelectionEvidenceStep[] => { + const decidingIndex = criteria.findIndex(({ reason }) => reason === decidingCriterion); + const evidenceChain: MergeSelectionEvidenceStep[] = [ + evidenceStep( + candidates, + 'CONFIRMED_DUPLICATE_SET', + confirmation.evidenceRefs, + candidates, + candidates, + `Decision ${confirmation.confirmedDuplicateDecisionId} confirms the same-subject Party set.`, + null, + ), + evidenceStep( + candidates, + 'IDENTITY_SAFETY', + confirmation.evidenceRefs, + candidates, + candidates, + 'No unresolved authoritative identity conflict blocks survivor selection.', + null, + ), + ]; + const evaluatedCriteria = decidingIndex === -1 ? criteria : criteria.slice(0, decidingIndex + 1); + let eligible: readonly MergeSurvivorCandidate[] = candidates; + for (const { reason, compare } of evaluatedCriteria) { + const retained = eligible.filter((candidate) => compare(candidate, survivor) === 0); + evidenceChain.push( + evidenceStep( + candidates, + reason, + confirmation.evidenceRefs, + eligible, + retained, + `${retained.length} of ${eligible.length} eligible candidates remain after ${reason}; ${eligible.length - retained.length} eliminated.`, + reason === decidingCriterion ? survivor.partyRef : null, + ), + ); + eligible = retained; + } + if (decidingCriterion === 'STABLE_RESOURCE_IDENTITY') { + evidenceChain.push( + evidenceStep( + candidates, + decidingCriterion, + confirmation.evidenceRefs, + eligible, + [survivor], + `${survivor.partyRef.resourceId} wins the final stable identity tie-break among ${eligible.length} eligible candidates.`, + survivor.partyRef, + ), + ); + } + + return Object.freeze(evidenceChain); +}; + +const confirmationMatchesCandidates = ( + confirmation: ConfirmedDuplicateSet, + candidateKeys: readonly string[], +): boolean => { + if (!confirmationHasEvidence(confirmation)) { + return false; + } + const confirmedKeys = confirmation.confirmedPartyRefs + .map(({ tenantId, resourceId }) => `${tenantId}:${resourceId}`) + .toSorted(); + const sortedCandidates = candidateKeys.toSorted(); + return ( + confirmedKeys.length === sortedCandidates.length && + confirmedKeys.every((key, index) => key === sortedCandidates[index]) + ); +}; + +const compareCandidates = (left: MergeSurvivorCandidate, right: MergeSurvivorCandidate) => { + for (const { compare } of criteria) { + const difference = compare(left, right); + if (difference !== 0) { + return difference; + } + } + return left.partyRef.resourceId.localeCompare(right.partyRef.resourceId); +}; + +const findDecidingCriterion = ( + survivor: MergeSurvivorCandidate, + runnerUp: MergeSurvivorCandidate, +): MergeSurvivorSelectionReason => + criteria.find(({ compare }) => compare(survivor, runnerUp) !== 0)?.reason ?? + 'STABLE_RESOURCE_IDENTITY'; + export const selectCanonicalSurvivor = ( input: MergeSurvivorSelectionInput, ): CanonicalSurvivorSelection => { @@ -141,15 +236,7 @@ export const selectCanonicalSurvivor = ( conflictingPartyRefs: candidates.map(({ partyRef }) => partyRef), }; } - const confirmedKeys = confirmation?.confirmedPartyRefs.map( - ({ tenantId, resourceId }) => `${tenantId}:${resourceId}`, - ); - if ( - confirmation === null || - !confirmationHasEvidence(confirmation) || - confirmedKeys?.length !== candidateKeys.length || - confirmedKeys.toSorted().some((key, index) => key !== candidateKeys.toSorted()[index]) - ) { + if (confirmation === null || !confirmationMatchesCandidates(confirmation, candidateKeys)) { return { _tag: 'SurvivorSelectionBlocked', blocker: 'DUPLICATE_SET_NOT_CONFIRMED', @@ -175,15 +262,7 @@ export const selectCanonicalSurvivor = ( }; } - const ordered = candidates.toSorted((left, right) => { - for (const { compare } of criteria) { - const difference = compare(left, right); - if (difference !== 0) { - return difference; - } - } - return left.partyRef.resourceId.localeCompare(right.partyRef.resourceId); - }); + const ordered = candidates.toSorted(compareCandidates); const [survivor, runnerUp] = ordered; if (survivor === undefined || runnerUp === undefined) { return { @@ -192,66 +271,13 @@ export const selectCanonicalSurvivor = ( conflictingPartyRefs: candidates.map(({ partyRef }) => partyRef), }; } - const decidingCriterion = - criteria.find(({ compare }) => compare(survivor, runnerUp) !== 0)?.reason ?? - 'STABLE_RESOURCE_IDENTITY'; - const decidingIndex = criteria.findIndex(({ reason }) => reason === decidingCriterion); - const evidenceChain: MergeSelectionEvidenceStep[] = [ - evidenceStep( - candidates, - 'CONFIRMED_DUPLICATE_SET', - confirmation.evidenceRefs, - candidates, - candidates, - `Decision ${confirmation.confirmedDuplicateDecisionId} confirms the same-subject Party set.`, - null, - ), - evidenceStep( - candidates, - 'IDENTITY_SAFETY', - confirmation.evidenceRefs, - candidates, - candidates, - 'No unresolved authoritative identity conflict blocks survivor selection.', - null, - ), - ]; - const evaluatedCriteria = decidingIndex === -1 ? criteria : criteria.slice(0, decidingIndex + 1); - let eligible: readonly MergeSurvivorCandidate[] = candidates; - for (const { reason, compare } of evaluatedCriteria) { - const retained = eligible.filter((candidate) => compare(candidate, survivor) === 0); - evidenceChain.push( - evidenceStep( - candidates, - reason, - confirmation.evidenceRefs, - eligible, - retained, - `${retained.length} of ${eligible.length} eligible candidates remain after ${reason}; ${eligible.length - retained.length} eliminated.`, - reason === decidingCriterion ? survivor.partyRef : null, - ), - ); - eligible = retained; - } - if (decidingCriterion === 'STABLE_RESOURCE_IDENTITY') { - evidenceChain.push( - evidenceStep( - candidates, - decidingCriterion, - confirmation.evidenceRefs, - eligible, - [survivor], - `${survivor.partyRef.resourceId} wins the final stable identity tie-break among ${eligible.length} eligible candidates.`, - survivor.partyRef, - ), - ); - } + const decidingCriterion = findDecidingCriterion(survivor, runnerUp); return { _tag: 'CanonicalSurvivorSelected', confirmedDuplicateDecisionId: confirmation.confirmedDuplicateDecisionId, decidingCriterion, - evidenceChain: Object.freeze(evidenceChain), + evidenceChain: selectionEvidence(candidates, confirmation, survivor, decidingCriterion), policyVersion: MERGE_SURVIVOR_SELECTION_POLICY_VERSION, survivorPartyRef: survivor.partyRef, }; diff --git a/app/verticals/party-registry/src/merge/party-alias-resolution.service.ts b/app/verticals/party-registry/src/merge/party-alias-resolution.service.ts index b5026fe31..7938004d9 100644 --- a/app/verticals/party-registry/src/merge/party-alias-resolution.service.ts +++ b/app/verticals/party-registry/src/merge/party-alias-resolution.service.ts @@ -47,7 +47,7 @@ export interface PartyAliasResolutionService { ) => Effect.Effect; } -export class PartyAliasResolution extends Context.Service< +class PartyAliasResolution extends Context.Service< PartyAliasResolution, PartyAliasResolutionService >()('@app/party-registry/merge/party-alias-resolution.service/PartyAliasResolution') {} @@ -126,7 +126,7 @@ export const makePartyAliasResolutionService = ( (tenantId: string, partyId: string) => resolveFrom(tenantId, partyId, partyId, new Set(), []), ); - return { + return PartyAliasResolution.of({ requireCanonicalWriteTarget: (tenantId, requestedPartyId) => resolvePartyAlias(tenantId, requestedPartyId).pipe( Effect.flatMap((resolution) => @@ -141,7 +141,7 @@ export const makePartyAliasResolutionService = ( ), ), resolvePartyAlias, - }; + }); }; type AliasTransaction = Pick; @@ -157,7 +157,7 @@ const unavailable = (cause?: unknown) => cause, ); -export const makeTransactionPartyAliasResolutionService = ( +const makeTransactionPartyAliasResolutionService = ( transaction: AliasTransaction, ): PartyAliasResolutionService => makePartyAliasResolutionService({ diff --git a/app/verticals/party-registry/src/merge/party-alias-resolution.ts b/app/verticals/party-registry/src/merge/party-alias-resolution.ts index 7d5a497ca..78a79c631 100644 --- a/app/verticals/party-registry/src/merge/party-alias-resolution.ts +++ b/app/verticals/party-registry/src/merge/party-alias-resolution.ts @@ -3,13 +3,12 @@ import { PartyRefSchema } from '../../shared/resources/party.ts'; import type { PartyRef } from '../../shared/resources/party.ts'; import { Match, Schema } from 'effect'; -export const AliasResolutionRejectionSchema = Schema.Union([ +const AliasResolutionRejectionSchema = Schema.Union([ Schema.TaggedStruct('PartyAliasCycleRejected', { aliasPartyRef: PartyRefSchema }), Schema.TaggedStruct('PartyAliasSelfReferenceRejected', { aliasPartyRef: PartyRefSchema }), Schema.TaggedStruct('PartyAliasCrossTenantRejected', { aliasPartyRef: PartyRefSchema }), ]); -export type AliasResolutionRejection = typeof AliasResolutionRejectionSchema.Type; -export const CanonicalPartyResolutionSchema = Schema.Union([ +const CanonicalPartyResolutionSchema = Schema.Union([ AliasResolutionRejectionSchema, Schema.TaggedStruct('CanonicalPartyResolved', { canonicalPartyRef: PartyRefSchema, diff --git a/app/verticals/party-registry/src/merge/reference-preservation-plan.ts b/app/verticals/party-registry/src/merge/reference-preservation-plan.ts index 91a8499de..06b1186bd 100644 --- a/app/verticals/party-registry/src/merge/reference-preservation-plan.ts +++ b/app/verticals/party-registry/src/merge/reference-preservation-plan.ts @@ -3,7 +3,7 @@ import type { PartyRef } from '../../shared/resources/party.ts'; import { Match, Option, Schema } from 'effect'; import { resolveCanonicalPartyRef } from './party-alias-resolution.ts'; -export const SupportedReferenceClassSchema = Schema.Literals([ +const SupportedReferenceClassSchema = Schema.Literals([ 'COMMERCE_PROFILE', 'CONNECTOR_CORRELATION', 'COUNTERPARTY', @@ -12,13 +12,13 @@ export const SupportedReferenceClassSchema = Schema.Literals([ 'EVENT_OR_OUTBOX_PAYLOAD', 'HISTORICAL_DOCUMENT', ]); -export type SupportedReferenceClass = typeof SupportedReferenceClassSchema.Type; -export const ReferenceClassSchema = Schema.Union([ +type SupportedReferenceClass = typeof SupportedReferenceClassSchema.Type; +const ReferenceClassSchema = Schema.Union([ SupportedReferenceClassSchema, Schema.Literal('UNSUPPORTED'), ]); -export type ReferenceClass = typeof ReferenceClassSchema.Type; -export interface HistoricalPartySnapshot { +type ReferenceClass = typeof ReferenceClassSchema.Type; +interface HistoricalPartySnapshot { readonly address?: string; readonly name?: string; readonly price?: string; @@ -45,42 +45,58 @@ interface PlannedPartyReference { readonly physicalRewriteRequired: false; } -export const planReferencePreservation = ( - input: Readonly<{ - aliases: readonly PartyAlias[]; - consumerReconciliation?: readonly ConsumerReconciliationContract[]; - references: readonly PartyReferenceInventoryItem[]; - }>, -) => { - const blockers: Readonly<{ code: string; ownerKey: string }>[] = []; +type ReferenceBlocker = Readonly<{ code: string; ownerKey: string }>; + +const consumerContractBlocker = ( + ownerKey: string, + contract: ConsumerReconciliationContract | undefined, +): ReferenceBlocker | undefined => { + if (ownerKey === 'party.registry') { + return undefined; + } + if ( + contract === undefined || + !contract.collisionBehaviorTested || + !contract.idempotent || + contract.evidenceRefs.length === 0 + ) { + return { code: 'CONSUMER_RECONCILIATION_UNPROVEN', ownerKey }; + } + if (!contract.partialRetrySupported) { + return { code: 'CONSUMER_PARTIAL_RETRY_UNPROVEN', ownerKey }; + } + return undefined; +}; + +const collectReferenceBlockers = ( + references: readonly PartyReferenceInventoryItem[], + consumerReconciliation: readonly ConsumerReconciliationContract[] | undefined, +): ReferenceBlocker[] => { + const blockers: ReferenceBlocker[] = []; const contracts = new Map( - (input.consumerReconciliation ?? []).map((contract) => [contract.consumerKey, contract]), + (consumerReconciliation ?? []).map((contract) => [contract.consumerKey, contract]), ); - for (const reference of input.references) { + for (const reference of references) { if (reference.class === 'UNSUPPORTED') { blockers.push({ code: 'UNSUPPORTED_REFERENCE_CLASS', ownerKey: reference.ownerKey }); continue; } - if (reference.ownerKey !== 'party.registry') { - const contract = contracts.get(reference.ownerKey); - if ( - contract === undefined || - !contract.collisionBehaviorTested || - !contract.idempotent || - contract.evidenceRefs.length === 0 - ) { - blockers.push({ - code: 'CONSUMER_RECONCILIATION_UNPROVEN', - ownerKey: reference.ownerKey, - }); - } else if (!contract.partialRetrySupported) { - blockers.push({ - code: 'CONSUMER_PARTIAL_RETRY_UNPROVEN', - ownerKey: reference.ownerKey, - }); - } + const blocker = consumerContractBlocker(reference.ownerKey, contracts.get(reference.ownerKey)); + if (blocker !== undefined) { + blockers.push(blocker); } } + return blockers; +}; + +export const planReferencePreservation = ( + input: Readonly<{ + aliases: readonly PartyAlias[]; + consumerReconciliation?: readonly ConsumerReconciliationContract[]; + references: readonly PartyReferenceInventoryItem[]; + }>, +) => { + const blockers = collectReferenceBlockers(input.references, input.consumerReconciliation); if (blockers.length > 0) { return { _tag: 'ReferencePreservationBlocked', diff --git a/app/verticals/party-registry/src/policies/create-party-without-strong-identifier.policy.ts b/app/verticals/party-registry/src/policies/create-party-without-strong-identifier.policy.ts index 30f4f9520..a0a42f909 100644 --- a/app/verticals/party-registry/src/policies/create-party-without-strong-identifier.policy.ts +++ b/app/verticals/party-registry/src/policies/create-party-without-strong-identifier.policy.ts @@ -10,35 +10,45 @@ import type { PartyEvidenceInsufficientError, } from '../../shared/domain/identity-contracts.ts'; -export const CreateWithoutStrongIdentifierPolicyConfigurationSchema = Schema.Struct({ +const CreateWithoutStrongIdentifierPolicyConfigurationSchema = Schema.Struct({ requireIdentityReview: Schema.Boolean, }); export type CreateWithoutStrongIdentifierPolicyConfiguration = typeof CreateWithoutStrongIdentifierPolicyConfigurationSchema.Type; +const subjectEvidenceReason = (evidence: NonNullable) => { + const subjects = new Set(evidence.map((item) => item.subjectKey)); + const kinds = new Set(evidence.map((item) => item.observedSubject)); + if (evidence.length === 0) { + return 'subject_evidence_required'; + } + if (kinds.has('MANAGED_LEGAL_ENTITY')) { + return 'managed_legal_entity_forbidden'; + } + if (kinds.has('TECHNICAL_RECORD')) { + return 'technical_record_forbidden'; + } + if (subjects.size !== 1) { + return 'one_concrete_subject_required'; + } + if (kinds.has('PERSON') && kinds.has('ORGANIZATION')) { + return 'conflicting_type_evidence'; + } + return 'proven_concrete_subject'; +}; + /** Evaluates explicit actor attestations. The owner Action, not a reference prefix or provider * label, records who accepted them. A review decision never bypasses subject/type evidence. */ export const evaluatePartySubjectEvidence = ( candidate: Pick, ): PartyEvidenceEvaluation => { const evidence = candidate.subjectEvidence ?? []; - const subjects = new Set(evidence.map((item) => item.subjectKey)); - const kinds = new Set(evidence.map((item) => item.observedSubject)); - let reasonCode = 'proven_concrete_subject'; - if (evidence.length === 0) { - reasonCode = 'subject_evidence_required'; - } else if (kinds.has('MANAGED_LEGAL_ENTITY')) { - reasonCode = 'managed_legal_entity_forbidden'; - } else if (kinds.has('TECHNICAL_RECORD')) { - reasonCode = 'technical_record_forbidden'; - } else if (subjects.size !== 1) { - reasonCode = 'one_concrete_subject_required'; - } else if (kinds.has('PERSON') && kinds.has('ORGANIZATION')) { - reasonCode = 'conflicting_type_evidence'; - } + let reasonCode: string = subjectEvidenceReason(evidence); const subjectEligible = reasonCode === 'proven_concrete_subject'; const typeSupported = - subjectEligible && (candidate.partyType === 'UNRESOLVED' || kinds.has(candidate.partyType)); + subjectEligible && + (candidate.partyType === 'UNRESOLVED' || + evidence.some(({ observedSubject }) => observedSubject === candidate.partyType)); if (subjectEligible && !typeSupported) { reasonCode = 'party_type_evidence_required'; } @@ -52,7 +62,7 @@ export const evaluatePartySubjectEvidence = ( }; }; -export const CreateWithoutStrongIdentifierDecisionSchema = Schema.Union([ +const CreateWithoutStrongIdentifierDecisionSchema = Schema.Union([ Schema.Struct({ decision: Schema.Literal('ALLOW'), reasonCode: Schema.Literal('proven_concrete_subject'), diff --git a/app/verticals/party-registry/src/routes/[lang]/contacts/page.tsx b/app/verticals/party-registry/src/routes/[lang]/contacts/page.tsx index 0ac37ed30..4ff9ef222 100644 --- a/app/verticals/party-registry/src/routes/[lang]/contacts/page.tsx +++ b/app/verticals/party-registry/src/routes/[lang]/contacts/page.tsx @@ -1,7 +1,7 @@ import { useModernI18n } from '@modern-js/plugin-i18n/runtime'; import { UltramodernRouteHead } from '../../ultramodern-route-head'; -export const ContactsPage = () => { +const ContactsPage = () => { const { t } = useModernI18n(); const headingId = 'contacts-heading'; diff --git a/app/verticals/party-registry/src/routes/ultramodern-route-metadata.ts b/app/verticals/party-registry/src/routes/ultramodern-route-metadata.ts index 40ef52089..a1c6481ab 100644 --- a/app/verticals/party-registry/src/routes/ultramodern-route-metadata.ts +++ b/app/verticals/party-registry/src/routes/ultramodern-route-metadata.ts @@ -4,7 +4,7 @@ export const ultramodernRouteNamespace = 'party-registry' as const; -export const ultramodernRouteMetadata = [ +const ultramodernRouteMetadata = [ { canonicalPath: '/contacts', descriptionKey: 'party-registry.pages.contacts.description', @@ -36,20 +36,5 @@ export const ultramodernRouteMetadata = [ ] as const; export const ultramodernLocalisedUrls = { - '/contacts': { - cs: '/contacts', - en: '/contacts', - }, -} as const; - -export const ultramodernPublicRoutes = [] as const; - -export const ultramodernRouteConfig = { - authoring: 'colocated-route-meta', - generatedManifest: true, - localisedUrls: ultramodernLocalisedUrls, - namespace: ultramodernRouteNamespace, - publicRoutes: ultramodernPublicRoutes, - routes: ultramodernRouteMetadata, - source: 'route-owned', + [ultramodernRouteMetadata[0].canonicalPath]: ultramodernRouteMetadata[0].localisedPaths, } as const; diff --git a/app/verticals/party-registry/src/search/counterparties.provider.ts b/app/verticals/party-registry/src/search/counterparties.provider.ts index ca4b4c9a3..2c7a0cf8c 100644 --- a/app/verticals/party-registry/src/search/counterparties.provider.ts +++ b/app/verticals/party-registry/src/search/counterparties.provider.ts @@ -21,7 +21,7 @@ import type { PartySearchProjectionGatewayService as PartySearchProjectionGatewa import { PartySearchProjectionUnavailable } from '../../shared/domain/search-projection-error.ts'; import { normalizeCounterpartySearchHits } from '../../shared/domain/search-semantics.ts'; -export const counterpartiesEntrypoint = defineTenantModuleEntrypoint({ +const counterpartiesEntrypoint = defineTenantModuleEntrypoint({ authorization: { kind: 'context_permission', permission: 'module.access' }, access: 'read', entrypointKey: 'party.registry.search.counterparties', diff --git a/app/verticals/party-registry/src/search/parties.provider.ts b/app/verticals/party-registry/src/search/parties.provider.ts index 566073853..bf47985a7 100644 --- a/app/verticals/party-registry/src/search/parties.provider.ts +++ b/app/verticals/party-registry/src/search/parties.provider.ts @@ -34,7 +34,7 @@ import { CurrentCounterpartyRoleSchema } from '../../shared/domain/search-result import { CounterpartyRefSchema } from '../../shared/resources/counterparty.ts'; import { PartyRefSchema } from '../../shared/resources/party.ts'; -export const partiesEntrypoint = defineTenantModuleEntrypoint({ +const partiesEntrypoint = defineTenantModuleEntrypoint({ authorization: { kind: 'context_permission', permission: 'module.access' }, access: 'read', entrypointKey: 'party.registry.search.parties', diff --git a/app/verticals/party-registry/src/services/counterparty-persistence.service.ts b/app/verticals/party-registry/src/services/counterparty-persistence.service.ts index 399dfdbbd..0b4ab2524 100644 --- a/app/verticals/party-registry/src/services/counterparty-persistence.service.ts +++ b/app/verticals/party-registry/src/services/counterparty-persistence.service.ts @@ -497,6 +497,29 @@ export const createCounterpartyRecord = Effect.fn( } as const; }); +const roleEndEvidence = (input: AcceptedActionEvidence, recordedAt: Date, hasEnd: boolean) => { + if (!hasEnd) { + return { + endEvidenceRefs: null, + endProvenanceMethod: null, + endProvenanceSource: null, + endReason: null, + endedByActionInvocationId: null, + endedByPrincipalId: null, + endedRecordedAt: null, + }; + } + return { + endEvidenceRefs: [input.provenance.evidenceReference], + endProvenanceMethod: input.provenance.method, + endProvenanceSource: input.provenance.source, + endReason: input.provenance.reason ?? input.provenance.method, + endedByActionInvocationId: input.actionInvocationId, + endedByPrincipalId: input.principalId, + endedRecordedAt: recordedAt, + }; +}; + export const addCounterpartyRoleRecord = Effect.fn( 'CounterpartyPersistenceService.addCounterpartyRoleRecord', )(function* addCounterpartyRole( @@ -563,13 +586,7 @@ export const addCounterpartyRoleRecord = Effect.fn( addEvidenceRefs: [input.provenance.evidenceReference], addReason: input.provenance.reason ?? input.provenance.method, counterpartyId: input.counterpartyId, - endEvidenceRefs: validTo === null ? null : [input.provenance.evidenceReference], - endProvenanceMethod: validTo === null ? null : input.provenance.method, - endProvenanceSource: validTo === null ? null : input.provenance.source, - endReason: validTo === null ? null : (input.provenance.reason ?? input.provenance.method), - endedByActionInvocationId: validTo === null ? null : input.actionInvocationId, - endedByPrincipalId: validTo === null ? null : input.principalId, - endedRecordedAt: validTo === null ? null : recordedAt, + ...roleEndEvidence(input, recordedAt, validTo !== null), isCurrent: lifecycle.isCurrent, legalEntityId: input.legalEntityId, policyVersion: input.policyVersion, @@ -591,6 +608,18 @@ export const addCounterpartyRoleRecord = Effect.fn( return { _tag: 'found', value: roleDto(row) } as const; }); +const repeatsRecordedRoleEnd = ( + current: RolePeriodRow, + input: EndCounterpartyRoleInput, + validTo: Date, +): boolean => + current.validTo !== null && + DateTime.Equivalence(DateTime.makeUnsafe(current.validTo), DateTime.makeUnsafe(validTo)) && + current.endProvenanceMethod === input.provenance.method && + current.endProvenanceSource === input.provenance.source && + current.endEvidenceRefs?.[0] === input.provenance.evidenceReference && + current.endReason === (input.provenance.reason ?? input.provenance.method); + export const endCounterpartyRoleRecord = Effect.fn( 'CounterpartyPersistenceService.endCounterpartyRoleRecord', )(function* endCounterpartyRole( @@ -637,13 +666,7 @@ export const endCounterpartyRoleRecord = Effect.fn( roleType, } as const; } - const repeatsRecordedEnd = - current.validTo?.getTime() === validTo.getTime() && - current.endProvenanceMethod === input.provenance.method && - current.endProvenanceSource === input.provenance.source && - current.endEvidenceRefs?.[0] === input.provenance.evidenceReference && - current.endReason === (input.provenance.reason ?? input.provenance.method); - if (repeatsRecordedEnd) { + if (repeatsRecordedRoleEnd(current, input, validTo)) { return { _tag: 'found', changed: false, value: roleDto(current) } as const; } if (current.state !== 'ACTIVE' || current.validTo !== null) { diff --git a/app/verticals/party-registry/src/services/engagement-reference-validation.service.ts b/app/verticals/party-registry/src/services/engagement-reference-validation.service.ts index 1f960ce64..3c115241d 100644 --- a/app/verticals/party-registry/src/services/engagement-reference-validation.service.ts +++ b/app/verticals/party-registry/src/services/engagement-reference-validation.service.ts @@ -17,13 +17,13 @@ export interface EngagementPartyReferences { readonly partyRef: PartyRef; } -export interface PartyRegistryCounterpartyProjection { +interface PartyRegistryCounterpartyProjection { readonly counterpartyRef: CounterpartyRef; readonly partyRef: PartyRef; readonly roleTypes: readonly ('CUSTOMER' | 'SUPPLIER')[]; } -export interface PartyRegistryPartyProjection { +interface PartyRegistryPartyProjection { readonly archived: boolean; readonly partyRef: PartyRef; readonly partyType: 'ORGANIZATION' | 'PERSON' | 'UNRESOLVED'; @@ -127,6 +127,36 @@ export const partyRegistryReferenceOperations = ({ : Effect.fail(mismatch('The Party reference does not belong to the trusted tenant')), }); +const validateCounterpartyReference = Effect.fn( + 'EngagementReferenceValidationService.validateCounterpartyReference', +)(function* validateCounterpartyReferenceEffect( + operations: PartyRegistryReferenceOperations, + counterpartyRef: CounterpartyRef, + partyRef: PartyRef, + party: PartyRegistryPartyProjection, +) { + const counterparty = yield* operations.readCounterparty(counterpartyRef); + if ( + counterparty.counterpartyRef.resourceId !== counterpartyRef.resourceId || + counterparty.counterpartyRef.tenantId !== counterpartyRef.tenantId || + counterpartyRef.tenantId !== partyRef.tenantId || + counterparty.partyRef.resourceId !== party.partyRef.resourceId || + counterparty.partyRef.tenantId !== partyRef.tenantId + ) { + return yield* new EngagementProfileConflict({ + code: 'contacts_party_counterparty_mismatch', + reason: 'The Counterparty does not resolve to the supplied Party', + }); + } + if (!counterparty.roleTypes.includes('CUSTOMER')) { + return yield* new EngagementProfileConflict({ + code: 'contacts_counterparty_customer_role_required', + reason: 'An explicit commercial context requires a current CUSTOMER role', + }); + } + return yield* Effect.void; +}); + export const validatePartyRegistryReferences = Effect.fn( 'EngagementReferenceValidationService.validatePartyRegistryReferences', )(function* validatePartyRegistryReferencesEffect( @@ -169,24 +199,10 @@ export const validatePartyRegistryReferences = Effect.fn( if (refs.counterpartyRef === undefined) { return yield* Effect.void; } - const counterparty = yield* operations.readCounterparty(refs.counterpartyRef); - if ( - counterparty.counterpartyRef.resourceId !== refs.counterpartyRef.resourceId || - counterparty.counterpartyRef.tenantId !== refs.counterpartyRef.tenantId || - refs.counterpartyRef.tenantId !== refs.partyRef.tenantId || - counterparty.partyRef.resourceId !== party.partyRef.resourceId || - counterparty.partyRef.tenantId !== refs.partyRef.tenantId - ) { - return yield* new EngagementProfileConflict({ - code: 'contacts_party_counterparty_mismatch', - reason: 'The Counterparty does not resolve to the supplied Party', - }); - } - if (!counterparty.roleTypes.includes('CUSTOMER')) { - return yield* new EngagementProfileConflict({ - code: 'contacts_counterparty_customer_role_required', - reason: 'An explicit commercial context requires a current CUSTOMER role', - }); - } - return yield* Effect.void; + return yield* validateCounterpartyReference( + operations, + refs.counterpartyRef, + refs.partyRef, + party, + ); }); diff --git a/app/verticals/party-registry/src/services/party-contact-point-persistence.service.ts b/app/verticals/party-registry/src/services/party-contact-point-persistence.service.ts index ea8681579..0c8291e33 100644 --- a/app/verticals/party-registry/src/services/party-contact-point-persistence.service.ts +++ b/app/verticals/party-registry/src/services/party-contact-point-persistence.service.ts @@ -141,6 +141,13 @@ const provenanceDto = Effect.fn('PartyContactPointPersistenceService.provenanceD }, ); +const encodeExternalEvidence = (provenance: AddContactPointCommand['provenance']) => + provenance.externalEvidence === undefined + ? Effect.succeed(null) + : Schema.encodeUnknownEffect(AresAppliedEvidenceSchema)(provenance.externalEvidence).pipe( + Effect.mapError(unavailable), + ); + const verificationDto = (row: VerificationRecord) => ({ ...(row.verificationMethod === null ? {} : { method: row.verificationMethod }), state: row.verificationState as 'REJECTED' | 'UNVERIFIED' | 'VERIFIED', @@ -215,6 +222,11 @@ const isPurposeCurrentAt = (row: PurposeRecord, now: Date): boolean => toEpochMillis(row.validFrom) <= toEpochMillis(now) && (row.validTo === null || toEpochMillis(row.validTo) > toEpochMillis(now)); +const purposeRegistryColumns = (context: AddressPurposeAssignment['registryContext']) => ({ + jurisdiction: context?.jurisdiction.toUpperCase() ?? 'ZZ', + registryContext: context?.registryKey ?? 'GENERAL', +}); + const purposeDto = Effect.fn('PartyContactPointPersistenceService.purposeDto')( function* makePurposeDto(row: PurposeRecord, now: Date) { return { @@ -241,6 +253,42 @@ const purposeDto = Effect.fn('PartyContactPointPersistenceService.purposeDto')( }, ); +const contactPointValueDto = ( + row: PartyContactPointRecord, + purposeDtos: readonly Effect.Success>[], +) => { + const value = + row.contactPointType === 'EMAIL' + ? { + displayValue: row.displayValue ?? '', + lookupValue: row.normalizedValue ?? '', + preferred: row.preferred, + type: 'EMAIL' as const, + } + : row.contactPointType === 'PHONE' + ? { + countryCode: row.phoneCountryCode, + displayValue: row.displayValue ?? '', + extension: row.phoneExtension, + lookupValue: row.normalizedValue ?? '', + preferred: row.preferred, + type: 'PHONE' as const, + } + : { + address: { + addressLine1: row.addressLine1, + addressLine2: row.addressLine2, + city: row.city, + countryCode: row.countryCode ?? '', + postalCode: row.postalCode, + region: row.region, + }, + purposes: purposeDtos, + type: 'ADDRESS' as const, + }; + return value; +}; + const contactPointDto = Effect.fn('PartyContactPointPersistenceService.contactPointDto')( function* makeContactPointDto(input: { readonly now: Date; @@ -252,35 +300,7 @@ const contactPointDto = Effect.fn('PartyContactPointPersistenceService.contactPo const purposeDtos = yield* Effect.forEach(purposes, (purpose) => purposeDto(purpose, now), { concurrency: 1, }); - const value = - row.contactPointType === 'EMAIL' - ? { - displayValue: row.displayValue ?? '', - lookupValue: row.normalizedValue ?? '', - preferred: row.preferred, - type: 'EMAIL' as const, - } - : row.contactPointType === 'PHONE' - ? { - countryCode: row.phoneCountryCode, - displayValue: row.displayValue ?? '', - extension: row.phoneExtension, - lookupValue: row.normalizedValue ?? '', - preferred: row.preferred, - type: 'PHONE' as const, - } - : { - address: { - addressLine1: row.addressLine1, - addressLine2: row.addressLine2, - city: row.city, - countryCode: row.countryCode ?? '', - postalCode: row.postalCode, - region: row.region, - }, - purposes: purposeDtos, - type: 'ADDRESS' as const, - }; + const value = contactPointValueDto(row, purposeDtos); return { contactPointRef: contactPointRef(row.tenantId, row.contactPointId), current: @@ -346,6 +366,32 @@ const loadDto = Effect.fn('PartyContactPointPersistenceService.loadDto')( }, ); +const storedAddressKey = (row: PartyContactPointRecord) => + normalizedAddressKey({ + ...(row.addressLine1 === null ? {} : { addressLine1: row.addressLine1 }), + ...(row.addressLine2 === null ? {} : { addressLine2: row.addressLine2 }), + ...(row.city === null ? {} : { city: row.city }), + countryCode: row.countryCode ?? '', + ...(row.postalCode === null ? {} : { postalCode: row.postalCode }), + ...(row.region === null ? {} : { region: row.region }), + }); + +const enrichedEvidenceReferences = ( + row: PartyContactPointRecord, + provenance: AddContactPointCommand['provenance'], +) => { + const additionalEvidenceRefs = [ + ...new Set([ + ...row.additionalEvidenceRefs, + ...(provenance.evidenceReference === undefined || + provenance.evidenceReference === row.evidenceReference + ? [] + : [provenance.evidenceReference]), + ]), + ]; + return additionalEvidenceRefs; +}; + const sameCanonicalContact = ( row: PartyContactPointRecord, command: AddContactPointCommand, @@ -365,17 +411,7 @@ const sameCanonicalContact = ( if (command.contactPoint.type !== 'ADDRESS') { return false; } - return ( - normalizedAddressKey(command.contactPoint.address) === - normalizedAddressKey({ - ...(row.addressLine1 === null ? {} : { addressLine1: row.addressLine1 }), - ...(row.addressLine2 === null ? {} : { addressLine2: row.addressLine2 }), - ...(row.city === null ? {} : { city: row.city }), - countryCode: row.countryCode ?? '', - ...(row.postalCode === null ? {} : { postalCode: row.postalCode }), - ...(row.region === null ? {} : { region: row.region }), - }) - ); + return normalizedAddressKey(command.contactPoint.address) === storedAddressKey(row); }; const lockParty = (transaction: PartyScopedTransaction, tenantId: string, partyId: string) => @@ -511,6 +547,54 @@ const transferPurposePreference = Effect.fn( }); }); +const contactValueColumns = (normalized: ReturnType) => { + const addressColumns = + normalized.type === 'ADDRESS' + ? normalized.address + : { + addressLine1: null, + addressLine2: null, + city: null, + countryCode: null, + postalCode: null, + region: null, + }; + const channelColumns = + normalized.type === 'ADDRESS' + ? { + displayValue: null, + normalizationVersion: null, + normalizedValue: null, + preferred: false, + } + : { + displayValue: normalized.displayValue, + normalizationVersion: 'party-contact-v1', + normalizedValue: normalized.lookupValue, + preferred: normalized.preferred, + }; + const phoneColumns = + normalized.type === 'PHONE' + ? { + phoneCountryCode: normalized.countryCode, + phoneExtension: normalized.extension, + } + : { phoneCountryCode: null, phoneExtension: null }; + return { ...addressColumns, ...channelColumns, ...phoneColumns }; +}; + +const acceptedVerificationColumns = (command: AddContactPointCommand) => ({ + verificationMethod: command.verification.method ?? null, + verificationState: command.verification.state, + verifiedAt: + command.verification.verifiedAt === undefined + ? null + : DateTime.toDateUtc(command.verification.verifiedAt), + verifiedByPrincipalId: + command.verification.state === 'VERIFIED' ? command.acceptedByPrincipalId : null, + verifierReference: command.verification.verifierReference ?? null, +}); + export const addContactPointRecord = Effect.fn( 'PartyContactPointPersistenceService.addContactPointRecord', )(function* addContactPoint( @@ -549,12 +633,7 @@ export const addContactPointRecord = Effect.fn( return normalizeContactPointInput(contactPoint); }, }); - const externalEvidence = - command.provenance.externalEvidence === undefined - ? null - : yield* Schema.encodeUnknownEffect(AresAppliedEvidenceSchema)( - command.provenance.externalEvidence, - ).pipe(Effect.mapError(unavailable)); + const externalEvidence = yield* encodeExternalEvidence(command.provenance); const [party] = yield* lockParty(transaction, scope.tenantId, command.partyRef.resourceId); if (party === undefined) { return yield* new PartyContactPointPartyNotFound({ @@ -646,44 +725,10 @@ export const addContactPointRecord = Effect.fn( ) .pipe(Effect.mapError(unavailable)); } - const addressColumns = - normalized.type === 'ADDRESS' - ? normalized.address - : { - addressLine1: null, - addressLine2: null, - city: null, - countryCode: null, - postalCode: null, - region: null, - }; - const channelColumns = - normalized.type === 'ADDRESS' - ? { - displayValue: null, - normalizationVersion: null, - normalizedValue: null, - preferred: false, - } - : { - displayValue: normalized.displayValue, - normalizationVersion: 'party-contact-v1', - normalizedValue: normalized.lookupValue, - preferred: normalized.preferred, - }; - const phoneColumns = - normalized.type === 'PHONE' - ? { - phoneCountryCode: normalized.countryCode, - phoneExtension: normalized.extension, - } - : { phoneCountryCode: null, phoneExtension: null }; const [created] = yield* transaction .insert(partyContactPoints) .values({ - ...addressColumns, - ...channelColumns, - ...phoneColumns, + ...contactValueColumns(normalized), acceptedByActionInvocationId: command.acceptedByActionInvocationId, acceptedByPrincipalId: command.acceptedByPrincipalId, contactPointType: normalized.type, @@ -698,15 +743,7 @@ export const addContactPointRecord = Effect.fn( provenanceSource: command.provenance.source, tenantId: scope.tenantId, validFrom: instantAsDate(command.validFrom), - verificationMethod: command.verification.method ?? null, - verificationState: command.verification.state, - verifiedAt: - command.verification.verifiedAt === undefined - ? null - : DateTime.toDateUtc(command.verification.verifiedAt), - verifiedByPrincipalId: - command.verification.state === 'VERIFIED' ? command.acceptedByPrincipalId : null, - verifierReference: command.verification.verifierReference ?? null, + ...acceptedVerificationColumns(command), }) .returning() .pipe(Effect.mapError(unavailable)); @@ -1097,27 +1134,14 @@ export const updateContactPointRecord = Effect.fn( reason: 'Replacing external observation evidence would erase accepted provenance', }); } - const additionalEvidenceRefs = [ - ...new Set([ - ...row.additionalEvidenceRefs, - ...(change.provenance.evidenceReference === undefined || - change.provenance.evidenceReference === row.evidenceReference - ? [] - : [change.provenance.evidenceReference]), - ]), - ]; + const additionalEvidenceRefs = enrichedEvidenceReferences(row, change.provenance); if (additionalEvidenceRefs.length > 32) { return yield* new PartyContactPointInvalid({ code: 'party_contact_point_invalid', reason: 'Contact Point evidence enrichment exceeds its bounded history', }); } - const externalEvidence = - change.provenance.externalEvidence === undefined - ? null - : yield* Schema.encodeUnknownEffect(AresAppliedEvidenceSchema)( - change.provenance.externalEvidence, - ).pipe(Effect.mapError(unavailable)); + const externalEvidence = yield* encodeExternalEvidence(change.provenance); yield* transaction .update(partyContactPoints) .set({ @@ -1194,12 +1218,7 @@ export const updateContactPointRecord = Effect.fn( current?.contactPointPurposeId, ); } - const externalEvidence = - command.provenance.externalEvidence === undefined - ? null - : yield* Schema.encodeUnknownEffect(AresAppliedEvidenceSchema)( - command.provenance.externalEvidence, - ).pipe(Effect.mapError(unavailable)); + const externalEvidence = yield* encodeExternalEvidence(command.provenance); if (current === undefined) { yield* transaction .insert(partyContactPointPurposes) @@ -1210,7 +1229,7 @@ export const updateContactPointRecord = Effect.fn( evidenceReference: command.provenance.evidenceReference ?? null, externalEvidence, isCurrent: true, - jurisdiction: assignment.registryContext?.jurisdiction.toUpperCase() ?? 'ZZ', + ...purposeRegistryColumns(assignment.registryContext), partyId: row.partyId, policyVersion: 'party-contact-point.v1', preferred: assignment.preferred, @@ -1218,7 +1237,6 @@ export const updateContactPointRecord = Effect.fn( provenanceMethod: command.provenance.method, provenanceSource: command.provenance.source, purposeKey: assignment.purpose, - registryContext: assignment.registryContext?.registryKey ?? 'GENERAL', tenantId: scope.tenantId, validFrom: operationTime, }) @@ -1249,6 +1267,23 @@ export const updateContactPointRecord = Effect.fn( }).pipe(Effect.withSpan('PartyContactPointPersistenceService.setAddressPurpose')); }); +const validatePurposeEnd = Effect.fn('PartyContactPointPersistenceService.validatePurposeEnd')( + function* validatePurposeEnd(purpose: PurposeRecord, effectiveEndMillis: number) { + if (purpose.validTo !== null) { + return yield* new PartyContactPointCorrectionRequired({ + code: 'party_contact_point_correction_required', + reason: 'Changing a planned ADDRESS purpose end requires correction semantics', + }); + } + if (effectiveEndMillis < toEpochMillis(purpose.validFrom)) { + return yield* new PartyContactPointInvalid({ + code: 'party_contact_point_invalid', + reason: 'The effective end cannot precede the ADDRESS purpose effective start', + }); + } + }, +); + export const endContactPointRecord = Effect.fn( 'PartyContactPointPersistenceService.endContactPointRecord', )(function* endContactPoint( @@ -1375,18 +1410,7 @@ export const endContactPointRecord = Effect.fn( reason: 'The ADDRESS purpose is not current or was ended at a different time', }); } - if (purpose.validTo !== null) { - return yield* new PartyContactPointCorrectionRequired({ - code: 'party_contact_point_correction_required', - reason: 'Changing a planned ADDRESS purpose end requires correction semantics', - }); - } - if (effectiveEndMillis < toEpochMillis(purpose.validFrom)) { - return yield* new PartyContactPointInvalid({ - code: 'party_contact_point_invalid', - reason: 'The effective end cannot precede the ADDRESS purpose effective start', - }); - } + yield* validatePurposeEnd(purpose, effectiveEndMillis); yield* transaction .update(partyContactPointPurposes) .set({ diff --git a/app/verticals/party-registry/src/services/party-correction.service.ts b/app/verticals/party-registry/src/services/party-correction.service.ts index e588fcc63..fe85d8fb5 100644 --- a/app/verticals/party-registry/src/services/party-correction.service.ts +++ b/app/verticals/party-registry/src/services/party-correction.service.ts @@ -237,296 +237,362 @@ const transitionedAssertionState = (replacementValue: string | undefined) => const optionalRelationshipRef = (tenantId: string, relationshipId: null | string) => relationshipId === null ? null : partyRelationshipRef(tenantId, relationshipId); -export const correctPartyFactRecord = Effect.fn('PartyCorrectionService.correctPartyFactRecord')( - function* correctFact( +const correctRelationship = Effect.fn('PartyCorrectionService.correctRelationship')( + function* correctRelationship( transaction: CorrectionTransaction, tenantId: string, - command: PartyCorrectionCommand, + command: RelationshipCorrectionCommand, acceptance: { readonly actionInvocationId: string; readonly principalId: string }, ) { - yield* lockTenantIdentityWrites(transaction, tenantId); - const now = yield* DateTime.nowAsDate; - let correctedPartyId: string; - let replacementAssertionId: null | string = null; - let partyFactAssertionId: null | string = null; - let officialIdentifierId: null | string = null; - let replacementOfficialIdentifierId: null | string = null; let relationshipId: null | string = null; let replacementRelationshipId: null | string = null; - let replacementEvidenceEvaluation: null | ReturnType = - null; - if (command.factKind === 'RELATIONSHIP') { - if (command.relationshipRef.tenantId !== tenantId) { - return yield* new PartyCorrectionConflict({ - code: 'party_correction_conflict', - reason: 'The target Party Relationship is absent or not active', - }); - } - const [targetRow] = yield* transaction - .select() - .from(partyRelationships) - .where( - and( - eq(partyRelationships.tenantId, tenantId), - eq(partyRelationships.relationshipId, command.relationshipRef.resourceId), - ), - ) - .limit(1) - .for('update') - .pipe(Effect.mapError(unavailable)); - const target = yield* requireActiveRelationshipTarget(targetRow, command.expectedRevision); - correctedPartyId = target.fromPartyId; - // A durable Relationship remains correctable after either stored endpoint becomes an alias. - // Resolve for invariant reads, but never rewrite the immutable stored endpoint identity. - yield* resolvePartyAlias(transaction, tenantId, target.fromPartyId).pipe( - Effect.mapError(unavailable), - ); - yield* resolvePartyAlias(transaction, tenantId, target.toPartyId).pipe( - Effect.mapError(unavailable), - ); - ({ relationshipId } = target); - const [transitioned] = yield* transaction - .update(partyRelationships) - .set({ - assertionState: transitionedRelationshipState(command), - revision: target.revision + 1, + let replacementAssertionId: null | string = null; + if (command.relationshipRef.tenantId !== tenantId) { + return yield* new PartyCorrectionConflict({ + code: 'party_correction_conflict', + reason: 'The target Party Relationship is absent or not active', + }); + } + const [targetRow] = yield* transaction + .select() + .from(partyRelationships) + .where( + and( + eq(partyRelationships.tenantId, tenantId), + eq(partyRelationships.relationshipId, command.relationshipRef.resourceId), + ), + ) + .limit(1) + .for('update') + .pipe(Effect.mapError(unavailable)); + const target = yield* requireActiveRelationshipTarget(targetRow, command.expectedRevision); + const correctedPartyId = target.fromPartyId; + // A durable Relationship remains correctable after either stored endpoint becomes an alias. + // Resolve for invariant reads, but never rewrite the immutable stored endpoint identity. + yield* resolvePartyAlias(transaction, tenantId, target.fromPartyId).pipe( + Effect.mapError(unavailable), + ); + yield* resolvePartyAlias(transaction, tenantId, target.toPartyId).pipe( + Effect.mapError(unavailable), + ); + ({ relationshipId } = target); + const [transitioned] = yield* transaction + .update(partyRelationships) + .set({ + assertionState: transitionedRelationshipState(command), + revision: target.revision + 1, + }) + .where( + and( + eq(partyRelationships.tenantId, tenantId), + eq(partyRelationships.relationshipId, target.relationshipId), + eq(partyRelationships.revision, target.revision), + eq(partyRelationships.assertionState, 'ACTIVE'), + ), + ) + .returning() + .pipe(Effect.mapError(unavailable)); + if (transitioned === undefined) { + return yield* new PartyCorrectionConflict({ + code: 'party_correction_conflict', + reason: 'The target Party Relationship changed concurrently', + }); + } + if (command.correctionMode === 'SUPERSEDE') { + const [replacement] = yield* transaction + .insert(partyRelationships) + .values({ + acceptedByActionInvocationId: acceptance.actionInvocationId, + acceptedByPrincipalId: acceptance.principalId, + assertionState: 'ACTIVE', + fromPartyId: target.fromPartyId, + policyVersion: command.policyVersion, + provenanceMethod: command.provenance.method, + provenanceSource: command.provenance.source, + relationshipType: target.relationshipType, + revision: 1, + supersedesRelationshipId: target.relationshipId, + tenantId, + toPartyId: target.toPartyId, + validFrom: Option.match(command.replacementValidFrom, { + onNone: () => null, + onSome: instantAsDate, + }), + validTo: Option.match(command.replacementValidTo, { + onNone: () => null, + onSome: instantAsDate, + }), }) - .where( - and( - eq(partyRelationships.tenantId, tenantId), - eq(partyRelationships.relationshipId, target.relationshipId), - eq(partyRelationships.revision, target.revision), - eq(partyRelationships.assertionState, 'ACTIVE'), - ), - ) .returning() - .pipe(Effect.mapError(unavailable)); - if (transitioned === undefined) { - return yield* new PartyCorrectionConflict({ - code: 'party_correction_conflict', - reason: 'The target Party Relationship changed concurrently', - }); - } - if (command.correctionMode === 'SUPERSEDE') { - const [replacement] = yield* transaction - .insert(partyRelationships) - .values({ - acceptedByActionInvocationId: acceptance.actionInvocationId, - acceptedByPrincipalId: acceptance.principalId, - assertionState: 'ACTIVE', - fromPartyId: target.fromPartyId, - policyVersion: command.policyVersion, - provenanceMethod: command.provenance.method, - provenanceSource: command.provenance.source, - relationshipType: target.relationshipType, - revision: 1, - supersedesRelationshipId: target.relationshipId, - tenantId, - toPartyId: target.toPartyId, - validFrom: Option.match(command.replacementValidFrom, { - onNone: () => null, - onSome: instantAsDate, - }), - validTo: Option.match(command.replacementValidTo, { - onNone: () => null, - onSome: instantAsDate, - }), - }) - .returning() - .pipe(Effect.mapError(relationshipMutationFailure)); - if (replacement === undefined) { - return yield* unavailable(); - } - replacementRelationshipId = replacement.relationshipId; - replacementAssertionId = replacement.relationshipId; + .pipe(Effect.mapError(relationshipMutationFailure)); + if (replacement === undefined) { + return yield* unavailable(); } - } else if (command.factKind === 'OFFICIAL_IDENTIFIER') { - correctedPartyId = command.partyId; - yield* requireCanonicalCorrectionTarget(transaction, tenantId, command.partyId); - const [targetRow] = yield* transaction - .select() - .from(partyOfficialIdentifiers) - .where( - and( - eq(partyOfficialIdentifiers.tenantId, tenantId), - eq(partyOfficialIdentifiers.officialIdentifierId, command.targetAssertionId), - eq(partyOfficialIdentifiers.partyId, command.partyId), - ), - ) - .limit(1) - .for('update') - .pipe(Effect.mapError(unavailable)); - const target = yield* requireActiveCurrentAssertion( - targetRow, - 'The target Official Identifier assertion is absent or not active', - ); - ({ officialIdentifierId } = target); - // The shared tenant-qualified claim lock serializes releases against create/add/unarchive. - // SAFETY: The persisted identifier columns are constrained to the closed identifier vocabulary. - yield* lockAndResolveClaims(transaction, tenantId, [ - { - identifierType: - target.identifierTypeKey as NormalizedOfficialIdentifier['identifierType'], - namespace: target.namespace as NormalizedOfficialIdentifier['namespace'], - normalizedValue: target.normalizedValue, - verification: target.verificationState as NormalizedOfficialIdentifier['verification'], - }, - ]); - const replacementIdentifier = - command.replacementValue === undefined - ? undefined - : yield* Schema.decodeUnknownEffect(OfficialIdentifierInputSchema)({ - identifierType: target.identifierTypeKey, - value: command.replacementValue, - verification: 'UNVERIFIED', - }).pipe( - Effect.map(normalizeOfficialIdentifier), - Effect.mapError((cause) => - attachCause( - new PartyCorrectionConflict({ - code: 'party_correction_conflict', - reason: 'The replacement Official Identifier is not formally valid', - }), - cause, - ), + replacementRelationshipId = replacement.relationshipId; + replacementAssertionId = replacement.relationshipId; + } + + return { correctedPartyId, relationshipId, replacementRelationshipId, replacementAssertionId }; + }, +); + +const correctOfficialIdentifier = Effect.fn('PartyCorrectionService.correctOfficialIdentifier')( + function* correctOfficialIdentifier( + transaction: CorrectionTransaction, + tenantId: string, + command: IdentityCorrectionCommand, + acceptance: { readonly actionInvocationId: string; readonly principalId: string }, + now: Date, + ) { + let officialIdentifierId: null | string = null; + let replacementOfficialIdentifierId: null | string = null; + let replacementAssertionId: null | string = null; + const correctedPartyId = command.partyId; + yield* requireCanonicalCorrectionTarget(transaction, tenantId, command.partyId); + const [targetRow] = yield* transaction + .select() + .from(partyOfficialIdentifiers) + .where( + and( + eq(partyOfficialIdentifiers.tenantId, tenantId), + eq(partyOfficialIdentifiers.officialIdentifierId, command.targetAssertionId), + eq(partyOfficialIdentifiers.partyId, command.partyId), + ), + ) + .limit(1) + .for('update') + .pipe(Effect.mapError(unavailable)); + const target = yield* requireActiveCurrentAssertion( + targetRow, + 'The target Official Identifier assertion is absent or not active', + ); + ({ officialIdentifierId } = target); + // The shared tenant-qualified claim lock serializes releases against create/add/unarchive. + // SAFETY: The persisted identifier columns are constrained to the closed identifier vocabulary. + yield* lockAndResolveClaims(transaction, tenantId, [ + { + identifierType: target.identifierTypeKey as NormalizedOfficialIdentifier['identifierType'], + namespace: target.namespace as NormalizedOfficialIdentifier['namespace'], + normalizedValue: target.normalizedValue, + verification: target.verificationState as NormalizedOfficialIdentifier['verification'], + }, + ]); + const replacementIdentifier = + command.replacementValue === undefined + ? undefined + : yield* Schema.decodeUnknownEffect(OfficialIdentifierInputSchema)({ + identifierType: target.identifierTypeKey, + value: command.replacementValue, + verification: 'UNVERIFIED', + }).pipe( + Effect.map(normalizeOfficialIdentifier), + Effect.mapError((cause) => + attachCause( + new PartyCorrectionConflict({ + code: 'party_correction_conflict', + reason: 'The replacement Official Identifier is not formally valid', + }), + cause, ), - ); - yield* transaction - .update(partyOfficialIdentifiers) - .set({ - isCurrent: false, - state: transitionedAssertionState(command.replacementValue), - validTo: now, + ), + ); + yield* transaction + .update(partyOfficialIdentifiers) + .set({ + isCurrent: false, + state: transitionedAssertionState(command.replacementValue), + validTo: now, + }) + .where( + and( + eq(partyOfficialIdentifiers.tenantId, tenantId), + eq(partyOfficialIdentifiers.officialIdentifierId, target.officialIdentifierId), + ), + ) + .pipe(Effect.mapError(unavailable)); + yield* transaction + .delete(partyIdentifierClaims) + .where( + and( + eq(partyIdentifierClaims.tenantId, tenantId), + eq(partyIdentifierClaims.officialIdentifierId, target.officialIdentifierId), + ), + ) + .pipe(Effect.mapError(unavailable)); + if (replacementIdentifier !== undefined) { + const [replacement] = yield* transaction + .insert(partyOfficialIdentifiers) + .values({ + acceptedByActionInvocationId: acceptance.actionInvocationId, + acceptedByPrincipalId: acceptance.principalId, + identifierTypeKey: target.identifierTypeKey, + namespace: replacementIdentifier.namespace, + normalizedValue: replacementIdentifier.normalizedValue, + partyId: command.partyId, + policyVersion: command.policyVersion, + provenanceMethod: command.provenance.method, + provenanceSource: command.provenance.source, + state: 'ACTIVE', + supersedesOfficialIdentifierId: target.officialIdentifierId, + tenantId, + validFrom: now, + verificationState: 'UNVERIFIED', }) - .where( - and( - eq(partyOfficialIdentifiers.tenantId, tenantId), - eq(partyOfficialIdentifiers.officialIdentifierId, target.officialIdentifierId), - ), - ) - .pipe(Effect.mapError(unavailable)); - yield* transaction - .delete(partyIdentifierClaims) - .where( - and( - eq(partyIdentifierClaims.tenantId, tenantId), - eq(partyIdentifierClaims.officialIdentifierId, target.officialIdentifierId), - ), - ) + .returning() .pipe(Effect.mapError(unavailable)); - if (replacementIdentifier !== undefined) { - const [replacement] = yield* transaction - .insert(partyOfficialIdentifiers) - .values({ - acceptedByActionInvocationId: acceptance.actionInvocationId, - acceptedByPrincipalId: acceptance.principalId, - identifierTypeKey: target.identifierTypeKey, - namespace: replacementIdentifier.namespace, - normalizedValue: replacementIdentifier.normalizedValue, - partyId: command.partyId, - policyVersion: command.policyVersion, - provenanceMethod: command.provenance.method, - provenanceSource: command.provenance.source, - state: 'ACTIVE', - supersedesOfficialIdentifierId: target.officialIdentifierId, - tenantId, - validFrom: now, - verificationState: 'UNVERIFIED', - }) - .returning() - .pipe(Effect.mapError(unavailable)); - if (replacement === undefined) { - return yield* unavailable(); - } - replacementOfficialIdentifierId = replacement.officialIdentifierId; - replacementAssertionId = replacement.officialIdentifierId; + if (replacement === undefined) { + return yield* unavailable(); } - } else { - correctedPartyId = command.partyId; - yield* requireCanonicalCorrectionTarget(transaction, tenantId, command.partyId); - const [targetRow] = yield* transaction - .select() - .from(partyFactAssertions) - .where( - and( - eq(partyFactAssertions.tenantId, tenantId), - eq(partyFactAssertions.assertionId, command.targetAssertionId), - eq(partyFactAssertions.partyId, command.partyId), - eq(partyFactAssertions.factKind, command.factKind), - ), - ) - .limit(1) - .for('update') + replacementOfficialIdentifierId = replacement.officialIdentifierId; + replacementAssertionId = replacement.officialIdentifierId; + } + + return { + correctedPartyId, + officialIdentifierId, + replacementOfficialIdentifierId, + replacementAssertionId, + }; + }, +); + +const correctIdentityAssertion = Effect.fn('PartyCorrectionService.correctIdentityAssertion')( + function* correctIdentityAssertion( + transaction: CorrectionTransaction, + tenantId: string, + command: IdentityCorrectionCommand, + acceptance: { readonly actionInvocationId: string; readonly principalId: string }, + now: Date, + ) { + let partyFactAssertionId: null | string = null; + let replacementAssertionId: null | string = null; + let replacementEvidenceEvaluation: null | ReturnType = + null; + const correctedPartyId = command.partyId; + yield* requireCanonicalCorrectionTarget(transaction, tenantId, command.partyId); + const [targetRow] = yield* transaction + .select() + .from(partyFactAssertions) + .where( + and( + eq(partyFactAssertions.tenantId, tenantId), + eq(partyFactAssertions.assertionId, command.targetAssertionId), + eq(partyFactAssertions.partyId, command.partyId), + eq(partyFactAssertions.factKind, command.factKind), + ), + ) + .limit(1) + .for('update') + .pipe(Effect.mapError(unavailable)); + const target = yield* requireActiveCurrentAssertion( + targetRow, + 'The target Party assertion is absent or not active', + ); + replacementEvidenceEvaluation = yield* validatePartyTypeCorrection( + transaction, + tenantId, + command, + target.normalizedValue, + ); + partyFactAssertionId = target.assertionId; + yield* transaction + .update(partyFactAssertions) + .set({ + isCurrent: false, + state: transitionedAssertionState(command.replacementValue), + validTo: now, + }) + .where( + and( + eq(partyFactAssertions.tenantId, tenantId), + eq(partyFactAssertions.assertionId, target.assertionId), + ), + ) + .pipe(Effect.mapError(unavailable)); + if (command.replacementValue !== undefined) { + const { replacementValue } = command; + const [replacement] = yield* transaction + .insert(partyFactAssertions) + .values({ + acceptedByActionInvocationId: acceptance.actionInvocationId, + acceptedByPrincipalId: acceptance.principalId, + evidenceEvaluation: replacementEvidenceEvaluation, + factKind: command.factKind, + normalizedValue: replacementValue, + partyId: command.partyId, + policyVersion: command.policyVersion, + provenanceMethod: command.provenance.method, + provenanceSource: command.provenance.source, + state: 'ACTIVE', + supersedesAssertionId: target.assertionId, + tenantId, + validFrom: now, + }) + .returning() .pipe(Effect.mapError(unavailable)); - const target = yield* requireActiveCurrentAssertion( - targetRow, - 'The target Party assertion is absent or not active', - ); - replacementEvidenceEvaluation = yield* validatePartyTypeCorrection( - transaction, - tenantId, - command, - target.normalizedValue, - ); - partyFactAssertionId = target.assertionId; + if (replacement === undefined) { + return yield* unavailable(); + } + replacementAssertionId = replacement.assertionId; yield* transaction - .update(partyFactAssertions) - .set({ - isCurrent: false, - state: transitionedAssertionState(command.replacementValue), - validTo: now, - }) - .where( - and( - eq(partyFactAssertions.tenantId, tenantId), - eq(partyFactAssertions.assertionId, target.assertionId), - ), + .update(parties) + .set( + command.factKind === 'PARTY_TYPE' + ? { + currentType: replacementValue, + revision: sql`${parties.revision} + 1`, + updatedAt: now, + } + : { + currentDisplayName: replacementValue, + revision: sql`${parties.revision} + 1`, + updatedAt: now, + }, ) + .where(and(eq(parties.tenantId, tenantId), eq(parties.partyId, command.partyId))) .pipe(Effect.mapError(unavailable)); - if (command.replacementValue !== undefined) { - const { replacementValue } = command; - const [replacement] = yield* transaction - .insert(partyFactAssertions) - .values({ - acceptedByActionInvocationId: acceptance.actionInvocationId, - acceptedByPrincipalId: acceptance.principalId, - evidenceEvaluation: replacementEvidenceEvaluation, - factKind: command.factKind, - normalizedValue: replacementValue, - partyId: command.partyId, - policyVersion: command.policyVersion, - provenanceMethod: command.provenance.method, - provenanceSource: command.provenance.source, - state: 'ACTIVE', - supersedesAssertionId: target.assertionId, - tenantId, - validFrom: now, - }) - .returning() - .pipe(Effect.mapError(unavailable)); - if (replacement === undefined) { - return yield* unavailable(); - } - replacementAssertionId = replacement.assertionId; - yield* transaction - .update(parties) - .set( - command.factKind === 'PARTY_TYPE' - ? { - currentType: replacementValue, - revision: sql`${parties.revision} + 1`, - updatedAt: now, - } - : { - currentDisplayName: replacementValue, - revision: sql`${parties.revision} + 1`, - updatedAt: now, - }, - ) - .where(and(eq(parties.tenantId, tenantId), eq(parties.partyId, command.partyId))) - .pipe(Effect.mapError(unavailable)); - } } + + return { correctedPartyId, partyFactAssertionId, replacementAssertionId }; + }, +); + +export const correctPartyFactRecord = Effect.fn('PartyCorrectionService.correctPartyFactRecord')( + function* correctFact( + transaction: CorrectionTransaction, + tenantId: string, + command: PartyCorrectionCommand, + acceptance: { readonly actionInvocationId: string; readonly principalId: string }, + ) { + yield* lockTenantIdentityWrites(transaction, tenantId); + const now = yield* DateTime.nowAsDate; + const correctionTarget = yield* Match.value(command).pipe( + Match.when({ factKind: 'RELATIONSHIP' }, (relationship) => + correctRelationship(transaction, tenantId, relationship, acceptance), + ), + Match.when({ factKind: 'OFFICIAL_IDENTIFIER' }, (identifier) => + correctOfficialIdentifier(transaction, tenantId, identifier, acceptance, now), + ), + Match.orElse((identity) => + correctIdentityAssertion(transaction, tenantId, identity, acceptance, now), + ), + ); + const { + correctedPartyId, + replacementAssertionId, + partyFactAssertionId, + officialIdentifierId, + replacementOfficialIdentifierId, + relationshipId, + replacementRelationshipId, + } = { + partyFactAssertionId: null, + officialIdentifierId: null, + replacementOfficialIdentifierId: null, + relationshipId: null, + replacementRelationshipId: null, + ...correctionTarget, + }; const [correction] = yield* transaction .insert(partyCorrections) .values({ @@ -568,6 +634,19 @@ export const correctPartyFactRecord = Effect.fn('PartyCorrectionService.correctP }, ); +const relationshipEndEvidence = (row: typeof partyRelationships.$inferSelect) => + row.validTo !== null && + row.endProvenanceMethod !== null && + row.endProvenanceSource !== null && + row.endedRecordedAt !== null + ? { + effectiveAt: row.validTo.toISOString(), + provenance: { method: row.endProvenanceMethod, source: row.endProvenanceSource }, + reason: row.endReason, + recordedAt: row.endedRecordedAt.toISOString(), + } + : null; + const loadCorrectionAssertion = Effect.fn('PartyCorrectionService.loadCorrectionAssertion')( function* loadImmutableAssertion( transaction: Pick, @@ -593,18 +672,7 @@ const loadCorrectionAssertion = Effect.fn('PartyCorrectionService.loadCorrection return yield* Schema.decodeUnknownEffect(PartyCorrectionAssertionValueSchema)({ assertionId: row.relationshipId, assertionState: row.assertionState, - endEvidence: - row.validTo !== null && - row.endProvenanceMethod !== null && - row.endProvenanceSource !== null && - row.endedRecordedAt !== null - ? { - effectiveAt: row.validTo.toISOString(), - provenance: { method: row.endProvenanceMethod, source: row.endProvenanceSource }, - reason: row.endReason, - recordedAt: row.endedRecordedAt.toISOString(), - } - : null, + endEvidence: relationshipEndEvidence(row), factKind, fromPartyRef: makePartyRef(tenantId, row.fromPartyId), provenance: { method: row.provenanceMethod, source: row.provenanceSource }, @@ -671,6 +739,31 @@ const loadCorrectionAssertion = Effect.fn('PartyCorrectionService.loadCorrection }, ); +const correctionAssertionTargets = Effect.fn('PartyCorrectionService.correctionAssertionTargets')( + function* correctionAssertionTargets( + row: typeof partyCorrections.$inferSelect, + reason: typeof StoredCorrectionReasonSchema.Type, + ) { + let { factKind } = reason; + if (row.officialIdentifierId !== null) { + factKind = 'OFFICIAL_IDENTIFIER'; + } + if (row.relationshipId !== null) { + factKind = 'RELATIONSHIP'; + } + const targetAssertionId = + row.partyFactAssertionId ?? row.officialIdentifierId ?? row.relationshipId; + if (targetAssertionId === null) { + return yield* unavailable(); + } + const replacementAssertionId = + row.replacementPartyFactAssertionId ?? + row.replacementOfficialIdentifierId ?? + row.replacementRelationshipId; + return { factKind, targetAssertionId, replacementAssertionId }; + }, +); + export const findPartyCorrection = Effect.fn('PartyCorrectionService.findPartyCorrection')( function* findCorrection( transaction: Pick, @@ -695,22 +788,8 @@ export const findPartyCorrection = Effect.fn('PartyCorrectionService.findPartyCo return yield* unavailable(); } const storedReason = yield* decodeStoredCorrectionReason(row.reason); - let { factKind } = storedReason; - if (row.officialIdentifierId !== null) { - factKind = 'OFFICIAL_IDENTIFIER'; - } - if (row.relationshipId !== null) { - factKind = 'RELATIONSHIP'; - } - const targetAssertionId = - row.partyFactAssertionId ?? row.officialIdentifierId ?? row.relationshipId; - if (targetAssertionId === null) { - return yield* unavailable(); - } - const replacementAssertionId = - row.replacementPartyFactAssertionId ?? - row.replacementOfficialIdentifierId ?? - row.replacementRelationshipId; + const { factKind, targetAssertionId, replacementAssertionId } = + yield* correctionAssertionTargets(row, storedReason); const originalAssertion = yield* loadCorrectionAssertion( transaction, tenantId, diff --git a/app/verticals/party-registry/src/services/party-identifier-claim.service.ts b/app/verticals/party-registry/src/services/party-identifier-claim.service.ts index 82902e164..af5831f11 100644 --- a/app/verticals/party-registry/src/services/party-identifier-claim.service.ts +++ b/app/verticals/party-registry/src/services/party-identifier-claim.service.ts @@ -134,8 +134,3 @@ export const lockAndResolveClaims = Effect.fn('PartyIdentifierClaimService.lockA ); }, ); - -export const partyIdentifierClaimService = Effect.succeed({ - lockAndResolveClaims, - lockTenantIdentityWrites, -}); diff --git a/app/verticals/party-registry/src/services/party-identity-persistence.service.ts b/app/verticals/party-registry/src/services/party-identity-persistence.service.ts index 0caf63566..2ed7d6269 100644 --- a/app/verticals/party-registry/src/services/party-identity-persistence.service.ts +++ b/app/verticals/party-registry/src/services/party-identity-persistence.service.ts @@ -73,7 +73,7 @@ const ClaimOwnershipSchema = Schema.Union([ export type PartyLookup = typeof PartyLookupSchema.Type; export type PartyLifecycle = typeof PartyLifecycleSchema.Type; -export type PartyUnarchiveLifecycle = typeof PartyUnarchiveLifecycleSchema.Type; +type PartyUnarchiveLifecycle = typeof PartyUnarchiveLifecycleSchema.Type; const MATCH_RULE_VERSION = 'party-exact-claims.v1'; const instantAsDate = (instant: string | DateTime.Utc): Date => @@ -399,6 +399,66 @@ const invalidIdentityFactInterval = ( ): boolean => assertions.some((assertion) => instantAsDate(requestedValidFrom) < assertion.validFrom); +const identityRecordChanges = ( + current: PartyRecord, + input: UpdatePartyIdentityInput, + now: Date, +) => { + const changes: Partial = { + revision: current.revision + 1, + updatedAt: now, + }; + if (input.displayName !== undefined) { + changes.currentDisplayName = input.displayName; + } + if (input.partyType !== undefined) { + changes.currentType = input.partyType; + } + return changes; +}; + +const identityUpdateAssertions = ( + current: PartyRecord, + input: UpdatePartyIdentityInput, + tenantId: string, + externalEvidence: (typeof partyFactAssertions.$inferInsert)['externalEvidence'], + evaluation: (typeof partyFactAssertions.$inferInsert)['evidenceEvaluation'], +) => { + const assertions: (typeof partyFactAssertions.$inferInsert)[] = []; + if (input.displayName !== undefined) { + assertions.push({ + acceptedByActionInvocationId: input.actionInvocationId, + acceptedByPrincipalId: input.principalId, + externalEvidence, + factKind: 'DISPLAY_NAME', + normalizedValue: input.displayName, + partyId: input.partyId, + policyVersion: 'party-identity.v1', + provenanceMethod: input.provenanceMethod, + provenanceSource: input.provenanceSource, + tenantId, + validFrom: instantAsDate(input.validFrom), + }); + } + if (input.partyType !== undefined && input.partyType !== current.currentType) { + assertions.push({ + acceptedByActionInvocationId: input.actionInvocationId, + acceptedByPrincipalId: input.principalId, + evidenceEvaluation: evaluation, + externalEvidence, + factKind: 'PARTY_TYPE', + normalizedValue: input.partyType, + partyId: input.partyId, + policyVersion: 'party-identity.v1', + provenanceMethod: input.provenanceMethod, + provenanceSource: input.provenanceSource, + tenantId, + validFrom: instantAsDate(input.validFrom), + }); + } + return assertions; +}; + export const updatePartyIdentityRecord = Effect.fn( 'PartyIdentityPersistenceService.updatePartyIdentityRecord', )(function* updateParty( @@ -462,16 +522,7 @@ export const updatePartyIdentityRecord = Effect.fn( : yield* Schema.encodeUnknownEffect(AresAppliedEvidenceSchema)(input.externalEvidence).pipe( Effect.mapError(unavailable), ); - const changes: Partial = { - revision: current.revision + 1, - updatedAt: now, - }; - if (input.displayName !== undefined) { - changes.currentDisplayName = input.displayName; - } - if (input.partyType !== undefined) { - changes.currentType = input.partyType; - } + const changes = identityRecordChanges(current, input, now); const [updated] = yield* transaction .update(parties) .set(changes) @@ -481,38 +532,13 @@ export const updatePartyIdentityRecord = Effect.fn( if (updated === undefined) { return yield* unavailable(); } - const assertions: (typeof partyFactAssertions.$inferInsert)[] = []; - if (input.displayName !== undefined) { - assertions.push({ - acceptedByActionInvocationId: input.actionInvocationId, - acceptedByPrincipalId: input.principalId, - externalEvidence, - factKind: 'DISPLAY_NAME', - normalizedValue: input.displayName, - partyId: input.partyId, - policyVersion: 'party-identity.v1', - provenanceMethod: input.provenanceMethod, - provenanceSource: input.provenanceSource, - tenantId, - validFrom: instantAsDate(input.validFrom), - }); - } - if (input.partyType !== undefined && input.partyType !== current.currentType) { - assertions.push({ - acceptedByActionInvocationId: input.actionInvocationId, - acceptedByPrincipalId: input.principalId, - evidenceEvaluation: evaluation, - externalEvidence, - factKind: 'PARTY_TYPE', - normalizedValue: input.partyType, - partyId: input.partyId, - policyVersion: 'party-identity.v1', - provenanceMethod: input.provenanceMethod, - provenanceSource: input.provenanceSource, - tenantId, - validFrom: instantAsDate(input.validFrom), - }); - } + const assertions = identityUpdateAssertions( + current, + input, + tenantId, + externalEvidence, + evaluation, + ); yield* Effect.forEach( changedIdentityFactKinds(current, input), (factKind) => @@ -540,14 +566,12 @@ export const updatePartyIdentityRecord = Effect.fn( return { _tag: 'found', value: partyDto(updated) } as const; }); -export const transitionPartyRecord = Effect.fn( - 'PartyIdentityPersistenceService.transitionPartyRecord', -)(function* transitionParty( - transaction: Pick, +const lockPartyIdentityRecord = Effect.fn( + 'PartyIdentityPersistenceService.lockPartyIdentityRecord', +)(function* lockPartyIdentityRecord( + transaction: Pick, tenantId: string, partyId: string, - expectedRevision: number, - state: 'ARCHIVED', ) { yield* lockTenantIdentityWrites(transaction, tenantId); const [current] = yield* transaction @@ -557,6 +581,19 @@ export const transitionPartyRecord = Effect.fn( .limit(1) .for('update') .pipe(Effect.mapError(unavailable)); + return current; +}); + +export const transitionPartyRecord = Effect.fn( + 'PartyIdentityPersistenceService.transitionPartyRecord', +)(function* transitionParty( + transaction: Pick, + tenantId: string, + partyId: string, + expectedRevision: number, + state: 'ARCHIVED', +) { + const current = yield* lockPartyIdentityRecord(transaction, tenantId, partyId); if (current === undefined) { return { _tag: 'not_found' } as const; } @@ -589,14 +626,7 @@ export const unarchivePartyRecord = Effect.fn( partyId: string, expectedRevision: number, ) { - yield* lockTenantIdentityWrites(transaction, tenantId); - const [current] = yield* transaction - .select() - .from(parties) - .where(and(eq(parties.tenantId, tenantId), eq(parties.partyId, partyId))) - .limit(1) - .for('update') - .pipe(Effect.mapError(unavailable)); + const current = yield* lockPartyIdentityRecord(transaction, tenantId, partyId); if (current === undefined) { return { _tag: 'not_found' } as const; } diff --git a/app/verticals/party-registry/src/services/party-matching-persistence.service.ts b/app/verticals/party-registry/src/services/party-matching-persistence.service.ts index 5971e7048..97c6f8477 100644 --- a/app/verticals/party-registry/src/services/party-matching-persistence.service.ts +++ b/app/verticals/party-registry/src/services/party-matching-persistence.service.ts @@ -84,6 +84,11 @@ const candidateInstant = (instant: PartyCandidate['validFrom'] | string): DateTi const encodedCandidateInstant = (instant: PartyCandidate['validFrom'] | string): string => DateTime.formatIso(candidateInstant(instant)); +const hasIncompatiblePartyType = (partyType: string, candidate: PartyCandidate) => + partyType !== 'UNRESOLVED' && + candidate.partyType !== 'UNRESOLVED' && + partyType !== candidate.partyType; + const qualifyingCandidateClaims = (candidate: PartyCandidate) => candidate.officialIdentifiers .map(normalizeOfficialIdentifier) @@ -362,6 +367,25 @@ interface CreateOrReuseCaseInput { readonly tenantId: string; } +const snapshotCandidate = (candidate: PartyCandidate): PartyCandidateSnapshot => ({ + evidenceArtifactRefs: candidate.evidenceRefs, + names: candidate.displayName === undefined ? [] : [candidate.displayName], + officialIdentifiers: candidate.officialIdentifiers.map((identifier) => { + const normalized = normalizeOfficialIdentifier(identifier); + return { + identifierTypeKey: normalized.identifierType, + namespace: normalized.namespace, + normalizedValue: normalized.normalizedValue, + verificationState: normalized.verification, + }; + }), + partyType: candidate.partyType, + policyVersion: MATCH_RULE_VERSION, + provenance: candidate.provenance, + subjectEvidence: candidate.subjectEvidence ?? [], + validFrom: encodedCandidateInstant(candidate.validFrom), +}); + const createOrReuseCase = Effect.fn('PartyMatchingPersistenceService.createOrReuseCase')( function* createCase( transaction: Pick, @@ -424,24 +448,7 @@ const createOrReuseCase = Effect.fn('PartyMatchingPersistenceService.createOrReu } const values: typeof duplicateCandidateCases.$inferInsert = { candidateFingerprint: fingerprint, - candidateSnapshot: { - evidenceArtifactRefs: candidate.evidenceRefs, - names: candidate.displayName === undefined ? [] : [candidate.displayName], - officialIdentifiers: candidate.officialIdentifiers.map((identifier) => { - const normalized = normalizeOfficialIdentifier(identifier); - return { - identifierTypeKey: normalized.identifierType, - namespace: normalized.namespace, - normalizedValue: normalized.normalizedValue, - verificationState: normalized.verification, - }; - }), - partyType: candidate.partyType, - policyVersion: MATCH_RULE_VERSION, - provenance: candidate.provenance, - subjectEvidence: candidate.subjectEvidence ?? [], - validFrom: encodedCandidateInstant(candidate.validFrom), - } satisfies PartyCandidateSnapshot, + candidateSnapshot: snapshotCandidate(candidate), evaluatedEvidence: evidenceExplanation, evaluationFingerprint, matchRuleVersion: MATCH_RULE_VERSION, @@ -532,11 +539,6 @@ const persistDecision = ( ); }; -/** Owner-local mutation evidence; Actions consume it without widening their public result. */ -export interface MatchingIdentifierAcceptance { - readonly addedOfficialIdentifierRefs?: readonly PartyOfficialIdentifierRef[]; -} - const collectNewIdentifierAcceptance = ( acceptedIds: Set, record: typeof partyOfficialIdentifiers.$inferSelect, @@ -560,76 +562,49 @@ type CreateOrMatchPartyOperation = ( readonly tenantId: string; }, ) => Effect.Effect< - PartyCreateOutcome & MatchingIdentifierAcceptance, + PartyCreateOutcome & { + readonly addedOfficialIdentifierRefs?: readonly PartyOfficialIdentifierRef[]; + }, PartyEvidenceInsufficientError | PartyPersistenceUnavailableError >; -export const createOrMatchParty: CreateOrMatchPartyOperation = Effect.fn( - 'PartyMatchingPersistenceService.createOrMatchParty', -)(function* createOrMatch( +const acceptMatchingIdentifiers = ( transaction: Parameters[0], input: Parameters[1], -) { - yield* lockTenantIdentityWrites(transaction, input.tenantId); - const now = yield* DateTime.nowAsDate; - if (DateTime.toDateUtc(candidateInstant(input.candidate.validFrom)) > now) { - return yield* new PartyEvidenceInsufficient({ - code: 'party_evidence_insufficient', - reason: 'Future-effective identity acceptance is not supported in V1', - }); - } - yield* requirePartySubjectEvidence(input.candidate); - const operation = input.operation ?? 'CREATE'; - const identifiers = input.candidate.officialIdentifiers.map(normalizeOfficialIdentifier); - const strong = qualifyingCandidateClaims(input.candidate); - const fingerprint = candidateFingerprint(input.candidate); - if (strong.length === 0) { - const eligibility = decideAtomicCreateWithoutStrongIdentifier( - input.candidate, - input.createWithoutStrongIdentifierReviewed === true, - ); - if (eligibility.decision === 'DENY') { - return yield* new PartyEvidenceInsufficient({ - code: 'party_evidence_insufficient', - reason: `Create without a strong identifier denied: ${eligibility.reasonCode}`, - }); - } - if (eligibility.decision === 'REVIEW_REQUIRED') { - const weakPartyIds = yield* findWeakCandidatePartyIds( - transaction, - input.tenantId, - input.candidate, - now, - ); - const candidateCase = yield* createOrReuseCase(transaction, { - candidate: input.candidate, - partyIds: weakPartyIds, - tenantId: input.tenantId, - }); - const decision = yield* persistDecision(transaction, { + partyId: string, + partyType: PartyCandidate['partyType'], + identifiers: readonly NormalizedOfficialIdentifier[], + acceptedIds: Set, +) => + Effect.forEach( + identifiers, + (identifier) => + addOfficialIdentifierRecord(transaction, input.tenantId, partyId, identifier, { actionInvocationId: input.actionInvocationId, - candidate: input.candidate, - candidateCaseId: candidateCase.candidateCaseId, - candidateFingerprint: fingerprint, - operation, - outcome: 'AMBIGUOUS', - partyIds: weakPartyIds, - tenantId: input.tenantId, - }); - return { - caseRef: makeDuplicateCandidateCaseRef(input.tenantId, candidateCase.candidateCaseId), - decisionRef: makePartyMatchDecisionRef(input.tenantId, decision.matchDecisionId), - outcome: 'AMBIGUOUS', - } as const; - } - } - const resolvedClaims = yield* lockAndResolveClaims(transaction, input.tenantId, strong); - const partyIds = [ - ...new Set( - resolvedClaims.flatMap((claim) => (claim.partyId === undefined ? [] : [claim.partyId])), - ), - ].toSorted(); - if (partyIds.length > 1) { + externalEvidence: input.candidate.provenance.externalEvidence, + matchRuleVersion: MATCH_RULE_VERSION, + partyType, + principalId: input.principalId, + provenanceMethod: input.candidate.provenance.method, + provenanceSource: input.candidate.provenance.source, + validFrom: encodedCandidateInstant(input.candidate.validFrom), + }).pipe( + Effect.tap((record) => + Effect.sync(() => + collectNewIdentifierAcceptance(acceptedIds, record, input.actionInvocationId), + ), + ), + ), + { concurrency: 1, discard: true }, + ); + +const recordAmbiguousCreate = Effect.fn('PartyMatchingPersistenceService.recordAmbiguousCreate')( + function* recordAmbiguity( + transaction: Parameters[0], + input: Parameters[1], + partyIds: readonly string[], + claimFields: Pick[1], 'claims'> = {}, + ) { const candidateCase = yield* createOrReuseCase(transaction, { candidate: input.candidate, partyIds, @@ -639,9 +614,9 @@ export const createOrMatchParty: CreateOrMatchPartyOperation = Effect.fn( actionInvocationId: input.actionInvocationId, candidate: input.candidate, candidateCaseId: candidateCase.candidateCaseId, - candidateFingerprint: fingerprint, - claims: resolvedClaims, - operation, + candidateFingerprint: candidateFingerprint(input.candidate), + ...claimFields, + operation: input.operation ?? 'CREATE', outcome: 'AMBIGUOUS', partyIds, tenantId: input.tenantId, @@ -651,106 +626,49 @@ export const createOrMatchParty: CreateOrMatchPartyOperation = Effect.fn( decisionRef: makePartyMatchDecisionRef(input.tenantId, decision.matchDecisionId), outcome: 'AMBIGUOUS', } as const; - } - const [existingPartyId] = partyIds; - if (existingPartyId !== undefined) { + }, +); + +const matchExistingCandidate = Effect.fn('PartyMatchingPersistenceService.matchExistingCandidate')( + function* matchExisting( + transaction: Parameters[0], + input: Parameters[1], + existingPartyId: string, + partyIds: readonly string[], + resolvedClaims: readonly ResolvedClaim[], + identifiers: readonly NormalizedOfficialIdentifier[], + ) { const existing = yield* findLockedPartyRecord(transaction, input.tenantId, existingPartyId); if (Schema.is(PartyNotFoundSchema)(existing)) { return yield* unavailable(); } if ( Option.isSome(existing.value.archivedAt) || - (existing.value.partyType !== 'UNRESOLVED' && - input.candidate.partyType !== 'UNRESOLVED' && - existing.value.partyType !== input.candidate.partyType) + hasIncompatiblePartyType(existing.value.partyType, input.candidate) ) { - const candidateCase = yield* createOrReuseCase(transaction, { - candidate: input.candidate, - partyIds, - tenantId: input.tenantId, - }); - const decision = yield* persistDecision(transaction, { - actionInvocationId: input.actionInvocationId, - candidate: input.candidate, - candidateCaseId: candidateCase.candidateCaseId, - candidateFingerprint: fingerprint, - claims: resolvedClaims, - operation, - outcome: 'AMBIGUOUS', - partyIds, - tenantId: input.tenantId, - }); - return { - caseRef: makeDuplicateCandidateCaseRef(input.tenantId, candidateCase.candidateCaseId), - decisionRef: makePartyMatchDecisionRef(input.tenantId, decision.matchDecisionId), - outcome: 'AMBIGUOUS', - } as const; + return yield* recordAmbiguousCreate(transaction, input, partyIds, { claims: resolvedClaims }); } const addedOfficialIdentifierIds = new Set(); - yield* Effect.forEach( - resolvedClaims.filter((claim) => claim.partyId === undefined), - (unresolved) => - addOfficialIdentifierRecord( - transaction, - input.tenantId, - existingPartyId, - unresolved.claim, - { - actionInvocationId: input.actionInvocationId, - externalEvidence: input.candidate.provenance.externalEvidence, - matchRuleVersion: MATCH_RULE_VERSION, - partyType: existing.value.partyType, - principalId: input.principalId, - provenanceMethod: input.candidate.provenance.method, - provenanceSource: input.candidate.provenance.source, - validFrom: encodedCandidateInstant(input.candidate.validFrom), - }, - ).pipe( - Effect.tap((record) => - Effect.sync(() => - collectNewIdentifierAcceptance( - addedOfficialIdentifierIds, - record, - input.actionInvocationId, - ), - ), - ), - ), - { concurrency: 1, discard: true }, - ); - yield* Effect.forEach( - identifiers.filter( + const identifiersToAccept = [ + ...resolvedClaims.filter((claim) => claim.partyId === undefined).map((claim) => claim.claim), + ...identifiers.filter( (item) => !qualifiesForExclusiveClaim(item, input.candidate.partyType, MATCH_RULE_VERSION), ), - (identifier) => - addOfficialIdentifierRecord(transaction, input.tenantId, existingPartyId, identifier, { - actionInvocationId: input.actionInvocationId, - externalEvidence: input.candidate.provenance.externalEvidence, - matchRuleVersion: MATCH_RULE_VERSION, - partyType: existing.value.partyType, - principalId: input.principalId, - provenanceMethod: input.candidate.provenance.method, - provenanceSource: input.candidate.provenance.source, - validFrom: encodedCandidateInstant(input.candidate.validFrom), - }).pipe( - Effect.tap((record) => - Effect.sync(() => - collectNewIdentifierAcceptance( - addedOfficialIdentifierIds, - record, - input.actionInvocationId, - ), - ), - ), - ), - { concurrency: 1, discard: true }, + ]; + yield* acceptMatchingIdentifiers( + transaction, + input, + existingPartyId, + existing.value.partyType, + identifiersToAccept, + addedOfficialIdentifierIds, ); const decision = yield* persistDecision(transaction, { actionInvocationId: input.actionInvocationId, candidate: input.candidate, - candidateFingerprint: fingerprint, + candidateFingerprint: candidateFingerprint(input.candidate), claims: resolvedClaims, - operation, + operation: input.operation ?? 'CREATE', outcome: 'MATCHED', partyId: existingPartyId, partyIds, @@ -764,6 +682,68 @@ export const createOrMatchParty: CreateOrMatchPartyOperation = Effect.fn( outcome: 'MATCHED_EXISTING', partyRef: makePartyRef(input.tenantId, existingPartyId), } as const; + }, +); + +export const createOrMatchParty: CreateOrMatchPartyOperation = Effect.fn( + 'PartyMatchingPersistenceService.createOrMatchParty', +)(function* createOrMatch( + transaction: Parameters[0], + input: Parameters[1], +) { + yield* lockTenantIdentityWrites(transaction, input.tenantId); + const now = yield* DateTime.nowAsDate; + if (DateTime.toDateUtc(candidateInstant(input.candidate.validFrom)) > now) { + return yield* new PartyEvidenceInsufficient({ + code: 'party_evidence_insufficient', + reason: 'Future-effective identity acceptance is not supported in V1', + }); + } + yield* requirePartySubjectEvidence(input.candidate); + const operation = input.operation ?? 'CREATE'; + const identifiers = input.candidate.officialIdentifiers.map(normalizeOfficialIdentifier); + const strong = qualifyingCandidateClaims(input.candidate); + const fingerprint = candidateFingerprint(input.candidate); + if (strong.length === 0) { + const eligibility = decideAtomicCreateWithoutStrongIdentifier( + input.candidate, + input.createWithoutStrongIdentifierReviewed === true, + ); + if (eligibility.decision === 'DENY') { + return yield* new PartyEvidenceInsufficient({ + code: 'party_evidence_insufficient', + reason: `Create without a strong identifier denied: ${eligibility.reasonCode}`, + }); + } + if (eligibility.decision === 'REVIEW_REQUIRED') { + const weakPartyIds = yield* findWeakCandidatePartyIds( + transaction, + input.tenantId, + input.candidate, + now, + ); + return yield* recordAmbiguousCreate(transaction, input, weakPartyIds); + } + } + const resolvedClaims = yield* lockAndResolveClaims(transaction, input.tenantId, strong); + const partyIds = [ + ...new Set( + resolvedClaims.flatMap((claim) => (claim.partyId === undefined ? [] : [claim.partyId])), + ), + ].toSorted(); + if (partyIds.length > 1) { + return yield* recordAmbiguousCreate(transaction, input, partyIds, { claims: resolvedClaims }); + } + const [existingPartyId] = partyIds; + if (existingPartyId !== undefined) { + return yield* matchExistingCandidate( + transaction, + input, + existingPartyId, + partyIds, + resolvedClaims, + identifiers, + ); } if (input.createWithoutStrongIdentifierReviewed !== true) { const weakPartyIds = yield* findWeakCandidatePartyIds( @@ -773,26 +753,7 @@ export const createOrMatchParty: CreateOrMatchPartyOperation = Effect.fn( now, ); if (weakPartyIds.length > 0) { - const candidateCase = yield* createOrReuseCase(transaction, { - candidate: input.candidate, - partyIds: weakPartyIds, - tenantId: input.tenantId, - }); - const decision = yield* persistDecision(transaction, { - actionInvocationId: input.actionInvocationId, - candidate: input.candidate, - candidateCaseId: candidateCase.candidateCaseId, - candidateFingerprint: fingerprint, - operation, - outcome: 'AMBIGUOUS', - partyIds: weakPartyIds, - tenantId: input.tenantId, - }); - return { - caseRef: makeDuplicateCandidateCaseRef(input.tenantId, candidateCase.candidateCaseId), - decisionRef: makePartyMatchDecisionRef(input.tenantId, decision.matchDecisionId), - outcome: 'AMBIGUOUS', - } as const; + return yield* recordAmbiguousCreate(transaction, input, weakPartyIds); } } const party = yield* insertPartyRecord(transaction, input.tenantId, input.candidate, { @@ -839,19 +800,15 @@ export const createOrMatchParty: CreateOrMatchPartyOperation = Effect.fn( } as const; }); -/** Records an identity decision without creating or changing a canonical Party. */ -export const matchParty = Effect.fn('PartyMatchingPersistenceService.matchParty')( - function* recordMatchDecision( - transaction: Pick, +const requirePriorReviewCase = Effect.fn('PartyMatchingPersistenceService.requirePriorReviewCase')( + function* requirePriorCase( + transaction: Pick, input: { - readonly actionInvocationId: string; - readonly candidate: PartyCandidate; readonly priorCandidateCaseId?: string; readonly priorCaseTenantId?: string; readonly tenantId: string; }, ) { - yield* lockTenantIdentityWrites(transaction, input.tenantId); const { priorCandidateCaseId } = input; if (priorCandidateCaseId !== undefined) { if (input.priorCaseTenantId !== input.tenantId) { @@ -878,6 +835,49 @@ export const matchParty = Effect.fn('PartyMatchingPersistenceService.matchParty' }); } } + }, +); + +const evaluateMatchOutcome = Effect.fn('PartyMatchingPersistenceService.evaluateMatchOutcome')( + function* evaluateOutcome( + transaction: Pick, + tenantId: string, + candidate: PartyCandidate, + partyIds: readonly string[], + strongPartyIds: readonly string[], + ) { + let outcome = initialMatchOutcome(partyIds, strongPartyIds); + const [solePartyId] = partyIds; + if (outcome === 'MATCHED' && solePartyId !== undefined) { + const party = yield* findLockedPartyRecord(transaction, tenantId, solePartyId); + if (Schema.is(PartyNotFoundSchema)(party)) { + return yield* unavailable(); + } + if ( + Option.isSome(party.value.archivedAt) || + hasIncompatiblePartyType(party.value.partyType, candidate) + ) { + outcome = 'AMBIGUOUS'; + } + } + return outcome; + }, +); + +/** Records an identity decision without creating or changing a canonical Party. */ +export const matchParty = Effect.fn('PartyMatchingPersistenceService.matchParty')( + function* recordMatchDecision( + transaction: Pick, + input: { + readonly actionInvocationId: string; + readonly candidate: PartyCandidate; + readonly priorCandidateCaseId?: string; + readonly priorCaseTenantId?: string; + readonly tenantId: string; + }, + ) { + yield* lockTenantIdentityWrites(transaction, input.tenantId); + yield* requirePriorReviewCase(transaction, input); yield* requirePartySubjectEvidence(input.candidate); const now = yield* DateTime.nowAsDate; if (DateTime.toDateUtc(candidateInstant(input.candidate.validFrom)) > now) { @@ -901,22 +901,14 @@ export const matchParty = Effect.fn('PartyMatchingPersistenceService.matchParty' strongPartyIds.length > 0 ? strongPartyIds : yield* findWeakCandidatePartyIds(transaction, input.tenantId, input.candidate, now); - let outcome = initialMatchOutcome(partyIds, strongPartyIds); + const outcome = yield* evaluateMatchOutcome( + transaction, + input.tenantId, + input.candidate, + partyIds, + strongPartyIds, + ); const [solePartyId] = partyIds; - if (outcome === 'MATCHED' && solePartyId !== undefined) { - const party = yield* findLockedPartyRecord(transaction, input.tenantId, solePartyId); - if (Schema.is(PartyNotFoundSchema)(party)) { - return yield* unavailable(); - } - if ( - Option.isSome(party.value.archivedAt) || - (party.value.partyType !== 'UNRESOLVED' && - input.candidate.partyType !== 'UNRESOLVED' && - party.value.partyType !== input.candidate.partyType) - ) { - outcome = 'AMBIGUOUS'; - } - } const createCaseInput: CreateOrReuseCaseInput = { candidate: input.candidate, evidenceExplanation: explainCandidateEvidence( @@ -965,6 +957,41 @@ export const matchParty = Effect.fn('PartyMatchingPersistenceService.matchParty' }, ); +const requireOpenCandidateCase = Effect.fn( + 'PartyMatchingPersistenceService.requireOpenCandidateCase', +)(function* requireOpenCase( + transaction: Pick, + input: { + readonly tenantId: string; + readonly candidateCaseId: string; + readonly expectedRevision: number; + }, +) { + const [candidateCase] = yield* transaction + .select() + .from(duplicateCandidateCases) + .where( + and( + eq(duplicateCandidateCases.tenantId, input.tenantId), + eq(duplicateCandidateCases.candidateCaseId, input.candidateCaseId), + ), + ) + .limit(1) + .for('update') + .pipe(Effect.mapError(unavailable)); + if ( + candidateCase === undefined || + candidateCase.revision !== input.expectedRevision || + !['OPEN', 'NEEDS_EVIDENCE'].includes(candidateCase.lifecycleState) + ) { + return yield* new DuplicateCandidateConflict({ + code: 'duplicate_candidate_conflict', + reason: 'The Duplicate Candidate case is absent, closed, or has a stale revision', + }); + } + return candidateCase; +}); + type TransitionDuplicateCandidateCaseOperation = ( transaction: Pick, input: { @@ -987,28 +1014,7 @@ export const transitionDuplicateCandidateCase: TransitionDuplicateCandidateCaseO input: Parameters[1], ) { yield* lockTenantIdentityWrites(transaction, input.tenantId); - const [candidateCase] = yield* transaction - .select() - .from(duplicateCandidateCases) - .where( - and( - eq(duplicateCandidateCases.tenantId, input.tenantId), - eq(duplicateCandidateCases.candidateCaseId, input.candidateCaseId), - ), - ) - .limit(1) - .for('update') - .pipe(Effect.mapError(unavailable)); - if ( - candidateCase === undefined || - candidateCase.revision !== input.expectedRevision || - !['OPEN', 'NEEDS_EVIDENCE'].includes(candidateCase.lifecycleState) - ) { - return yield* new DuplicateCandidateConflict({ - code: 'duplicate_candidate_conflict', - reason: 'The Duplicate Candidate case is absent, closed, or has a stale revision', - }); - } + const candidateCase = yield* requireOpenCandidateCase(transaction, input); const lifecycleState = resolvedCaseLifecycle(input.outcome); const now = yield* DateTime.nowAsDate; yield* transaction @@ -1052,7 +1058,9 @@ type ResolveDuplicateCandidateMatchOperation = ( readonly tenantId: string; }, ) => Effect.Effect< - DuplicateCaseResolutionResult & MatchingIdentifierAcceptance, + DuplicateCaseResolutionResult & { + readonly addedOfficialIdentifierRefs?: readonly PartyOfficialIdentifierRef[]; + }, | ClaimOwnedByDifferentParty | DuplicateCandidateConflict | PartyPersistenceUnavailableError @@ -1073,28 +1081,7 @@ export const resolveDuplicateCandidateMatch: ResolveDuplicateCandidateMatchOpera reason: 'The selected Party must belong to the trusted tenant', }); } - const [candidateCase] = yield* transaction - .select() - .from(duplicateCandidateCases) - .where( - and( - eq(duplicateCandidateCases.tenantId, input.tenantId), - eq(duplicateCandidateCases.candidateCaseId, input.candidateCaseId), - ), - ) - .limit(1) - .for('update') - .pipe(Effect.mapError(unavailable)); - if ( - candidateCase === undefined || - candidateCase.revision !== input.expectedRevision || - !['OPEN', 'NEEDS_EVIDENCE'].includes(candidateCase.lifecycleState) - ) { - return yield* new DuplicateCandidateConflict({ - code: 'duplicate_candidate_conflict', - reason: 'The Duplicate Candidate case is absent, closed, or has a stale revision', - }); - } + const candidateCase = yield* requireOpenCandidateCase(transaction, input); if (candidateCase.candidateSnapshot.intent === 'UNARCHIVE') { return yield* new DuplicateCandidateConflict({ code: 'duplicate_candidate_conflict', @@ -1137,9 +1124,7 @@ export const resolveDuplicateCandidateMatch: ResolveDuplicateCandidateMatchOpera if ( selectedParty === undefined || selectedParty.archivedAt !== null || - (selectedParty.currentType !== 'UNRESOLVED' && - candidate.partyType !== 'UNRESOLVED' && - selectedParty.currentType !== candidate.partyType) + hasIncompatiblePartyType(selectedParty.currentType, candidate) ) { return yield* new DuplicateCandidateConflict({ code: 'duplicate_candidate_conflict', @@ -1261,28 +1246,7 @@ export const resolveDuplicateCandidateCreate: ResolveDuplicateCandidateCreateOpe input: Parameters[1], ) { yield* lockTenantIdentityWrites(transaction, input.tenantId); - const [candidateCase] = yield* transaction - .select() - .from(duplicateCandidateCases) - .where( - and( - eq(duplicateCandidateCases.tenantId, input.tenantId), - eq(duplicateCandidateCases.candidateCaseId, input.candidateCaseId), - ), - ) - .limit(1) - .for('update') - .pipe(Effect.mapError(unavailable)); - if ( - candidateCase === undefined || - candidateCase.revision !== input.expectedRevision || - !['OPEN', 'NEEDS_EVIDENCE'].includes(candidateCase.lifecycleState) - ) { - return yield* new DuplicateCandidateConflict({ - code: 'duplicate_candidate_conflict', - reason: 'The Duplicate Candidate case is absent, closed, or has a stale revision', - }); - } + const candidateCase = yield* requireOpenCandidateCase(transaction, input); if (candidateCase.candidateSnapshot.intent === 'UNARCHIVE') { return yield* new DuplicateCandidateConflict({ code: 'duplicate_candidate_conflict', @@ -1300,17 +1264,16 @@ export const resolveDuplicateCandidateCreate: ResolveDuplicateCandidateCreateOpe }); } const candidate = restoreCandidate(candidateCase.candidateSnapshot); - const result: PartyCreateOutcome & MatchingIdentifierAcceptance = yield* createOrMatchParty( - transaction, - { - actionInvocationId: input.actionInvocationId, - candidate, - createWithoutStrongIdentifierReviewed: true, - operation: 'REVIEW_CREATE', - principalId: input.principalId, - tenantId: input.tenantId, - }, - ); + const result: PartyCreateOutcome & { + readonly addedOfficialIdentifierRefs?: readonly PartyOfficialIdentifierRef[]; + } = yield* createOrMatchParty(transaction, { + actionInvocationId: input.actionInvocationId, + candidate, + createWithoutStrongIdentifierReviewed: true, + operation: 'REVIEW_CREATE', + principalId: input.principalId, + tenantId: input.tenantId, + }); if (result.outcome !== 'CREATED') { return yield* new DuplicateCandidateConflict({ code: 'duplicate_candidate_conflict', diff --git a/app/verticals/party-registry/src/services/party-official-identifier-persistence.service.ts b/app/verticals/party-registry/src/services/party-official-identifier-persistence.service.ts index 24bdd4055..489c33019 100644 --- a/app/verticals/party-registry/src/services/party-official-identifier-persistence.service.ts +++ b/app/verticals/party-registry/src/services/party-official-identifier-persistence.service.ts @@ -38,7 +38,7 @@ const instantAsDate = (instant: string | DateTime.Utc): Date => export const PARTY_EXACT_CLAIM_RULE_VERSION = 'party-exact-claims.v1'; -export const endedOfficialIdentifierTransition = { +const endedOfficialIdentifierTransition = { isCurrent: false, state: 'ENDED', } as const; @@ -116,6 +116,59 @@ const lockIdentifierWriteTarget = Effect.fn( : ({ _tag: 'found', current, party: matchedParty.result.value } as const); }); +const matchIdentifierWriteTarget = ( + transaction: Pick, + tenantId: string, + officialIdentifierId: string, +) => + lockIdentifierWriteTarget(transaction, tenantId, officialIdentifierId).pipe( + Effect.map((target) => + Match.value(target).pipe( + Match.tag('found', (result) => ({ matched: true, result }) as const), + Match.tag('conflict', 'not_found', (result) => ({ matched: false, result }) as const), + Match.exhaustive, + ), + ), + ); + +const verificationTargetChanged = ( + current: typeof partyOfficialIdentifiers.$inferSelect, + expectedVerification: IdentifierVerification, +) => + current.state !== 'ACTIVE' || + !current.isCurrent || + current.validTo !== null || + current.verificationState !== expectedVerification; + +const resolveVerificationClaim = Effect.fn( + 'PartyOfficialIdentifierPersistenceService.resolveVerificationClaim', +)(function* resolveVerificationClaim( + transaction: Pick, + tenantId: string, + candidate: NormalizedOfficialIdentifier, + current: typeof partyOfficialIdentifiers.$inferSelect, + partyType: PartyType, + matchRuleVersion: string, +) { + const claimEligible = qualifiesForExclusiveClaim(candidate, partyType, matchRuleVersion); + const previouslyClaimEligible = qualifiesForExclusiveClaim( + // SAFETY: the database CHECK constrains verification to this contract. + { ...candidate, verification: current.verificationState as IdentifierVerification }, + partyType, + matchRuleVersion, + ); + if (!claimEligible && !previouslyClaimEligible) { + return { claimEligible, claimOwner: undefined, conflict: false }; + } + const [claim] = yield* lockAndResolveClaims(transaction, tenantId, [candidate]); + const claimOwner = claim?.partyId; + return { + claimEligible, + claimOwner, + conflict: claimEligible && claimOwner !== undefined && claimOwner !== current.partyId, + }; +}); + export const addOfficialIdentifierRecord = Effect.fn( 'PartyOfficialIdentifierPersistenceService.addOfficialIdentifierRecord', )(function* addIdentifier( @@ -208,11 +261,10 @@ export const endOfficialIdentifierRecord = Effect.fn( officialIdentifierId: string, validTo: string, ) { - const target = yield* lockIdentifierWriteTarget(transaction, tenantId, officialIdentifierId); - const matchedTarget = Match.value(target).pipe( - Match.tag('found', (result) => ({ matched: true, result }) as const), - Match.tag('conflict', 'not_found', (result) => ({ matched: false, result }) as const), - Match.exhaustive, + const matchedTarget = yield* matchIdentifierWriteTarget( + transaction, + tenantId, + officialIdentifierId, ); if (!matchedTarget.matched) { return matchedTarget.result; @@ -286,22 +338,16 @@ export const updateOfficialIdentifierVerificationRecord = Effect.fn( readonly verification: IdentifierVerification; }, ) { - const target = yield* lockIdentifierWriteTarget(transaction, tenantId, officialIdentifierId); - const matchedTarget = Match.value(target).pipe( - Match.tag('found', (result) => ({ matched: true, result }) as const), - Match.tag('conflict', 'not_found', (result) => ({ matched: false, result }) as const), - Match.exhaustive, + const matchedTarget = yield* matchIdentifierWriteTarget( + transaction, + tenantId, + officialIdentifierId, ); if (!matchedTarget.matched) { return matchedTarget.result; } const { current, party } = matchedTarget.result; - if ( - current.state !== 'ACTIVE' || - !current.isCurrent || - current.validTo !== null || - current.verificationState !== input.expectedVerification - ) { + if (verificationTargetChanged(current, input.expectedVerification)) { return { _tag: 'conflict' } as const; } @@ -313,26 +359,17 @@ export const updateOfficialIdentifierVerificationRecord = Effect.fn( normalizedValue: current.normalizedValue, verification: input.verification, }; - const claimEligible = qualifiesForExclusiveClaim( + const { claimEligible, claimOwner, conflict } = yield* resolveVerificationClaim( + transaction, + tenantId, candidate, + current, // SAFETY: the Party database CHECK constrains currentType to the PartyType union. party.currentType as PartyType, input.matchRuleVersion, ); - let claimOwner: string | undefined; - const previouslyClaimEligible = qualifiesForExclusiveClaim( - // SAFETY: the database CHECK constrains verification to this contract. - { ...candidate, verification: current.verificationState as IdentifierVerification }, - // SAFETY: the Party database CHECK constrains currentType to the PartyType union. - party.currentType as PartyType, - input.matchRuleVersion, - ); - if (claimEligible || previouslyClaimEligible) { - const [claim] = yield* lockAndResolveClaims(transaction, tenantId, [candidate]); - claimOwner = claim?.partyId; - if (claimEligible && claimOwner !== undefined && claimOwner !== current.partyId) { - return { _tag: 'claim_conflict' } as const; - } + if (conflict) { + return { _tag: 'claim_conflict' } as const; } const now = yield* DateTime.nowAsDate; @@ -382,12 +419,6 @@ export const updateOfficialIdentifierVerificationRecord = Effect.fn( return { _tag: 'found', previous: current, value: updated } as const; }); -export const partyOfficialIdentifierPersistenceService = Effect.succeed({ - addOfficialIdentifierRecord, - endOfficialIdentifierRecord, - updateOfficialIdentifierVerificationRecord, -}); - const identifierDto = ( row: typeof partyOfficialIdentifiers.$inferSelect, ): OfficialIdentifierAssertion => ({ diff --git a/app/verticals/party-registry/src/services/party-relationship-persistence.service.ts b/app/verticals/party-registry/src/services/party-relationship-persistence.service.ts index d83aaab92..390528f48 100644 --- a/app/verticals/party-registry/src/services/party-relationship-persistence.service.ts +++ b/app/verticals/party-registry/src/services/party-relationship-persistence.service.ts @@ -652,14 +652,12 @@ export const createPartyRelationshipRecord = Effect.fn( return { outcome: 'CREATED', relationship: storedDetail(created, recordedAt) } as const; }); -export const updatePartyRelationshipRecord = Effect.fn( - 'PartyRelationshipPersistenceService.updatePartyRelationshipRecord', -)(function* updateRelationship( +const loadActiveRelationshipContext = Effect.fn( + 'PartyRelationshipPersistenceService.loadActiveRelationshipContext', +)(function* loadActiveRelationshipContextEffect( transaction: RelationshipScopedTransaction, tenantId: string, - principalId: string, - actionInvocationId: string, - payload: UpdatePartyRelationshipPayload, + payload: Pick, ) { yield* ensureTrustedTenant(tenantId, payload.relationshipRef.tenantId); const [current] = yield* loadLocked(transaction, tenantId, payload.relationshipRef.resourceId); @@ -682,6 +680,62 @@ export const updatePartyRelationshipRecord = Effect.fn( transaction, }); const now = yield* DateTime.nowAsDate; + return { current, fromCanonicalId, toCanonicalId, now }; +}); + +const persistRelationshipChange = Effect.fn( + 'PartyRelationshipPersistenceService.persistRelationshipChange', +)(function* persistRelationshipChangeEffect( + transaction: RelationshipScopedTransaction, + tenantId: string, + current: PartyRelationshipRecord, + payload: Pick, + values: Partial, + now: Date, + fromCanonicalId: string, + toCanonicalId: string, +) { + const [updated] = yield* transaction + .update(partyRelationships) + .set(values) + .where( + and( + eq(partyRelationships.tenantId, tenantId), + eq(partyRelationships.relationshipId, current.relationshipId), + eq(partyRelationships.revision, current.revision), + ), + ) + .returning() + .pipe(Effect.mapError(mutationFailure)); + if (updated === undefined) { + return yield* new PartyRelationshipRevisionConflict({ + actualRevision: current.revision + 1, + code: 'party_relationship_revision_conflict', + expectedRevision: payload.expectedRevision, + reason: 'The Party Relationship changed concurrently', + }); + } + return { + outcome: 'CHANGED', + previous: storedDetail(current, now, fromCanonicalId, toCanonicalId), + relationship: storedDetail(updated, now, fromCanonicalId, toCanonicalId), + } as const; +}); + +export const updatePartyRelationshipRecord = Effect.fn( + 'PartyRelationshipPersistenceService.updatePartyRelationshipRecord', +)(function* updateRelationship( + transaction: RelationshipScopedTransaction, + tenantId: string, + principalId: string, + actionInvocationId: string, + payload: UpdatePartyRelationshipPayload, +) { + const { current, fromCanonicalId, toCanonicalId, now } = yield* loadActiveRelationshipContext( + transaction, + tenantId, + payload, + ); const decision = decideRelationshipUpdate( { revision: current.revision, @@ -726,38 +780,23 @@ export const updatePartyRelationshipRecord = Effect.fn( payload, principalId, }); - const [updated] = yield* transaction - .update(partyRelationships) - .set({ + return yield* persistRelationshipChange( + transaction, + tenantId, + current, + payload, + { ...endEvidence, provenanceMethod: payload.provenance.method, provenanceSource: payload.provenance.source, revision: current.revision + 1, validFrom: nextValidFrom, validTo: nextValidTo, - }) - .where( - and( - eq(partyRelationships.tenantId, tenantId), - eq(partyRelationships.relationshipId, current.relationshipId), - eq(partyRelationships.revision, current.revision), - ), - ) - .returning() - .pipe(Effect.mapError(mutationFailure)); - if (updated === undefined) { - return yield* new PartyRelationshipRevisionConflict({ - actualRevision: current.revision + 1, - code: 'party_relationship_revision_conflict', - expectedRevision: payload.expectedRevision, - reason: 'The Party Relationship changed concurrently', - }); - } - return { - outcome: 'CHANGED', - previous: storedDetail(current, now, fromCanonicalId, toCanonicalId), - relationship: storedDetail(updated, now, fromCanonicalId, toCanonicalId), - } as const; + }, + now, + fromCanonicalId, + toCanonicalId, + ); }); export const endPartyRelationshipRecord = Effect.fn( @@ -769,27 +808,11 @@ export const endPartyRelationshipRecord = Effect.fn( actionInvocationId: string, payload: EndPartyRelationshipPayload, ) { - yield* ensureTrustedTenant(tenantId, payload.relationshipRef.tenantId); - const [current] = yield* loadLocked(transaction, tenantId, payload.relationshipRef.resourceId); - if (current === undefined) { - return yield* new PartyRelationshipNotFound({ - code: 'party_relationship_not_found', - reason: RELATIONSHIP_NOT_FOUND_REASON, - }); - } - if (current.assertionState !== 'ACTIVE') { - return yield* new PartyRelationshipNotFound({ - code: 'party_relationship_not_found', - reason: 'The requested active Party Relationship does not exist', - }); - } - const [fromCanonicalId, toCanonicalId] = yield* resolveCanonicalEndpointIds({ - fromPartyId: current.fromPartyId, - tenantId, - toPartyId: current.toPartyId, + const { current, fromCanonicalId, toCanonicalId, now } = yield* loadActiveRelationshipContext( transaction, - }); - const now = yield* DateTime.nowAsDate; + tenantId, + payload, + ); const decision = decideRelationshipEnd( { endProvenanceMethod: current.endProvenanceMethod, @@ -810,9 +833,12 @@ export const endPartyRelationshipRecord = Effect.fn( } as const; } const effectiveAt = dateFromIso(payload.effectiveAt); - const [updated] = yield* transaction - .update(partyRelationships) - .set({ + return yield* persistRelationshipChange( + transaction, + tenantId, + current, + payload, + { endedByActionInvocationId: actionInvocationId, endedByPrincipalId: principalId, endedRecordedAt: now, @@ -821,29 +847,11 @@ export const endPartyRelationshipRecord = Effect.fn( endReason: payload.reason ?? null, revision: current.revision + 1, validTo: effectiveAt, - }) - .where( - and( - eq(partyRelationships.tenantId, tenantId), - eq(partyRelationships.relationshipId, current.relationshipId), - eq(partyRelationships.revision, current.revision), - ), - ) - .returning() - .pipe(Effect.mapError(mutationFailure)); - if (updated === undefined) { - return yield* new PartyRelationshipRevisionConflict({ - actualRevision: current.revision + 1, - code: 'party_relationship_revision_conflict', - expectedRevision: payload.expectedRevision, - reason: 'The Party Relationship changed concurrently', - }); - } - return { - outcome: 'CHANGED', - previous: storedDetail(current, now, fromCanonicalId, toCanonicalId), - relationship: storedDetail(updated, now, fromCanonicalId, toCanonicalId), - } as const; + }, + now, + fromCanonicalId, + toCanonicalId, + ); }); export const findPartyRelationshipRecord = Effect.fn( diff --git a/app/verticals/party-registry/src/services/party-search-projection-source.service.ts b/app/verticals/party-registry/src/services/party-search-projection-source.service.ts index 0ed4ed54a..b69d4ae12 100644 --- a/app/verticals/party-registry/src/services/party-search-projection-source.service.ts +++ b/app/verticals/party-registry/src/services/party-search-projection-source.service.ts @@ -269,6 +269,24 @@ const readCounterparties = Effect.fn('PartySearchProjectionSourceService.readCou ).pipe(Effect.map((records) => records.flat())); }); +const wantedCanonicalIds = ( + target: PartySearchProjectionTarget, + canonicalIds: ReadonlyMap, +): Set => { + const wanted = new Set(); + if ('partyId' in target) { + const canonicalId = canonicalIds.get(target.partyId); + if (canonicalId !== undefined) { + wanted.add(canonicalId); + } + } else if ('rebuild' in target) { + for (const canonicalId of canonicalIds.values()) { + wanted.add(canonicalId); + } + } + return wanted; +}; + const readCanonicalProjection = Effect.fn( 'PartySearchProjectionSourceService.readCanonicalProjection', )(function* readCanonicalProjectionSnapshot( @@ -303,17 +321,7 @@ const readCanonicalProjection = Effect.fn( { concurrency: 1 }, ); const canonicalIds = new Map(canonicalIdEntries); - const wanted = new Set(); - if ('partyId' in target) { - const canonicalId = canonicalIds.get(target.partyId); - if (canonicalId !== undefined) { - wanted.add(canonicalId); - } - } else if ('rebuild' in target) { - for (const canonicalId of canonicalIds.values()) { - wanted.add(canonicalId); - } - } + const wanted = wantedCanonicalIds(target, canonicalIds); const initialFamily = records .filter((row) => wanted.has(canonicalIds.get(row.partyId) ?? '')) .map((row) => row.partyId); diff --git a/app/verticals/party-registry/src/services/party-search-projection.service.ts b/app/verticals/party-registry/src/services/party-search-projection.service.ts index f35170b92..0318bbbda 100644 --- a/app/verticals/party-registry/src/services/party-search-projection.service.ts +++ b/app/verticals/party-registry/src/services/party-search-projection.service.ts @@ -18,8 +18,6 @@ import type { CounterpartyRef } from '../../shared/resources/counterparty.ts'; import { PartySearchProjectionUnavailable } from '../../shared/domain/search-projection-error.ts'; import { PartySearchProjectionSource } from './party-search-projection-source.service.ts'; -export { PartySearchProjectionSource } from './party-search-projection-source.service.ts'; - export interface PartySearchSourceValue { readonly value: string; readonly state: string; @@ -27,7 +25,7 @@ export interface PartySearchSourceValue { readonly validTo?: string; } -export interface PartySearchSourceContact extends PartySearchSourceValue { +interface PartySearchSourceContact extends PartySearchSourceValue { readonly type: 'EMAIL' | 'PHONE'; readonly privacy: 'PUBLIC' | 'BUSINESS_SENSITIVE' | 'PERSONAL'; } diff --git a/app/verticals/party-registry/src/worker-host/layer.ts b/app/verticals/party-registry/src/worker-host/layer.ts index 5cf770d9d..54475cf3f 100644 --- a/app/verticals/party-registry/src/worker-host/layer.ts +++ b/app/verticals/party-registry/src/worker-host/layer.ts @@ -24,7 +24,7 @@ export { OutboxRepositoryLive as outboxWorkerRepositoryLive, } from '@app/core-runtime/outbox/worker'; -export const outboxWorkerInfrastructureLayer: Layer.Layer< +const outboxWorkerInfrastructureLayer: Layer.Layer< OutboxRuntime, never, Layer.Services @@ -59,21 +59,6 @@ export const outboxWorkerHandlerLayers: OutboxWorkerHandlerLayers = Object.freez searchWorkerSnapshot: CoreSearchWorkerSnapshotLive, }); -type OutboxWorkerDependencyLayers = readonly [ - OutboxWorkerHandlerLayers['projectionSource'], - OutboxWorkerHandlerLayers['searchIngestion'], - OutboxWorkerHandlerLayers['searchProjectionStore'], - OutboxWorkerHandlerLayers['searchWorkerSnapshot'], -]; - -/** Applied in order by the process-level composition root. */ -export const outboxWorkerDependencyLayers: OutboxWorkerDependencyLayers = Object.freeze([ - outboxWorkerHandlerLayers.projectionSource, - outboxWorkerHandlerLayers.searchIngestion, - outboxWorkerHandlerLayers.searchProjectionStore, - outboxWorkerHandlerLayers.searchWorkerSnapshot, -] as const); - export const outboxWorkerLayer: Layer.Layer< OutboxRuntime | PartySearchProjector, never, diff --git a/app/verticals/party-registry/tests/components/contacts-page.test.tsx b/app/verticals/party-registry/tests/components/contacts-page.test.tsx index 265aac85a..89aca7620 100644 --- a/app/verticals/party-registry/tests/components/contacts-page.test.tsx +++ b/app/verticals/party-registry/tests/components/contacts-page.test.tsx @@ -2,7 +2,7 @@ import { afterEach, expect, rstest, test } from '@rstest/core'; import { cleanup, render, screen } from '@testing-library/react'; import csCatalog from '../../locales/cs/party-registry.json'; import enCatalog from '../../locales/en/party-registry.json'; -import { ContactsPage } from '../../src/routes/[lang]/contacts/page.tsx'; +import ContactsPage from '../../src/routes/[lang]/contacts/page.tsx'; interface LocaleState { current: 'cs' | 'en'; diff --git a/app/verticals/party-registry/tests/integration/ares-governed.test.ts b/app/verticals/party-registry/tests/integration/ares-governed.test.ts index e4fc7d611..de7bad7b4 100644 --- a/app/verticals/party-registry/tests/integration/ares-governed.test.ts +++ b/app/verticals/party-registry/tests/integration/ares-governed.test.ts @@ -411,14 +411,16 @@ test('exported ARES coordinator uses real authorized HTTP commands, canonical pe assert.equal(persisted.contacts.length, 1); const identifierEvidence = persisted.identifiers[0]?.externalEvidence; const contactEvidence = persisted.contacts[0]?.externalEvidence; - assert.equal(identifierEvidence?.queryIco, '27074358'); - assert.equal(identifierEvidence?.observedAt, encodedObservation.observedAt); - assert.equal(identifierEvidence?.servedAt, encodedObservation.servedAt); - assert.equal(identifierEvidence?.providerChangedOn, encodedObservation.providerChangedOn); - assert.equal(identifierEvidence?.providerRecordRef, encodedObservation.providerRecordRef); - assert.equal(contactEvidence?.observedAt, encodedObservation.observedAt); - assert.equal(contactEvidence?.providerChangedOn, encodedObservation.providerChangedOn); - assert.equal(contactEvidence?.providerRecordRef, encodedObservation.providerRecordRef); + assert.ok(identifierEvidence); + assert.ok(contactEvidence); + assert.equal(identifierEvidence.queryIco, '27074358'); + assert.equal(identifierEvidence.observedAt, encodedObservation.observedAt); + assert.equal(identifierEvidence.servedAt, encodedObservation.servedAt); + assert.equal(identifierEvidence.providerChangedOn, encodedObservation.providerChangedOn); + assert.equal(identifierEvidence.providerRecordRef, encodedObservation.providerRecordRef); + assert.equal(contactEvidence.observedAt, encodedObservation.observedAt); + assert.equal(contactEvidence.providerChangedOn, encodedObservation.providerChangedOn); + assert.equal(contactEvidence.providerRecordRef, encodedObservation.providerRecordRef); assert.equal( persisted.assertions.find((item) => item.factKind === 'DISPLAY_NAME')?.externalEvidence ?.decidedAt, diff --git a/app/verticals/party-registry/tests/integration/database-boundary.test.ts b/app/verticals/party-registry/tests/integration/database-boundary.test.ts index 2bc0e81b6..48a36fd8d 100644 --- a/app/verticals/party-registry/tests/integration/database-boundary.test.ts +++ b/app/verticals/party-registry/tests/integration/database-boundary.test.ts @@ -464,13 +464,12 @@ test('enforces Party owner invariants, tenant isolation, and independent fact li const [scheduledContactEnd] = await runEffectTestPromise( admin.select().from(partyContactPoints).where(eq(partyContactPoints.contactPointId, emailA2)), ); - assert.equal(scheduledContactEnd?.isCurrent, true); - assert.equal(scheduledContactEnd?.endReason, 'Future email retirement scheduled'); - assert.equal(scheduledContactEnd?.evidenceReference, 'evidence:original-contact:1'); - assert.deepEqual(scheduledContactEnd?.additionalEvidenceRefs, [ - 'evidence:additional-contact:1', - ]); - assert.deepEqual(scheduledContactEnd?.endEvidenceRefs, []); + assert.ok(scheduledContactEnd); + assert.equal(scheduledContactEnd.isCurrent, true); + assert.equal(scheduledContactEnd.endReason, 'Future email retirement scheduled'); + assert.equal(scheduledContactEnd.evidenceReference, 'evidence:original-contact:1'); + assert.deepEqual(scheduledContactEnd.additionalEvidenceRefs, ['evidence:additional-contact:1']); + assert.deepEqual(scheduledContactEnd.endEvidenceRefs, []); const [scheduledPurposeEnd] = await runEffectTestPromise( admin .select() diff --git a/app/verticals/party-registry/tests/unit/gateway-assertion-redemption-runtime.test.ts b/app/verticals/party-registry/tests/unit/gateway-assertion-redemption-runtime.test.ts index a53e281ae..2cf1cb1de 100644 --- a/app/verticals/party-registry/tests/unit/gateway-assertion-redemption-runtime.test.ts +++ b/app/verticals/party-registry/tests/unit/gateway-assertion-redemption-runtime.test.ts @@ -2,10 +2,10 @@ import { runEffectTestPromise } from '@app/core-runtime/testing/effect-runtime'; import { GATEWAY_ASSERTION_CLOCK_SKEW_SECONDS } from '@app/shared-contracts'; import { PgClient } from '@effect/sql-pg'; import { makeWithDefaults } from 'drizzle-orm/effect-postgres'; -import { Cause, Clock, Effect, Exit, Schema, Stream } from 'effect'; +import { Cause, Clock, Effect, Exit, Schema } from 'effect'; import { TestClock } from 'effect/testing'; import { Reactivity } from 'effect/unstable/reactivity'; -import type { Connection } from 'effect/unstable/sql/SqlConnection'; +import { testSqlConnection } from '../../../../packages/core-runtime/tests/support/sql-connection.ts'; import { ConnectionError, SqlError } from 'effect/unstable/sql/SqlError'; import assert from 'node:assert/strict'; import test from 'node:test'; @@ -26,16 +26,7 @@ const makeRedemptionFixture = ( execute: (sql: string, params: readonly unknown[]) => Effect.Effect, ) => Effect.gen(function* makeRedemptionFixtureEffect() { - const values = (sql: string, params: readonly unknown[]) => - execute(sql, params).pipe(Effect.map((rows) => rows.map(Object.values))); - const connection: Connection = { - execute, - executeRaw: execute, - executeStream: (sql, params) => Stream.fromIterableEffect(execute(sql, params)), - executeUnprepared: execute, - executeValues: values, - executeValuesUnprepared: values, - }; + const connection = testSqlConnection(execute); const reactivity = yield* Reactivity.make; const client = yield* PgClient.makeWith({ acquirer: Effect.succeed(connection), diff --git a/app/verticals/party-registry/tests/unit/identity-party-detail-history.test.ts b/app/verticals/party-registry/tests/unit/identity-party-detail-history.test.ts index d11ba2deb..a9a68ddac 100644 --- a/app/verticals/party-registry/tests/unit/identity-party-detail-history.test.ts +++ b/app/verticals/party-registry/tests/unit/identity-party-detail-history.test.ts @@ -118,12 +118,13 @@ test('Party Detail persistence reads safe current and immutable historical asser [tenantId, partyId], [tenantId, partyId, 'ACTIVE', true], ]); - assert.match(queries[0] ?? '', /"tenant_id" = \$1/u); - assert.match(queries[0] ?? '', /"party_id" = \$2/u); - assert.doesNotMatch(queries[0] ?? '', /provenance|principal|invocation|verification/u); - assert.doesNotMatch(queries[1] ?? '', /external_evidence/u); - assert.match(queries[1] ?? '', /"state" = \$3/u); - assert.match(queries[1] ?? '', /"is_current" = \$4/u); + const [historyQuery = '', currentQuery = ''] = queries; + assert.match(historyQuery, /"tenant_id" = \$1/u); + assert.match(historyQuery, /"party_id" = \$2/u); + assert.doesNotMatch(historyQuery, /provenance|principal|invocation|verification/u); + assert.doesNotMatch(currentQuery, /external_evidence/u); + assert.match(currentQuery, /"state" = \$3/u); + assert.match(currentQuery, /"is_current" = \$4/u); const detail = yield* readPartyDetailFromServices( partyRef, tenantId, diff --git a/app/verticals/party-registry/tests/unit/matching-persistence.test.ts b/app/verticals/party-registry/tests/unit/matching-persistence.test.ts index 71191029d..47047bc80 100644 --- a/app/verticals/party-registry/tests/unit/matching-persistence.test.ts +++ b/app/verticals/party-registry/tests/unit/matching-persistence.test.ts @@ -424,6 +424,32 @@ test('Create Party matching an existing subject publishes each newly accepted id }), )); +const invokeReviewedMatch = (subject: ReturnType) => + Effect.gen(function* invokeReviewedMatchEffect() { + const collector = createActionCollector( + resolveDuplicateCandidateMatchAction.descriptor.domainEvents, + 'party.registry', + resolveDuplicateCandidateMatchAction.descriptor.accessEvidencePolicy, + ); + const result = yield* getActionHandler(resolveDuplicateCandidateMatchAction)( + { + caseRef: makeDuplicateCandidateCaseRef(tenantId, candidateCaseId), + expectedRevision: 1, + reason: resolutionInput.reason, + selectedPartyRef: makePartyRef(tenantId, partyC), + }, + { + ...collector, + actionInvocationId, + scope: actionScope, + services: { + resolve: () => resolveDuplicateCandidateMatch(subject.transaction, resolutionInput), + }, + }, + ); + return { collector, result }; + }); + test('reviewed matching publishes the accepted identifier through its declared Action event and linked outbox', () => runEffectTestPromise( Effect.gen(function* reviewedMatchingPublishesTheAcceptedIdentifierThrough() { @@ -436,27 +462,7 @@ test('reviewed matching publishes the accepted identifier through its declared A [partyOfficialIdentifiers, [[]]], ]), ); - const collector = createActionCollector( - resolveDuplicateCandidateMatchAction.descriptor.domainEvents, - 'party.registry', - resolveDuplicateCandidateMatchAction.descriptor.accessEvidencePolicy, - ); - const result = yield* getActionHandler(resolveDuplicateCandidateMatchAction)( - { - caseRef: makeDuplicateCandidateCaseRef(tenantId, candidateCaseId), - expectedRevision: 1, - reason: resolutionInput.reason, - selectedPartyRef: makePartyRef(tenantId, partyC), - }, - { - ...collector, - actionInvocationId, - scope: actionScope, - services: { - resolve: () => resolveDuplicateCandidateMatch(subject.transaction, resolutionInput), - }, - }, - ); + const { collector, result } = yield* invokeReviewedMatch(subject); assert.equal(result.outcome, 'MATCH_EXISTING'); assert.equal('addedOfficialIdentifierRefs' in result, false); const evidence = collector.snapshot(); @@ -614,27 +620,7 @@ test('reviewed matching with already-owned claims creates no duplicate identifie [partyIdentifierClaims, [[{ officialIdentifierId, partyId: partyC }]]], ]), ); - const collector = createActionCollector( - resolveDuplicateCandidateMatchAction.descriptor.domainEvents, - 'party.registry', - resolveDuplicateCandidateMatchAction.descriptor.accessEvidencePolicy, - ); - const result = yield* getActionHandler(resolveDuplicateCandidateMatchAction)( - { - caseRef: makeDuplicateCandidateCaseRef(tenantId, candidateCaseId), - expectedRevision: 1, - reason: resolutionInput.reason, - selectedPartyRef: makePartyRef(tenantId, partyC), - }, - { - ...collector, - actionInvocationId, - scope: actionScope, - services: { - resolve: () => resolveDuplicateCandidateMatch(subject.transaction, resolutionInput), - }, - }, - ); + const { collector, result } = yield* invokeReviewedMatch(subject); assert.equal(result.outcome, 'MATCH_EXISTING'); assert.deepEqual(collector.snapshot().domainEvents, []); assert.deepEqual(collector.snapshot().outboxMessages, []); diff --git a/app/verticals/party-registry/tests/unit/schema-contract.test.ts b/app/verticals/party-registry/tests/unit/schema-contract.test.ts index 3a87f43e1..5a9640a67 100644 --- a/app/verticals/party-registry/tests/unit/schema-contract.test.ts +++ b/app/verticals/party-registry/tests/unit/schema-contract.test.ts @@ -294,17 +294,18 @@ test('preserves bounded external observation evidence separately from trusted ac } }); -// eslint-disable-next-line complexity -- One schema-boundary matrix keeps all related family invariants visible. -test('models typed contact, relationship, and Counterparty lifecycles with owner-local references', () => { +const checkSql = (checks: Readonly>, name: string) => checks[name] ?? ''; + +test('models typed contact point lifecycles with owner-local references', () => { const contactChecks = Object.fromEntries( configOf(partyContactPoints).checks.map((candidate) => [ candidate.name, dialect.sqlToQuery(candidate.value).sql, ]), ); - assert.match(contactChecks['party_contact_points_shape_ck'] ?? '', /EMAIL/u); - assert.match(contactChecks['party_contact_points_shape_ck'] ?? '', /PHONE/u); - assert.match(contactChecks['party_contact_points_shape_ck'] ?? '', /ADDRESS/u); + assert.match(checkSql(contactChecks, 'party_contact_points_shape_ck'), /EMAIL/u); + assert.match(checkSql(contactChecks, 'party_contact_points_shape_ck'), /PHONE/u); + assert.match(checkSql(contactChecks, 'party_contact_points_shape_ck'), /ADDRESS/u); for (const column of [ 'display_value', 'normalization_version', @@ -329,22 +330,25 @@ test('models typed contact, relationship, and Counterparty lifecycles with owner column, ); } - assert.match(contactChecks['party_contact_points_revision_ck'] ?? '', /> 0/u); + assert.match(checkSql(contactChecks, 'party_contact_points_revision_ck'), /> 0/u); assert.match( - contactChecks['party_contact_points_additional_evidence_ck'] ?? '', + checkSql(contactChecks, 'party_contact_points_additional_evidence_ck'), /additional_evidence_refs.*array.*additional_evidence_refs.*32/u, ); assert.match( - contactChecks['party_contact_points_end_evidence_ck'] ?? '', + checkSql(contactChecks, 'party_contact_points_end_evidence_ck'), /valid_to.*end_reason.*end_provenance_source.*end_provenance_method.*end_evidence_refs.*ended_by_action_invocation_id.*ended_by_principal_id.*ended_recorded_at/u, ); - assert.match(contactChecks['party_contact_points_shape_ck'] ?? '', /num_nonnulls/u); - assert.match(contactChecks['party_contact_points_shape_ck'] ?? '', /\^\\\+/u); + assert.match(checkSql(contactChecks, 'party_contact_points_shape_ck'), /num_nonnulls/u); + assert.match(checkSql(contactChecks, 'party_contact_points_shape_ck'), /\^\\\+/u); const preferredIndex = configOf(partyContactPoints).indexes.find( (candidate) => candidate.config.name === 'party_contact_points_current_preferred_uk', ); assert.equal(preferredIndex?.config.unique, true); assert.ok(preferredIndex?.config.where); +}); + +test('models contact point purpose lifecycles with owner-local references', () => { const purposeConfig = configOf(partyContactPointPurposes); const purposeChecks = Object.fromEntries( purposeConfig.checks.map((candidate) => [ @@ -352,10 +356,10 @@ test('models typed contact, relationship, and Counterparty lifecycles with owner dialect.sqlToQuery(candidate.value).sql, ]), ); - assert.match(purposeChecks['party_contact_point_purposes_key_ck'] ?? '', /REGISTERED/u); - assert.match(purposeChecks['party_contact_point_purposes_key_ck'] ?? '', /BILLING/u); - assert.match(purposeChecks['party_contact_point_purposes_key_ck'] ?? '', /DELIVERY/u); - assert.match(purposeChecks['party_contact_point_purposes_key_ck'] ?? '', /CORRESPONDENCE/u); + assert.match(checkSql(purposeChecks, 'party_contact_point_purposes_key_ck'), /REGISTERED/u); + assert.match(checkSql(purposeChecks, 'party_contact_point_purposes_key_ck'), /BILLING/u); + assert.match(checkSql(purposeChecks, 'party_contact_point_purposes_key_ck'), /DELIVERY/u); + assert.match(checkSql(purposeChecks, 'party_contact_point_purposes_key_ck'), /CORRESPONDENCE/u); for (const column of [ 'registry_context', 'jurisdiction', @@ -379,7 +383,7 @@ test('models typed contact, relationship, and Counterparty lifecycles with owner ); } assert.match( - purposeChecks['party_contact_point_purposes_end_evidence_ck'] ?? '', + checkSql(purposeChecks, 'party_contact_point_purposes_end_evidence_ck'), /valid_to.*end_reason.*end_provenance_source.*end_provenance_method.*end_evidence_refs.*ended_by_action_invocation_id.*ended_by_principal_id.*ended_recorded_at/u, ); assert.equal( @@ -399,16 +403,18 @@ test('models typed contact, relationship, and Counterparty lifecycles with owner (candidate) => candidate.config.name === 'party_contact_point_purposes_current_registered_uk', )?.config.where, ); +}); +test('models relationship lifecycles with owner-local references', () => { const relationshipChecks = Object.fromEntries( configOf(partyRelationships).checks.map((candidate) => [ candidate.name, dialect.sqlToQuery(candidate.value).sql, ]), ); - assert.match(relationshipChecks['party_relationships_type_ck'] ?? '', /CONTACT_PERSON_OF/u); + assert.match(checkSql(relationshipChecks, 'party_relationships_type_ck'), /CONTACT_PERSON_OF/u); assert.doesNotMatch( - relationshipChecks['party_relationships_type_ck'] ?? '', + checkSql(relationshipChecks, 'party_relationships_type_ck'), /EMPLOYEE_OF|BRANCH_OF|OTHER/u, ); assert.ok( @@ -435,20 +441,23 @@ test('models typed contact, relationship, and Counterparty lifecycles with owner false, ); assert.match( - relationshipChecks['party_relationships_interval_ck'] ?? '', + checkSql(relationshipChecks, 'party_relationships_interval_ck'), /valid_to.*is null.*valid_from.*is null.*valid_to.*>.*valid_from/u, ); assert.match( - relationshipChecks['party_relationships_assertion_state_ck'] ?? '', + checkSql(relationshipChecks, 'party_relationships_assertion_state_ck'), /ACTIVE.*SUPERSEDED.*RETRACTED.*DISPUTED/u, ); - assert.doesNotMatch(relationshipChecks['party_relationships_assertion_state_ck'] ?? '', /ENDED/u); + assert.doesNotMatch( + checkSql(relationshipChecks, 'party_relationships_assertion_state_ck'), + /ENDED/u, + ); const relationshipIntervalIndex = configOf(partyRelationships).indexes.find( (candidate) => candidate.config.name === 'party_relationships_interval_idx', ); assert.equal(relationshipIntervalIndex?.config.unique, false); assert.equal(relationshipIntervalIndex?.config.where, undefined); - assert.match(relationshipChecks['party_relationships_revision_ck'] ?? '', /> 0/u); + assert.match(checkSql(relationshipChecks, 'party_relationships_revision_ck'), /> 0/u); for (const column of [ 'end_reason', 'end_provenance_source', @@ -470,7 +479,9 @@ test('models typed contact, relationship, and Counterparty lifecycles with owner ), getTableName(parties), ); +}); +test('models Counterparty lifecycles with owner-local references', () => { assert.deepEqual(uniqueColumns(counterparties, 'party_counterparties_context_uk'), [ 'tenant_id', 'party_id', @@ -488,10 +499,10 @@ test('models typed contact, relationship, and Counterparty lifecycles with owner dialect.sqlToQuery(candidate.value).sql, ]), ); - assert.match(roleChecks['party_counterparty_role_periods_type_ck'] ?? '', /CUSTOMER/u); - assert.match(roleChecks['party_counterparty_role_periods_type_ck'] ?? '', /SUPPLIER/u); + assert.match(checkSql(roleChecks, 'party_counterparty_role_periods_type_ck'), /CUSTOMER/u); + assert.match(checkSql(roleChecks, 'party_counterparty_role_periods_type_ck'), /SUPPLIER/u); assert.doesNotMatch( - roleChecks['party_counterparty_role_periods_type_ck'] ?? '', + checkSql(roleChecks, 'party_counterparty_role_periods_type_ck'), /BUSINESS_PARTNER/u, ); for (const column of [ @@ -510,7 +521,7 @@ test('models typed contact, relationship, and Counterparty lifecycles with owner column, ); } - const roleEndEvidence = roleChecks['party_counterparty_role_periods_end_evidence_ck'] ?? ''; + const roleEndEvidence = checkSql(roleChecks, 'party_counterparty_role_periods_end_evidence_ck'); assert.match( roleEndEvidence, /valid_to[^)]*is null[^)]*end_provenance_source[^)]*is null[^)]*end_provenance_method[^)]*is null/u, @@ -527,7 +538,7 @@ test('models typed contact, relationship, and Counterparty lifecycles with owner 'effective intervals, not an is_current unique index, own role-period uniqueness', ); assert.doesNotMatch( - roleChecks['party_counterparty_role_periods_state_ck'] ?? '', + checkSql(roleChecks, 'party_counterparty_role_periods_state_ck'), /ACTIVE' and [^)]*is_current/u, ); }); diff --git a/app/verticals/party-registry/tests/unit/search-identifier-sync.test.ts b/app/verticals/party-registry/tests/unit/search-identifier-sync.test.ts index 47529628d..cbe050a12 100644 --- a/app/verticals/party-registry/tests/unit/search-identifier-sync.test.ts +++ b/app/verticals/party-registry/tests/unit/search-identifier-sync.test.ts @@ -4,7 +4,7 @@ import test from 'node:test'; import { DateTime, Effect, Option, Schema } from 'effect'; import { makeCoreSearchIngestion, - makeCoreSearchQueryRuntime, + createCoreSearchQueryRuntime, makeInMemoryCoreSearchProjectionStore, } from '@app/core-runtime'; import type { OutboxMessage, OutboxWorkerHandlerContext } from '@app/core-runtime'; @@ -145,7 +145,7 @@ const makeSearchFixture = (identifiers: readonly PartySearchSourceValue[]) => { return { deliver, query: () => - makeCoreSearchQueryRuntime(store).search({ + createCoreSearchQueryRuntime(store).search({ effectiveAt: '2026-09-03T00:00:00.000Z', includeArchived: false, moduleId: 'party.registry', diff --git a/app/verticals/party-registry/tests/unit/search-projector.test.ts b/app/verticals/party-registry/tests/unit/search-projector.test.ts index b1ddadb5a..da8018fa6 100644 --- a/app/verticals/party-registry/tests/unit/search-projector.test.ts +++ b/app/verticals/party-registry/tests/unit/search-projector.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { Effect, Exit, Match } from 'effect'; import { - makeCoreSearchQueryRuntime, + createCoreSearchQueryRuntime, makeCoreSearchIngestion, makeInMemoryCoreSearchProjectionStore, } from '@app/core-runtime'; @@ -79,7 +79,7 @@ test('post-commit projection makes only active permission-safe identity evidence kind: 'upsert', }), )(documents); - const search = makeCoreSearchQueryRuntime(store); + const search = createCoreSearchQueryRuntime(store); const query = (value: string) => search.search({ effectiveAt: '2026-09-03T00:00:00.000Z', @@ -131,7 +131,7 @@ test('aliases collapse to canonical identity and only alias-only evidence labels }), )(documents); const query = (value: string) => - makeCoreSearchQueryRuntime(store).search({ + createCoreSearchQueryRuntime(store).search({ includeArchived: false, moduleId: 'party.registry', query: value, @@ -175,7 +175,7 @@ test('snapshot-generation replay is idempotent, archive/unarchive refreshes and partyId: partyRef.resourceId, }); const query = (includeArchived = false) => - makeCoreSearchQueryRuntime(store).search({ + createCoreSearchQueryRuntime(store).search({ includeArchived, moduleId: 'party.registry', query: 'ACME', @@ -248,7 +248,7 @@ test('future-ended contact disappears at its period boundary without another lif }), )(documents); const query = (effectiveAt: string) => - makeCoreSearchQueryRuntime(store).search({ + createCoreSearchQueryRuntime(store).search({ effectiveAt, includeArchived: false, moduleId: 'party.registry', @@ -312,7 +312,7 @@ test('Counterparty identity survives aliases, current-role expiry and canonical- kind: 'upsert', }), )(documents); - const gateway = makePartySearchProjectionGateway(makeCoreSearchQueryRuntime(store)); + const gateway = makePartySearchProjectionGateway(createCoreSearchQueryRuntime(store)); const input = { effectiveAt: '2026-09-03T00:00:00.000Z', includeArchived: false, @@ -371,7 +371,7 @@ test('shared public contact returns multiple Parties without uniqueness or match kind: 'upsert', }), )(documents); - const hits = yield* makeCoreSearchQueryRuntime(store).search({ + const hits = yield* createCoreSearchQueryRuntime(store).search({ includeArchived: false, moduleId: 'party.registry', query: 'public@example.test', @@ -415,7 +415,7 @@ test('rebuild reconciles omitted documents and preserves tombstones against stal partyId: 'party-1', }); assert.deepEqual( - yield* makeCoreSearchQueryRuntime(store).search({ + yield* createCoreSearchQueryRuntime(store).search({ includeArchived: true, moduleId: 'party.registry', query: 'ACME', @@ -455,7 +455,7 @@ test('source failure is sanitized and leaves previously searchable state intact partyId: 'party-1', }), ); - const priorHits = yield* makeCoreSearchQueryRuntime(store).search({ + const priorHits = yield* createCoreSearchQueryRuntime(store).search({ includeArchived: false, moduleId: 'party.registry', query: 'ACME', @@ -542,7 +542,7 @@ test('projection generation is independent of an out-of-order business event seq partyId: 'party-1', }, ); - const hits = yield* makeCoreSearchQueryRuntime(store).search({ + const hits = yield* createCoreSearchQueryRuntime(store).search({ includeArchived: false, moduleId: 'party.registry', query: 'ACME', @@ -591,7 +591,7 @@ test('correction and identifier/contact changes replace obsolete evidence instea partyId: 'party-1', }); const query = (value: string) => - makeCoreSearchQueryRuntime(store).search({ + createCoreSearchQueryRuntime(store).search({ includeArchived: false, moduleId: 'party.registry', query: value, @@ -628,7 +628,7 @@ test('a complete empty rebuild also rejects delayed evidence for a never-before- yield* projector.project(context, { partyId: 'party-1', }); - const hits = yield* makeCoreSearchQueryRuntime(store).search({ + const hits = yield* createCoreSearchQueryRuntime(store).search({ includeArchived: true, moduleId: 'party.registry', query: 'ACME', diff --git a/app/verticals/party-registry/vertical.manifest.ts b/app/verticals/party-registry/vertical.manifest.ts index b5846c010..163522872 100644 --- a/app/verticals/party-registry/vertical.manifest.ts +++ b/app/verticals/party-registry/vertical.manifest.ts @@ -18,7 +18,7 @@ import { AresLookupApi } from './shared/apis/ares-lookup.ts'; import { attachOrganizationEngagementAction } from './src/actions/attach-organization-engagement.action.ts'; import { attachPersonEngagementAction } from './src/actions/attach-person-engagement.action.ts'; import { confirmDuplicatePartiesAction } from './src/actions/confirm-duplicate-parties.action.ts'; -import { ContactsPage } from './src/routes/[lang]/contacts/page.tsx'; +import ContactsPage from './src/routes/[lang]/contacts/page.tsx'; import { correctPartyFactAction } from './src/actions/correct-party-fact.action.ts'; import { counterpartyCreateAction } from './src/actions/counterparty-create.action.ts'; import { CounterpartyReadApi } from './shared/apis/counterparty-read.ts'; From f9b626eab647a62c91785d861d9ceab9d235dc00 Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Tue, 8 Sep 2026 11:47:50 +0200 Subject: [PATCH 02/13] refactor: checkpoint exhaustive quality purge and enforcement Publish the second cleanup pass for incremental review. Dead-code and clone reductions, strict audit enforcement, and switching regression tests are checkpointed together. Cross-generator integration and aggregate validation remain in progress; this checkpoint is not merge-ready. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/quality-audit.yml | 9 +- app/apps/shell-super-app/api/auth/config.ts | 28 +- .../api/auth/configuration-provider.ts | 21 + .../shell-super-app/api/auth/db/catalog.ts | 13 +- .../shell-super-app/api/auth/db/client.ts | 7 +- .../api/auth/gateway-issuer-config.ts | 22 +- app/apps/shell-super-app/api/index.ts | 256 ++-- app/apps/shell-super-app/shared/api.ts | 92 -- .../shared/ultramodern-build.ts | 5 - .../shared/vertical-showcase.tsx | 26 - .../src/routes/shell-frame.tsx | 227 ++- .../src/routes/use-shell-controls.ts | 74 +- .../src/routes/vertical-components.tsx | 2 - .../src/routes/vertical-components.worker.tsx | 2 - .../shell-super-app/src/ultramodern-build.ts | 7 - .../shell-super-app/tests/e2e/auth-fixture.ts | 29 +- .../shell-super-app/tests/e2e/login.spec.ts | 150 +- .../tests/integration/auth-runtime.test.ts | 319 ++-- .../generated-owner-isolation.test.ts | 18 +- .../identity-modes-runtime.test.ts | 27 +- .../tests/support/context-access-double.ts | 18 + .../tests/unit/auth-contract.test.ts | 59 +- .../tests/unit/auth-schema.test.ts | 19 +- .../tests/unit/configuration-provider.test.ts | 61 + .../tests/unit/impersonation-service.test.ts | 18 +- .../tests/unit/layout.test.tsx | 105 ++ .../tests/unit/routes/home/page.test.tsx | 115 ++ .../tests/unit/routes/login/locales.test.ts | 59 +- .../tests/unit/routes/login/page.test.tsx | 245 ++- .../tests/unit/routes/modules/page.test.tsx | 227 +-- app/docs/quality-audit.md | 11 +- app/package.json | 8 +- .../core-runtime/scripts/verify-db-schema.mts | 74 +- .../core-runtime/src/actions/context.ts | 27 +- .../core-runtime/src/actions/error-schema.ts | 13 + .../core-runtime/src/actions/errors.ts | 229 +-- .../core-runtime/src/actions/events.ts | 40 +- .../src/actions/principal-context.ts | 21 +- .../core-runtime/src/actions/repository.ts | 57 +- .../src/actions/string-schemas.ts | 9 + .../src/actions/transaction-error.ts | 14 +- .../auth/principal-administration-reads.ts | 11 +- .../src/auth/principal-management.ts | 15 +- app/packages/core-runtime/src/db/schema.ts | 85 +- app/packages/core-runtime/src/index.ts | 2 - .../actions/bind-managed-api-key.action.ts | 5 +- .../actions/change-principal-status.action.ts | 5 +- .../create-non-human-principal.action.ts | 5 +- ...t-managed-api-key-binding-status.action.ts | 5 +- .../set-self-api-key-binding-status.action.ts | 5 +- .../core-runtime/src/outbox/errors.ts | 21 - .../core-runtime/src/search/persistence.ts | 30 +- .../core-runtime/src/testing/actions.ts | 44 +- .../integration/action-permission.test.ts | 46 +- .../tests/integration/action-runtime.test.ts | 148 +- .../integration/legal-entity-context.test.ts | 40 +- .../integration/module-state-gate.test.ts | 24 +- .../tests/integration/outbox-runtime.test.ts | 697 ++++----- .../integration/principal-management.test.ts | 9 +- .../integration/principal-resolver.test.ts | 40 +- .../integration/tenant-module-state.test.ts | 67 +- .../tests/support/fixture-cleanup.ts | 10 + .../tests/support/installed-catalog.ts | 23 + .../tests/unit/action-testing-harness.test.ts | 38 + .../tests/unit/context-access.test.ts | 42 +- .../tests/unit/module-catalog.test.ts | 93 +- .../tests/unit/read-runtime.test.ts | 199 ++- .../tests/unit/tenant-module-state.test.ts | 25 +- .../shared-contracts/src/gateway-context.ts | 25 +- .../tests/unit/client-runtime.test.ts | 49 +- app/quality-audit/knip-model.mts | 88 +- app/scripts/assert-mf-types.mts | 12 +- app/scripts/boundary-source-structure.mts | 110 ++ .../check-module-entrypoint-boundaries.mts | 94 +- .../check-ultramodern-api-boundaries.mts | 175 ++- .../generated-governed-http-boundary.mts | 1319 ++++++++--------- app/scripts/generated-module-api-boundary.mts | 1244 ++++++++-------- app/scripts/migrate-strict-effect.mts | 13 +- app/scripts/proof-cloudflare-version.mts | 13 +- app/scripts/proof-workerd-ssr.mts | 58 +- app/scripts/quality-audit-gate.mts | 167 +++ app/scripts/scaffolding/cli.mts | 224 +-- .../governed-contribution/scaffold.mts | 143 +- .../scaffold.mts | 2 +- .../scaffolding/module-contract/scaffold.mts | 27 +- .../search-provider-access/scaffold.mts | 6 +- app/scripts/scaffolding/shared.mts | 48 +- .../tests/resource-generator.test.mts | 30 +- .../tests/retire-contribution.test.mts | 122 +- .../tests/scaffold-generators.test.mts | 1029 ++++++------- app/scripts/tests/api-only-tooling.test.mts | 714 +++++---- .../tests/boundary-source-structure.test.mts | 103 ++ .../module-entrypoint-boundaries.test.mts | 119 +- .../tests/plan-deployment-impact.test.mts | 17 +- app/scripts/tests/quality-audit-gate.test.mts | 272 ++++ .../tests/quality-audit-model.test.mts | 7 +- .../ultramodern-api-boundary-rules.mts | 770 +++++----- app/scripts/ultramodern-command-failure.mts | 9 + .../ultramodern-performance-readiness.mts | 13 +- app/scripts/ultramodern-typecheck.mts | 13 +- .../validate-ultramodern-workspace.mts | 66 +- app/scripts/verify-cloudflare-output.mts | 13 +- .../feature-universal-module-state-gate.md | 3 +- .../rules/no-duplicate-literal-vocabulary.ts | 12 +- .../rules/no-hand-parsed-environment-value.ts | 18 +- .../rules/no-hand-rolled-tagged-union.ts | 13 +- .../rules/no-imperative-loop-in-effect-gen.ts | 13 +- .../rules/no-interface-first-codec.ts | 18 +- .../no-json-schema-as-document-contract.ts | 127 +- .../rules/no-layer-or-die-outside-root.ts | 9 +- .../rules/no-literal-union-type-alias.ts | 13 +- .../rules/no-local-defect-seam.ts | 12 +- ...al-error-handling-in-scaffold-templates.ts | 12 +- .../rules/no-manual-identity-annotations.ts | 24 +- .../rules/no-manual-route-param-parsing.ts | 13 +- .../rules/no-nullable-service-outcome.ts | 13 +- .../rules/no-per-request-key-material.ts | 19 +- .../rules/no-promise-shaped-port.ts | 15 +- .../no-runtime-construction-outside-root.ts | 9 +- .../rules/no-sequential-independent-yields.ts | 13 +- .../rules/no-sync-schema-codec.ts | 113 +- .../rules/prefer-match-over-tag-switch.ts | 14 +- ...e-context-service-for-service-interface.ts | 22 +- ...re-observability-layers-at-runtime-root.ts | 45 +- .../effect-native/shared/schema-identity.ts | 20 +- .../tests/shared-helpers.test.mts | 24 + .../api/engagement-profile-problems.ts | 9 +- .../api/engagement-profile-server.ts | 148 +- .../api/fail-authenticated-problem.ts | 13 + .../api/party-command-problems.ts | 15 +- .../party-registry/shared/command-api.ts | 38 +- .../resources/duplicate-candidate-case.ts | 41 +- .../shared/resources/party-correction.ts | 41 +- .../shared/resources/party-match-decision.ts | 41 +- .../resources/party-official-identifier.ts | 41 +- .../shared/resources/timeline-resource.ts | 39 + .../archive-organization-engagement.action.ts | 53 +- .../archive-person-engagement.action.ts | 51 +- .../src/actions/attach-engagement-handler.ts | 31 + .../attach-organization-engagement.action.ts | 44 +- .../attach-person-engagement.action.ts | 44 +- .../attached-official-identifier-events.ts | 39 + .../confirm-duplicate-parties.action.ts | 24 +- .../counterparty-role-action-support.ts | 25 + .../actions/counterparty-role-add.action.ts | 25 +- .../actions/counterparty-role-end.action.ts | 25 +- .../create-party-relationship.action.ts | 24 +- .../src/actions/create-party.action.ts | 29 +- .../dismiss-duplicate-candidate.action.ts | 24 +- .../duplicate-case-resolution-service.ts | 27 + .../actions/end-party-relationship.action.ts | 26 +- .../actions/engagement-lifecycle-handler.ts | 40 + ...plicate-candidate-needs-evidence.action.ts | 24 +- .../src/actions/relationship-event-payload.ts | 14 + ...esolve-duplicate-candidate-match.action.ts | 30 +- ...narchive-organization-engagement.action.ts | 53 +- .../unarchive-person-engagement.action.ts | 51 +- .../update-party-relationship.action.ts | 26 +- .../src/api/counterparty-read-support.ts | 58 + .../src/api/counterparty-read.read.ts | 75 +- .../src/api/counterparty-role-history.read.ts | 83 +- .../api/duplicate-candidate-detail.read.ts | 44 +- .../src/api/engagement-profile-client.ts | 111 +- .../organization-engagement-profile.read.ts | 53 +- .../src/api/party-command-client.ts | 507 ++----- .../src/api/party-correction.read.ts | 44 +- .../src/api/party-match.read.ts | 17 +- .../party-official-identifier-detail.read.ts | 43 +- .../party-official-identifier-history.read.ts | 16 +- .../src/api/party-registry-http-client.ts | 11 - .../src/api/person-engagement-profile.read.ts | 53 +- .../party-registry/src/api/read-outcome.ts | 28 + .../party-registry/src/db/catalog.ts | 18 +- app/verticals/party-registry/src/db/client.ts | 7 +- .../src/db/compare-table-catalog.ts | 11 + .../src/db/engagement-catalog.ts | 19 +- app/verticals/party-registry/src/db/schema.ts | 105 +- .../src/routes/ultramodern-jsonld.ts | 113 -- .../src/search/counterparties.provider.ts | 73 +- .../src/search/parties.provider.ts | 18 +- .../src/search/search-normalization.ts | 17 + .../engagement-profile-persistence.service.ts | 273 ++-- .../src/services/party-correction.service.ts | 26 +- .../party-identity-persistence.service.ts | 36 +- .../party-matching-persistence.service.ts | 21 +- .../party-relationship-persistence.service.ts | 61 +- .../party-registry/src/ultramodern-build.ts | 7 - .../src/workers/party-search-worker.ts | 41 + ...ct-contact-point-added-to-search.worker.ts | 32 +- ...ct-contact-point-ended-to-search.worker.ts | 32 +- ...-contact-point-updated-to-search.worker.ts | 32 +- ...t-counterparty-created-to-search.worker.ts | 32 +- ...ounterparty-role-added-to-search.worker.ts | 32 +- ...ounterparty-role-ended-to-search.worker.ts | 32 +- ...icial-identifier-added-to-search.worker.ts | 35 +- ...icial-identifier-ended-to-search.worker.ts | 32 +- ...ial-identifier-updated-to-search.worker.ts | 35 +- ...project-party-archived-to-search.worker.ts | 32 +- .../project-party-created-to-search.worker.ts | 32 +- ...t-party-fact-corrected-to-search.worker.ts | 32 +- ...oject-party-unarchived-to-search.worker.ts | 32 +- .../project-party-updated-to-search.worker.ts | 32 +- .../integration/database-boundary.test.ts | 125 +- .../engagement-database-boundary.test.ts | 51 +- .../integration/identity-concurrency.test.ts | 59 +- .../tests/support/command-assertion-fetch.ts | 22 + .../tests/support/database-boundary.ts | 41 + .../api-integration-command-client.test.ts | 51 +- .../api-integration-command-recovery.test.ts | 255 ++-- .../api-integration-command-runtime.test.ts | 493 +++--- .../unit/attach-engagement-handler.test.ts | 58 + .../tests/unit/catalog-contract.test.ts | 15 + .../tests/unit/correction-contract.test.ts | 174 +-- .../unit/counterparty-read-support.test.ts | 80 + .../unit/engagement-catalog-contract.test.ts | 15 + .../unit/engagement-lifecycle-handler.test.ts | 71 + ...gement-profile-persistence-service.test.ts | 134 ++ .../unit/identity-persistence.service.test.ts | 14 +- .../tests/unit/party-search-worker.test.ts | 90 ++ .../tests/unit/read-outcome.test.ts | 52 + .../relationship-operation-contract.test.ts | 11 + .../tests/unit/schema-contract.test.ts | 34 +- .../tests/unit/search-identifier-sync.test.ts | 35 +- .../tests/unit/search-provider.test.ts | 22 + .../unit/timeline-resource-contract.test.ts | 63 + 225 files changed, 8095 insertions(+), 9673 deletions(-) create mode 100644 app/apps/shell-super-app/api/auth/configuration-provider.ts delete mode 100644 app/apps/shell-super-app/shared/vertical-showcase.tsx delete mode 100644 app/apps/shell-super-app/src/routes/vertical-components.tsx delete mode 100644 app/apps/shell-super-app/src/routes/vertical-components.worker.tsx delete mode 100644 app/apps/shell-super-app/src/ultramodern-build.ts create mode 100644 app/apps/shell-super-app/tests/support/context-access-double.ts create mode 100644 app/apps/shell-super-app/tests/unit/configuration-provider.test.ts create mode 100644 app/packages/core-runtime/src/actions/error-schema.ts create mode 100644 app/packages/core-runtime/src/actions/string-schemas.ts create mode 100644 app/packages/core-runtime/tests/support/fixture-cleanup.ts create mode 100644 app/packages/core-runtime/tests/support/installed-catalog.ts create mode 100644 app/scripts/boundary-source-structure.mts create mode 100644 app/scripts/quality-audit-gate.mts create mode 100644 app/scripts/tests/boundary-source-structure.test.mts create mode 100644 app/scripts/tests/quality-audit-gate.test.mts create mode 100644 app/scripts/ultramodern-command-failure.mts create mode 100644 app/verticals/party-registry/api/fail-authenticated-problem.ts create mode 100644 app/verticals/party-registry/shared/resources/timeline-resource.ts create mode 100644 app/verticals/party-registry/src/actions/attach-engagement-handler.ts create mode 100644 app/verticals/party-registry/src/actions/attached-official-identifier-events.ts create mode 100644 app/verticals/party-registry/src/actions/counterparty-role-action-support.ts create mode 100644 app/verticals/party-registry/src/actions/duplicate-case-resolution-service.ts create mode 100644 app/verticals/party-registry/src/actions/engagement-lifecycle-handler.ts create mode 100644 app/verticals/party-registry/src/actions/relationship-event-payload.ts create mode 100644 app/verticals/party-registry/src/api/counterparty-read-support.ts create mode 100644 app/verticals/party-registry/src/api/read-outcome.ts create mode 100644 app/verticals/party-registry/src/db/compare-table-catalog.ts delete mode 100644 app/verticals/party-registry/src/routes/ultramodern-jsonld.ts create mode 100644 app/verticals/party-registry/src/search/search-normalization.ts delete mode 100644 app/verticals/party-registry/src/ultramodern-build.ts create mode 100644 app/verticals/party-registry/src/workers/party-search-worker.ts create mode 100644 app/verticals/party-registry/tests/support/command-assertion-fetch.ts create mode 100644 app/verticals/party-registry/tests/support/database-boundary.ts create mode 100644 app/verticals/party-registry/tests/unit/attach-engagement-handler.test.ts create mode 100644 app/verticals/party-registry/tests/unit/counterparty-read-support.test.ts create mode 100644 app/verticals/party-registry/tests/unit/engagement-lifecycle-handler.test.ts create mode 100644 app/verticals/party-registry/tests/unit/party-search-worker.test.ts create mode 100644 app/verticals/party-registry/tests/unit/read-outcome.test.ts create mode 100644 app/verticals/party-registry/tests/unit/timeline-resource-contract.test.ts diff --git a/.github/workflows/quality-audit.yml b/.github/workflows/quality-audit.yml index dfd45b4a8..dd4071176 100644 --- a/.github/workflows/quality-audit.yml +++ b/.github/workflows/quality-audit.yml @@ -1,4 +1,4 @@ -name: Quality Audit Reports +name: Quality Audit on: pull_request: @@ -22,7 +22,7 @@ concurrency: jobs: quality-audit: - name: Quality Audit (report only) + name: Quality Audit Guardrails runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -52,7 +52,7 @@ jobs: - name: Install dependencies without lifecycle scripts run: mise exec -- pnpm install --frozen-lockfile --ignore-scripts - - name: Verify audit runner behavior + - name: Verify audit runner and gate behavior run: mise exec -- pnpm quality:audit:test # Findings succeed; invalid or incomplete analysis fails visibly. @@ -60,6 +60,9 @@ jobs: id: audit run: mise exec -- pnpm quality:audit --output .codex/reports/quality-audit + - name: Enforce calibrated quality guardrails + run: mise exec -- pnpm quality:audit:gate --summary .codex/reports/quality-audit/summary.json + - name: Publish job summary if: ${{ always() && steps.prepare.outcome == 'success' }} run: | diff --git a/app/apps/shell-super-app/api/auth/config.ts b/app/apps/shell-super-app/api/auth/config.ts index cee1fe9de..577ff5702 100644 --- a/app/apps/shell-super-app/api/auth/config.ts +++ b/app/apps/shell-super-app/api/auth/config.ts @@ -1,5 +1,4 @@ -import { loadEnvironmentFileProvider } from './environment-file-provider.ts'; -import { APP_ENV_PATH } from '@app/core-runtime/workspace-environment'; +import { loadConfigurationProvider } from './configuration-provider.ts'; import { Config, ConfigProvider, Context, Effect, Layer, Redacted, Schema } from 'effect'; const AuthConfigError = Schema.TaggedError()('AuthConfigError', { @@ -7,7 +6,7 @@ const AuthConfigError = Schema.TaggedError()('AuthConfigError', { }); type AuthConfigFailure = InstanceType; -export const ROOT_ENV_PATH = APP_ENV_PATH; +export { APP_ENV_PATH as ROOT_ENV_PATH } from '@app/core-runtime/workspace-environment'; const EnvironmentKeySchema = Schema.Literals([ 'BETTER_AUTH_SECRET', @@ -122,20 +121,10 @@ const parseAuthConfigFromProvider = Effect.fn('AuthConfig.parseAuthConfigFromPro }, ); -const environmentProvider = (environment: Environment): ConfigProvider.ConfigProvider => - ConfigProvider.fromEnvRecord({ - BETTER_AUTH_SECRET: environment.BETTER_AUTH_SECRET, - BETTER_AUTH_SUPPORT_USER_IDS: environment.BETTER_AUTH_SUPPORT_USER_IDS, - BETTER_AUTH_TRUSTED_ORIGINS: environment.BETTER_AUTH_TRUSTED_ORIGINS, - BETTER_AUTH_URL: environment.BETTER_AUTH_URL, - DATABASE_URL: environment.DATABASE_URL, - NODE_ENV: environment.NODE_ENV, - }); - export const parseAuthConfig = ( environment: Environment, ): Effect.Effect => - parseAuthConfigFromProvider(environmentProvider(environment)); + parseAuthConfigFromProvider(ConfigProvider.fromEnvRecord(environment)); export interface LoadAuthConfigOptions { readonly environment?: Environment; @@ -145,15 +134,8 @@ export interface LoadAuthConfigOptions { export const loadAuthConfig = ( options: LoadAuthConfigOptions = {}, ): Effect.Effect => - loadEnvironmentFileProvider(options.envPath ?? ROOT_ENV_PATH, unableToLoadEnvironment).pipe( - Effect.flatMap((fileProvider) => - parseAuthConfigFromProvider( - (options.environment === undefined - ? ConfigProvider.fromEnv() - : environmentProvider(options.environment) - ).pipe(ConfigProvider.orElse(fileProvider)), - ), - ), + loadConfigurationProvider(options, unableToLoadEnvironment).pipe( + Effect.flatMap(parseAuthConfigFromProvider), ); export const AuthConfigLive = Layer.effect(AuthConfig, loadAuthConfig()); diff --git a/app/apps/shell-super-app/api/auth/configuration-provider.ts b/app/apps/shell-super-app/api/auth/configuration-provider.ts new file mode 100644 index 000000000..876ebe508 --- /dev/null +++ b/app/apps/shell-super-app/api/auth/configuration-provider.ts @@ -0,0 +1,21 @@ +import { APP_ENV_PATH } from '@app/core-runtime/workspace-environment'; +import { ConfigProvider, Effect } from 'effect'; +import { loadEnvironmentFileProvider } from './environment-file-provider.ts'; + +interface LoadConfigurationOptions { + readonly environment?: Readonly>>; + readonly envPath?: string; +} + +export const loadConfigurationProvider = ( + options: LoadConfigurationOptions, + unableToLoadEnvironment: () => Failure, +): Effect.Effect => + loadEnvironmentFileProvider(options.envPath ?? APP_ENV_PATH, unableToLoadEnvironment).pipe( + Effect.map((fileProvider) => + (options.environment === undefined + ? ConfigProvider.fromEnv() + : ConfigProvider.fromEnvRecord(options.environment) + ).pipe(ConfigProvider.orElse(fileProvider)), + ), + ); diff --git a/app/apps/shell-super-app/api/auth/db/catalog.ts b/app/apps/shell-super-app/api/auth/db/catalog.ts index 10cc2e867..73793a546 100644 --- a/app/apps/shell-super-app/api/auth/db/catalog.ts +++ b/app/apps/shell-super-app/api/auth/db/catalog.ts @@ -4,19 +4,12 @@ export const expectedAuthTableCatalog = AUTH_TABLE_INVENTORY.map( (tableName) => `${AUTH_SCHEMA_NAME}.${tableName}`, ); -export interface AuthCatalogDifference { - readonly missing: readonly string[]; - readonly unexpected: readonly string[]; -} - -export const compareAuthCatalog = ( - qualifiedTableNames: readonly string[], -): AuthCatalogDifference => { +export const compareAuthCatalog = (qualifiedTableNames: readonly string[]) => { const actual = new Set(qualifiedTableNames); const expected = new Set(expectedAuthTableCatalog); return { - missing: [...expected].filter((name) => !actual.has(name)).toSorted(), - unexpected: [...actual].filter((name) => !expected.has(name)).toSorted(), + missing: [...expected.difference(actual)].toSorted(), + unexpected: [...actual.difference(expected)].toSorted(), }; }; diff --git a/app/apps/shell-super-app/api/auth/db/client.ts b/app/apps/shell-super-app/api/auth/db/client.ts index e87ecd041..345e13ae6 100644 --- a/app/apps/shell-super-app/api/auth/db/client.ts +++ b/app/apps/shell-super-app/api/auth/db/client.ts @@ -32,12 +32,7 @@ const connectionFailure = (cause: unknown) => reason: 'Unable to initialize the authentication PostgreSQL pool', }), 'cause', - { - configurable: false, - enumerable: false, - value: cause, - writable: false, - }, + { value: cause }, ); export const acquirePoolResource = ( diff --git a/app/apps/shell-super-app/api/auth/gateway-issuer-config.ts b/app/apps/shell-super-app/api/auth/gateway-issuer-config.ts index 8826f49be..77ec394f3 100644 --- a/app/apps/shell-super-app/api/auth/gateway-issuer-config.ts +++ b/app/apps/shell-super-app/api/auth/gateway-issuer-config.ts @@ -1,6 +1,5 @@ -import { loadEnvironmentFileProvider } from './environment-file-provider.ts'; +import { loadConfigurationProvider } from './configuration-provider.ts'; import { Config, ConfigProvider, Effect, Redacted, Schema } from 'effect'; -import { ROOT_ENV_PATH } from './config.ts'; const withOptionalProperty = < Base extends object, @@ -107,16 +106,10 @@ const parseGatewayIssuerConfigFromProvider = Effect.fn( return { issuer: source.issuer, privateJwk }; }); -const environmentProvider = (environment: Environment): ConfigProvider.ConfigProvider => - ConfigProvider.fromEnvRecord({ - ONTOS_GATEWAY_ISSUER: environment.ONTOS_GATEWAY_ISSUER, - ONTOS_GATEWAY_PRIVATE_JWK: environment.ONTOS_GATEWAY_PRIVATE_JWK, - }); - export const parseGatewayIssuerConfig = ( environment: Environment, ): Effect.Effect => - parseGatewayIssuerConfigFromProvider(environmentProvider(environment)); + parseGatewayIssuerConfigFromProvider(ConfigProvider.fromEnvRecord(environment)); export interface LoadGatewayIssuerConfigOptions { readonly environment?: Environment; @@ -126,13 +119,6 @@ export interface LoadGatewayIssuerConfigOptions { export const loadGatewayIssuerConfig = ( options: LoadGatewayIssuerConfigOptions = {}, ): Effect.Effect => - loadEnvironmentFileProvider(options.envPath ?? ROOT_ENV_PATH, unableToLoadEnvironment).pipe( - Effect.flatMap((fileProvider) => - parseGatewayIssuerConfigFromProvider( - (options.environment === undefined - ? ConfigProvider.fromEnv() - : environmentProvider(options.environment) - ).pipe(ConfigProvider.orElse(fileProvider)), - ), - ), + loadConfigurationProvider(options, unableToLoadEnvironment).pipe( + Effect.flatMap(parseGatewayIssuerConfigFromProvider), ); diff --git a/app/apps/shell-super-app/api/index.ts b/app/apps/shell-super-app/api/index.ts index dfaf6adfd..bd761fb0a 100644 --- a/app/apps/shell-super-app/api/index.ts +++ b/app/apps/shell-super-app/api/index.ts @@ -585,12 +585,6 @@ const tenantProblem = (error: SwitchTenantRuntimeError): SwitchTenantProblem => Match.exhaustive, ); -const failTenantProblem = (tenantFailure: Failure) => - (Predicate.isTagged(tenantFailure, 'TenantAuthenticationRequiredProblem') - ? bearerChallenge - : Effect.void - ).pipe(Effect.andThen(Effect.fail(tenantFailure))); - const legalEntityAccessForbiddenProblem = (): LegalEntityAccessForbiddenProblem => problemDetails( 'LegalEntityAccessForbiddenProblem', @@ -600,7 +594,9 @@ const legalEntityAccessForbiddenProblem = (): LegalEntityAccessForbiddenProblem 'https://ontos.dev/problems/legal-entity-access-forbidden', ); -const failLegalEntityProblem = (failure: Failure) => +const failContextProblem = ( + failure: Failure, +) => (Predicate.isTagged(failure, 'TenantAuthenticationRequiredProblem') ? bearerChallenge : Effect.void @@ -970,13 +966,11 @@ const legalEntityGroupLive = HttpApiBuilder.group( const result = yield* authentication .resolveShellContext(requestHeaders(request.headers)) .pipe( - Effect.catch((error) => - pipe(error, tenantAuthenticationProblem, failLegalEntityProblem), - ), + Effect.catch((error) => pipe(error, tenantAuthenticationProblem, failContextProblem)), ); yield* forwardSetCookieHeaders(result.setCookieHeaders); if (result.state === 'anonymous') { - return yield* failLegalEntityProblem(tenantAuthenticationRequiredProblem()); + return yield* failContextProblem(tenantAuthenticationRequiredProblem()); } const selectedLegalEntityId = result.state === 'authenticated' ? result.identity.legalEntityId : undefined; @@ -998,7 +992,7 @@ const legalEntityGroupLive = HttpApiBuilder.group( ); }).pipe( recoverUnexpectedDefect(request, 'Unexpected legal-entity list defect', () => - failLegalEntityProblem(tenantInternalProblem()), + failContextProblem(tenantInternalProblem()), ), ), ) @@ -1010,7 +1004,7 @@ const legalEntityGroupLive = HttpApiBuilder.group( .pipe( Effect.catch( (error: AuthenticationRuntimeError | LegalEntitySelectionForbiddenError) => - failLegalEntityProblem( + failContextProblem( Predicate.isTagged(error, 'LegalEntitySelectionForbiddenError') ? legalEntityAccessForbiddenProblem() : tenantAuthenticationProblem(error), @@ -1025,7 +1019,7 @@ const legalEntityGroupLive = HttpApiBuilder.group( ); }).pipe( recoverUnexpectedDefect(request, 'Unexpected legal-entity switch defect', () => - failLegalEntityProblem(tenantInternalProblem()), + failContextProblem(tenantInternalProblem()), ), ), ), @@ -1039,7 +1033,7 @@ const tenantGroupLive = HttpApiBuilder.group(ShellAuthenticationApi, 'tenants', const result = yield* authentication .availableTenants(requestHeaders(request.headers)) .pipe( - Effect.catch((error) => pipe(error, tenantAuthenticationProblem, failTenantProblem)), + Effect.catch((error) => pipe(error, tenantAuthenticationProblem, failContextProblem)), ); yield* forwardSetCookieHeaders(result.setCookieHeaders); return yield* decodeResponse( @@ -1049,7 +1043,7 @@ const tenantGroupLive = HttpApiBuilder.group(ShellAuthenticationApi, 'tenants', ); }).pipe( recoverUnexpectedDefect(request, 'Unexpected tenant list defect', () => - failTenantProblem(tenantInternalProblem()), + failContextProblem(tenantInternalProblem()), ), ), ) @@ -1058,7 +1052,7 @@ const tenantGroupLive = HttpApiBuilder.group(ShellAuthenticationApi, 'tenants', const authentication = yield* AuthenticationService; const result = yield* authentication .switchTenant(payload.tenantId, requestHeaders(request.headers)) - .pipe(Effect.catch((error) => pipe(error, tenantProblem, failTenantProblem))); + .pipe(Effect.catch((error) => pipe(error, tenantProblem, failContextProblem))); yield* forwardSetCookieHeaders(result.setCookieHeaders); return yield* decodeResponse( SwitchTenantResponseSchema, @@ -1067,24 +1061,31 @@ const tenantGroupLive = HttpApiBuilder.group(ShellAuthenticationApi, 'tenants', ); }).pipe( recoverUnexpectedDefect(request, 'Unexpected tenant switch defect', () => - failTenantProblem(tenantInternalProblem()), + failContextProblem(tenantInternalProblem()), ), ), ), ); +const requireShellSession = Effect.fn('ShellApi.requireShellSession')(function* requireShellSession( + headers: RequestHeaders, +) { + const authentication = yield* AuthenticationService; + const session = yield* authentication + .resolveShellContext(requestHeaders(headers)) + .pipe( + Effect.catch((error) => pipe(error, shellProblemFromAuthenticationError, failShellProblem)), + ); + yield* forwardSetCookieHeaders(session.setCookieHeaders); + if (session.state === 'anonymous') { + return yield* failShellProblem(shellAuthenticationRequiredProblem()); + } + return session; +}); + const requireAuthenticatedShellContext = Effect.fn('ShellApi.requireAuthenticatedShellContext')( function* requireAuthenticatedShellContext(headers: RequestHeaders) { - const authentication = yield* AuthenticationService; - const session = yield* authentication - .resolveShellContext(requestHeaders(headers)) - .pipe( - Effect.catch((error) => pipe(error, shellProblemFromAuthenticationError, failShellProblem)), - ); - yield* forwardSetCookieHeaders(session.setCookieHeaders); - if (session.state === 'anonymous') { - return yield* failShellProblem(shellAuthenticationRequiredProblem()); - } + const session = yield* requireShellSession(headers); if (session.state !== 'authenticated') { return yield* failShellProblem(shellSelectionRequiredProblem()); } @@ -1099,18 +1100,7 @@ const compositionGroupLive = HttpApiBuilder.group( handlers .handle('shellComposition', ({ request }) => Effect.gen(function* shellCompositionHandler() { - const authentication = yield* AuthenticationService; - const session = yield* authentication - .resolveShellContext(requestHeaders(request.headers)) - .pipe( - Effect.catch((error) => - pipe(error, shellProblemFromAuthenticationError, failShellProblem), - ), - ); - yield* forwardSetCookieHeaders(session.setCookieHeaders); - if (session.state === 'anonymous') { - return yield* failShellProblem(shellAuthenticationRequiredProblem()); - } + const session = yield* requireShellSession(request.headers); if (session.state === 'access_blocked') { return yield* decodeResponse( ShellCompositionSchema, @@ -1192,18 +1182,7 @@ const resourcesGroupLive = HttpApiBuilder.group(ShellAuthenticationApi, 'resourc handlers .handle('search', ({ payload, request }) => Effect.gen(function* searchHandler() { - const authentication = yield* AuthenticationService; - const session = yield* authentication - .resolveShellContext(requestHeaders(request.headers)) - .pipe( - Effect.catch((error) => - pipe(error, shellProblemFromAuthenticationError, failShellProblem), - ), - ); - yield* forwardSetCookieHeaders(session.setCookieHeaders); - if (session.state === 'anonymous') { - return yield* failShellProblem(shellAuthenticationRequiredProblem()); - } + const session = yield* requireShellSession(request.headers); const governedReads = yield* ShellGovernedReads; const response = yield* governedReads .search({ @@ -1276,11 +1255,9 @@ const identityGroupLive = HttpApiBuilder.group(ShellAuthenticationApi, 'identity if (resolved.state !== 'authenticated') { return yield* failIdentityProblem(shellAuthenticationRequiredProblem()); } - return { authentication, resolved }; + return resolved; }), ); - const lifecycle = IdentityLifecycle; - const correlation = correlationFromRequest; const requiredIdempotencyKey = (headers: RequestHeaders) => { const value = headerValue(headers, 'idempotency-key'); return value === undefined @@ -1296,20 +1273,43 @@ const identityGroupLive = HttpApiBuilder.group(ShellAuthenticationApi, 'identity failIdentityProblem(shellInternalProblem()), ), ); + const keyIssuanceResponse = ( + effect: Effect.Effect, + ) => + Effect.matchEffect( + Effect.tap(effect, () => noStore), + { + onFailure: (error) => pipe(error, identityProblem, failIdentityProblem), + onSuccess: (response) => + decodeResponse(ApiKeyIssueResponseSchema, response, shellInternalProblem), + }, + ); + const mutationContext = Effect.fn('shell.identity.mutationContext')(function* mutationContext( + request: RequestWithHeaders, + headers: RequestHeaders, + ) { + const resolved = yield* authenticated(request); + const idempotencyKey = yield* requiredIdempotencyKey(headers); + const service = yield* IdentityLifecycle; + return { + context: { + correlationId: correlationFromRequest(request), + idempotencyKey, + principal: resolved.principal, + }, + service, + }; + }); return handlers .handle('createNonHumanPrincipal', ({ headers, payload, request }) => safeIdentity( request, Effect.gen(function* createNonHumanPrincipalHandler() { - const { resolved } = yield* authenticated(request); - const idempotencyKey = yield* requiredIdempotencyKey(headers); - const service = yield* lifecycle; + const { context, service } = yield* mutationContext(request, headers); const response = yield* service .createNonHumanPrincipal({ - correlationId: correlation(request), - idempotencyKey, + ...context, payload, - principal: resolved.principal, }) .pipe(Effect.catch((error) => pipe(error, identityProblem, failIdentityProblem))); return yield* decodeResponse( @@ -1324,15 +1324,11 @@ const identityGroupLive = HttpApiBuilder.group(ShellAuthenticationApi, 'identity safeIdentity( request, Effect.gen(function* changePrincipalStatusHandler() { - const { resolved } = yield* authenticated(request); - const idempotencyKey = yield* requiredIdempotencyKey(headers); - const service = yield* lifecycle; + const { context, service } = yield* mutationContext(request, headers); const result = yield* service .changePrincipalStatus({ - correlationId: correlation(request), - idempotencyKey, + ...context, payload, - principal: resolved.principal, }) .pipe(Effect.catch((error) => pipe(error, identityProblem, failIdentityProblem))); return yield* decodeResponse( @@ -1347,30 +1343,14 @@ const identityGroupLive = HttpApiBuilder.group(ShellAuthenticationApi, 'identity safeIdentity( request, Effect.gen(function* issueSelfApiKeyHandler() { - const { resolved } = yield* authenticated(request); - const idempotencyKey = yield* requiredIdempotencyKey(headers); - const service = yield* lifecycle; - const response = yield* service + const { context, service } = yield* mutationContext(request, headers); + return yield* service .issue( - withOptionalProperty( - { - correlationId: correlation(request), - idempotencyKey, - }, - payload.name !== undefined, - 'name', - payload.name, - { - principal: resolved.principal, - requestHeaders: requestHeaders(request.headers), - }, - ), + withOptionalProperty(context, payload.name !== undefined, 'name', payload.name, { + requestHeaders: requestHeaders(request.headers), + }), ) - .pipe( - Effect.tap(() => noStore), - Effect.catch((error) => pipe(error, identityProblem, failIdentityProblem)), - ); - return yield* decodeResponse(ApiKeyIssueResponseSchema, response, shellInternalProblem); + .pipe(keyIssuanceResponse); }), ), ) @@ -1378,7 +1358,7 @@ const identityGroupLive = HttpApiBuilder.group(ShellAuthenticationApi, 'identity safeIdentity( request, Effect.gen(function* listSelfApiKeysHandler() { - const { resolved } = yield* authenticated(request); + const resolved = yield* authenticated(request); const runtime = yield* ReadRuntime; const keys = yield* ApiKeyService; const resolver = yield* PrincipalResolver; @@ -1387,7 +1367,7 @@ const identityGroupLive = HttpApiBuilder.group(ShellAuthenticationApi, 'identity input: payload, principal: resolved.principal, registration: selfApiKeyBindingsRead, - transport: { correlationId: correlation(request) }, + transport: { correlationId: correlationFromRequest(request) }, }) .pipe(Effect.catch((error) => pipe(error, identityProblem, failIdentityProblem))); const items = yield* Effect.all( @@ -1429,31 +1409,23 @@ const identityGroupLive = HttpApiBuilder.group(ShellAuthenticationApi, 'identity safeIdentity( request, Effect.gen(function* issueManagedApiKeyHandler() { - const { resolved } = yield* authenticated(request); - const idempotencyKey = yield* requiredIdempotencyKey(headers); - const service = yield* lifecycle; - const response = yield* service + const { context, service } = yield* mutationContext(request, headers); + return yield* service .issue( withOptionalProperty( { - correlationId: correlation(request), - idempotencyKey, + ...context, managedPrincipalId: payload.principalId, }, payload.name !== undefined, 'name', payload.name, { - principal: resolved.principal, requestHeaders: requestHeaders(request.headers), }, ), ) - .pipe( - Effect.tap(() => noStore), - Effect.catch((error) => pipe(error, identityProblem, failIdentityProblem)), - ); - return yield* decodeResponse(ApiKeyIssueResponseSchema, response, shellInternalProblem); + .pipe(keyIssuanceResponse); }), ), ) @@ -1461,7 +1433,7 @@ const identityGroupLive = HttpApiBuilder.group(ShellAuthenticationApi, 'identity safeIdentity( request, Effect.gen(function* listManagedApiKeysHandler() { - const { resolved } = yield* authenticated(request); + const resolved = yield* authenticated(request); const runtime = yield* ReadRuntime; const keys = yield* ApiKeyService; const resolver = yield* PrincipalResolver; @@ -1470,7 +1442,7 @@ const identityGroupLive = HttpApiBuilder.group(ShellAuthenticationApi, 'identity input: payload, principal: resolved.principal, registration: managedPrincipalsRead, - transport: { correlationId: correlation(request) }, + transport: { correlationId: correlationFromRequest(request) }, }) .pipe(Effect.catch((error) => pipe(error, identityProblem, failIdentityProblem))); const items = yield* Effect.all( @@ -1530,15 +1502,11 @@ const identityGroupLive = HttpApiBuilder.group(ShellAuthenticationApi, 'identity safeIdentity( request, Effect.gen(function* setSelfKeyHandler() { - const { resolved } = yield* authenticated(request); - const idempotencyKey = yield* requiredIdempotencyKey(headers); - const service = yield* lifecycle; + const { context, service } = yield* mutationContext(request, headers); const response = yield* service .setStatus({ ...payload, - correlationId: correlation(request), - idempotencyKey, - principal: resolved.principal, + ...context, }) .pipe(Effect.catch((error) => pipe(error, identityProblem, failIdentityProblem))); return yield* decodeResponse( @@ -1553,17 +1521,13 @@ const identityGroupLive = HttpApiBuilder.group(ShellAuthenticationApi, 'identity safeIdentity( request, Effect.gen(function* setManagedKeyHandler() { - const { resolved } = yield* authenticated(request); - const idempotencyKey = yield* requiredIdempotencyKey(headers); - const service = yield* lifecycle; + const { context, service } = yield* mutationContext(request, headers); const { principalId, ...statusPayload } = payload; const response = yield* service .setStatus({ ...statusPayload, - correlationId: correlation(request), - idempotencyKey, + ...context, managedPrincipalId: principalId, - principal: resolved.principal, }) .pipe(Effect.catch((error) => pipe(error, identityProblem, failIdentityProblem))); return yield* decodeResponse( @@ -1578,32 +1542,16 @@ const identityGroupLive = HttpApiBuilder.group(ShellAuthenticationApi, 'identity safeIdentity( request, Effect.gen(function* rotateSelfKeyHandler() { - const { resolved } = yield* authenticated(request); - const idempotencyKey = yield* requiredIdempotencyKey(headers); - const service = yield* lifecycle; - const response = yield* service + const { context, service } = yield* mutationContext(request, headers); + return yield* service .rotate( - withOptionalProperty( - { - correlationId: correlation(request), - idempotencyKey, - }, - payload.name !== undefined, - 'name', - payload.name, - { - oldAuthBindingId: payload.oldAuthBindingId, - principal: resolved.principal, - reason: payload.reason, - requestHeaders: requestHeaders(request.headers), - }, - ), + withOptionalProperty(context, payload.name !== undefined, 'name', payload.name, { + oldAuthBindingId: payload.oldAuthBindingId, + reason: payload.reason, + requestHeaders: requestHeaders(request.headers), + }), ) - .pipe( - Effect.tap(() => noStore), - Effect.catch((error) => pipe(error, identityProblem, failIdentityProblem)), - ); - return yield* decodeResponse(ApiKeyIssueResponseSchema, response, shellInternalProblem); + .pipe(keyIssuanceResponse); }), ), ) @@ -1611,15 +1559,12 @@ const identityGroupLive = HttpApiBuilder.group(ShellAuthenticationApi, 'identity safeIdentity( request, Effect.gen(function* rotateManagedKeyHandler() { - const { resolved } = yield* authenticated(request); - const idempotencyKey = yield* requiredIdempotencyKey(headers); - const service = yield* lifecycle; - const response = yield* service + const { context, service } = yield* mutationContext(request, headers); + return yield* service .rotate( withOptionalProperty( { - correlationId: correlation(request), - idempotencyKey, + ...context, managedPrincipalId: payload.principalId, }, payload.name !== undefined, @@ -1628,17 +1573,12 @@ const identityGroupLive = HttpApiBuilder.group(ShellAuthenticationApi, 'identity { oldAuthBindingId: payload.oldAuthBindingId, oldManagedPrincipalId: payload.principalId, - principal: resolved.principal, reason: payload.reason, requestHeaders: requestHeaders(request.headers), }, ), ) - .pipe( - Effect.tap(() => noStore), - Effect.catch((error) => pipe(error, identityProblem, failIdentityProblem)), - ); - return yield* decodeResponse(ApiKeyIssueResponseSchema, response, shellInternalProblem); + .pipe(keyIssuanceResponse); }), ), ) @@ -1656,7 +1596,10 @@ const identityGroupLive = HttpApiBuilder.group(ShellAuthenticationApi, 'identity targetPrincipalId: payload.targetPrincipalId, }) .pipe( - Effect.provideService(SupportImpersonationCorrelationId, correlation(request)), + Effect.provideService( + SupportImpersonationCorrelationId, + correlationFromRequest(request), + ), Effect.catch((error) => pipe(error, identityProblem, failIdentityProblem)), ); yield* forwardSetCookieHeaders(result.setCookieHeaders); @@ -1680,7 +1623,10 @@ const identityGroupLive = HttpApiBuilder.group(ShellAuthenticationApi, 'identity requestHeaders: requestHeaders(request.headers), }) .pipe( - Effect.provideService(SupportImpersonationCorrelationId, correlation(request)), + Effect.provideService( + SupportImpersonationCorrelationId, + correlationFromRequest(request), + ), Effect.catch((error) => pipe(error, identityProblem, failIdentityProblem)), ); yield* forwardSetCookieHeaders(result.setCookieHeaders); diff --git a/app/apps/shell-super-app/shared/api.ts b/app/apps/shell-super-app/shared/api.ts index 57dbc3a77..0d4c9f612 100644 --- a/app/apps/shell-super-app/shared/api.ts +++ b/app/apps/shell-super-app/shared/api.ts @@ -12,11 +12,7 @@ import { GatewayContextApiGroup } from '@app/shared-contracts'; export type SafeTenantIdentity = typeof SafeTenantIdentitySchema.Type; export type SafeAuthenticatedIdentity = typeof SafeAuthenticatedIdentitySchema.Type; -export type AnonymousSession = typeof AnonymousSessionSchema.Type; -export type AuthenticatedSession = typeof AuthenticatedSessionSchema.Type; export type LegalEntityChoice = typeof LegalEntityChoiceSchema.Type; -export type SelectionRequiredSession = typeof SelectionRequiredSessionSchema.Type; -export type AccessBlockedSession = typeof AccessBlockedSessionSchema.Type; export type CurrentSession = typeof CurrentSessionSchema.Type; export type SignInPayload = typeof SignInPayloadSchema.Type; export type SignInResponse = typeof SignInResponseSchema.Type; @@ -37,8 +33,6 @@ export type ResourceRef = typeof ResourceRefSchema.Type; export type ShellSearchResult = typeof ShellSearchResultSchema.Type; export type ShellSearchPayload = typeof ShellSearchPayloadSchema.Type; export type ShellSearchResponse = typeof ShellSearchResponseSchema.Type; -export type ShellResourceDetailField = typeof ShellResourceDetailFieldSchema.Type; -export type ShellTimelineEntry = typeof ShellTimelineEntrySchema.Type; export type ShellResourceResponse = typeof ShellResourceResponseSchema.Type; export type MediaAttachmentResponse = typeof MediaAttachmentResponseSchema.Type; @@ -272,21 +266,6 @@ export type IdentityProblem = | ShellCapabilityUnavailableProblem | ShellInternalProblem; -export type ShellCompositionProblem = - | ShellAuthenticationRequiredProblem - | ShellCapabilityUnavailableProblem - | ShellInternalProblem; - -export type ShellTargetProblem = - | ShellAuthenticationRequiredProblem - | ShellCapabilityUnavailableProblem - | ShellInternalProblem - | ShellPolicyConflictProblem - | ShellPolicyUnprocessableProblem - | ShellSelectionRequiredProblem - | ShellTargetForbiddenProblem - | ShellTargetNotFoundProblem; - const safeTenantIdentityFields = { displayName: Schema.String, email: Schema.String, @@ -888,80 +867,9 @@ const authenticationEndpointPath = (endpoint: { readonly path: string }) => export const shellAuthenticationApiContract = { apiPrefix: '/shell-super-app-api', - availableLegalEntitiesPath: authenticationEndpointPath( - ShellAuthenticationApi.groups.legalEntities.endpoints.availableLegalEntities, - ), - availableTenantsPath: authenticationEndpointPath( - ShellAuthenticationApi.groups.tenants.endpoints.availableTenants, - ), - changePrincipalStatusPath: authenticationEndpointPath( - ShellAuthenticationApi.groups.identity.endpoints.changePrincipalStatus, - ), - compositionPath: authenticationEndpointPath( - ShellAuthenticationApi.groups.composition.endpoints.shellComposition, - ), - createNonHumanPrincipalPath: authenticationEndpointPath( - ShellAuthenticationApi.groups.identity.endpoints.createNonHumanPrincipal, - ), - currentSessionPath: authenticationEndpointPath( - ShellAuthenticationApi.groups.authentication.endpoints.currentSession, - ), - issueApiKeyGatewayContextPath: authenticationEndpointPath( - ShellAuthenticationApi.groups.gatewayContext.endpoints.issueApiKeyGatewayContext, - ), - issueGatewayContextPath: authenticationEndpointPath( - ShellAuthenticationApi.groups.gatewayContext.endpoints.issueGatewayContext, - ), - issueManagedApiKeyPath: authenticationEndpointPath( - ShellAuthenticationApi.groups.identity.endpoints.issueManagedApiKey, - ), - issueSelfApiKeyPath: authenticationEndpointPath( - ShellAuthenticationApi.groups.identity.endpoints.issueSelfApiKey, - ), - listManagedApiKeysPath: authenticationEndpointPath( - ShellAuthenticationApi.groups.identity.endpoints.listManagedApiKeys, - ), - listSelfApiKeysPath: authenticationEndpointPath( - ShellAuthenticationApi.groups.identity.endpoints.listSelfApiKeys, - ), - mediaAttachmentPath: authenticationEndpointPath( - ShellAuthenticationApi.groups.resources.endpoints.attachMedia, - ), - ownerId: 'shell-super-app', - resolveModuleTargetPath: authenticationEndpointPath( - ShellAuthenticationApi.groups.composition.endpoints.resolveModuleTarget, - ), - resourceDetailPath: authenticationEndpointPath( - ShellAuthenticationApi.groups.resources.endpoints.resourceDetail, - ), - rotateManagedApiKeyPath: authenticationEndpointPath( - ShellAuthenticationApi.groups.identity.endpoints.rotateManagedApiKey, - ), - rotateSelfApiKeyPath: authenticationEndpointPath( - ShellAuthenticationApi.groups.identity.endpoints.rotateSelfApiKey, - ), - searchPath: authenticationEndpointPath(ShellAuthenticationApi.groups.resources.endpoints.search), - setManagedApiKeyStatusPath: authenticationEndpointPath( - ShellAuthenticationApi.groups.identity.endpoints.setManagedApiKeyStatus, - ), - setSelfApiKeyStatusPath: authenticationEndpointPath( - ShellAuthenticationApi.groups.identity.endpoints.setSelfApiKeyStatus, - ), signInPath: authenticationEndpointPath( ShellAuthenticationApi.groups.authentication.endpoints.signIn, ), - signOutPath: authenticationEndpointPath( - ShellAuthenticationApi.groups.authentication.endpoints.signOut, - ), - startSupportImpersonationPath: authenticationEndpointPath( - ShellAuthenticationApi.groups.identity.endpoints.startSupportImpersonation, - ), - stopSupportImpersonationPath: authenticationEndpointPath( - ShellAuthenticationApi.groups.identity.endpoints.stopSupportImpersonation, - ), - switchLegalEntityPath: authenticationEndpointPath( - ShellAuthenticationApi.groups.legalEntities.endpoints.switchLegalEntity, - ), switchTenantPath: authenticationEndpointPath( ShellAuthenticationApi.groups.tenants.endpoints.switchTenant, ), diff --git a/app/apps/shell-super-app/shared/ultramodern-build.ts b/app/apps/shell-super-app/shared/ultramodern-build.ts index c3bada928..c4e3a9add 100644 --- a/app/apps/shell-super-app/shared/ultramodern-build.ts +++ b/app/apps/shell-super-app/shared/ultramodern-build.ts @@ -60,9 +60,4 @@ const ultramodernBuildArtifact = withUltramodernBuildIdentity( ultramodernSourceRevision, ); -export { ultramodernBuildArtifact }; - export const ultramodernDeliveryUnit = ultramodernBuildArtifact.deliveryUnit; -export const ultramodernVerticalIdentity = ultramodernDeliveryUnit; -export const ultramodernUiMarker = ultramodernBuildArtifact.surfaces.ui; -export const ultramodernApiMarker = ultramodernBuildArtifact.surfaces.api; diff --git a/app/apps/shell-super-app/shared/vertical-showcase.tsx b/app/apps/shell-super-app/shared/vertical-showcase.tsx deleted file mode 100644 index 6c5fe2801..000000000 --- a/app/apps/shell-super-app/shared/vertical-showcase.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useModernI18n } from '@modern-js/plugin-i18n/runtime'; - -const widgetCount = Number('0'); - -export const VerticalShowcase = () => { - const { t } = useModernI18n(); - - if (widgetCount === 0) { - return ( -
    -

    - {t('shell.hero.empty')} -

    -
    - ); - } - - return ( -
    -
    -
    - ); -}; diff --git a/app/apps/shell-super-app/src/routes/shell-frame.tsx b/app/apps/shell-super-app/src/routes/shell-frame.tsx index bd5d31818..a33621262 100644 --- a/app/apps/shell-super-app/src/routes/shell-frame.tsx +++ b/app/apps/shell-super-app/src/routes/shell-frame.tsx @@ -5,6 +5,7 @@ import { StatusText } from '@techsio/ui-kit/atoms/status-text'; import { Menu } from '@techsio/ui-kit/molecules/menu'; import type { MenuItem } from '@techsio/ui-kit/molecules/menu'; import { Select } from '@techsio/ui-kit/molecules/select'; +import type { SelectItem } from '@techsio/ui-kit/molecules/select'; import { SearchForm } from '@techsio/ui-kit/molecules/search-form'; import { Header } from '@techsio/ui-kit/organisms/header'; import { useEffect, useState } from 'react'; @@ -77,6 +78,20 @@ interface DashboardLegalEntitySelectorProps { readonly onLegalEntityChange: (legalEntityId: string) => void; } +interface DashboardSelectorProps { + readonly ariaLabel?: string; + readonly currentValue: string | undefined; + readonly disabled: boolean; + readonly items: SelectItem[]; + readonly label: string; + readonly name: string; + readonly onChange: (value: string) => void; + readonly placeholder: string; + readonly status: 'default' | 'error' | 'warning'; + readonly statusId: string; + readonly statusText: string | null; +} + interface DashboardSearchProps { readonly onSearch: (query: string) => void; readonly onValueChange: (value: string) => void; @@ -132,15 +147,58 @@ const selectorStatusText = ( return unavailable ? messages.unavailable : null; }; -const tenantSelectorDisabled = ( - tenantState: AuthenticatedDashboardLayoutProps['tenantState'], - tenantSwitchPending: boolean, - tenantItems: readonly { readonly value: string }[], - currentTenantId: string, -): boolean => - tenantState === 'unavailable' || - tenantSwitchPending || - !tenantItems.some((item) => item.value !== currentTenantId); +const DashboardSelector = ({ + ariaLabel, + currentValue, + disabled, + items, + label, + name, + onChange, + placeholder, + status, + statusId, + statusText, +}: DashboardSelectorProps) => ( + +); const DashboardTenantSelector = ({ currentTenantId, @@ -157,68 +215,31 @@ const DashboardTenantSelector = ({ value: tenantId, })); const tenantUnavailable = tenantState === 'unavailable'; - const tenantStatus = selectorStatus(tenantSwitchFailed, tenantUnavailable); - const tenantStatusText = selectorStatusText( - tenantSwitchPending, - tenantSwitchFailed, - tenantUnavailable, - { - failed: t('shell.dashboard.tenant.failed'), - pending: t('shell.dashboard.tenant.pending'), - unavailable: t('shell.dashboard.tenant.unavailable'), - }, - ); - const tenantSelectDisabled = tenantSelectorDisabled( - tenantState, - tenantSwitchPending, - tenantItems, - currentTenantId, - ); + const accessibleLabel = t('shell.dashboard.tenant.accessibleLabel'); + const unavailableText = t('shell.dashboard.tenant.unavailable'); return ( - + onChange={onTenantChange} + placeholder={unavailableText} + status={selectorStatus(tenantSwitchFailed, tenantUnavailable)} + statusId="tenant-switch-status" + statusText={selectorStatusText(tenantSwitchPending, tenantSwitchFailed, tenantUnavailable, { + failed: t('shell.dashboard.tenant.failed'), + pending: t('shell.dashboard.tenant.pending'), + unavailable: unavailableText, + })} + /> ); }; @@ -237,67 +258,29 @@ const DashboardLegalEntitySelector = ({ value: legalEntityId, })); const legalEntityUnavailable = legalEntityState === 'unavailable'; - const legalEntityStatus = selectorStatus(legalEntitySwitchFailed, legalEntityUnavailable); - const legalEntityStatusText = selectorStatusText( - legalEntitySwitchPending, - legalEntitySwitchFailed, - legalEntityUnavailable, - { - failed: t('shell.dashboard.legalEntity.failed'), - pending: t('shell.dashboard.legalEntity.pending'), - unavailable: t('shell.dashboard.legalEntity.unavailable'), - }, - ); return ( - + /> ); }; diff --git a/app/apps/shell-super-app/src/routes/use-shell-controls.ts b/app/apps/shell-super-app/src/routes/use-shell-controls.ts index 03195f038..3e397646e 100644 --- a/app/apps/shell-super-app/src/routes/use-shell-controls.ts +++ b/app/apps/shell-super-app/src/routes/use-shell-controls.ts @@ -9,7 +9,7 @@ import { runBrowserEffect } from '../runtime/browser-effect-runtime.ts'; import type { AuthenticatedHomePageModel } from './[lang]/page.data.ts'; const SwitchFailureStateSchema = Schema.Literals(['authentication-required', 'failed']); -export type SwitchFailureState = typeof SwitchFailureStateSchema.Type; +type SwitchFailureState = typeof SwitchFailureStateSchema.Type; const tenantSwitchFailureState = (error: SwitchTenantClientError): SwitchFailureState => Match.value(error).pipe( @@ -80,41 +80,56 @@ export const useShellControls = (model: AuthenticatedHomePageModel | undefined) ); }; - const handleLegalEntityChange = (legalEntityId: string) => { - if ( - model === undefined || - legalEntitySwitchPending || - legalEntityId === model.selectedLegalEntityId - ) { - return; - } - setLegalEntitySwitchPending(true); - setLegalEntitySwitchFailed(false); + const runSwitch = ( + switching: Effect.Effect, + switchFailureState: (error: NoInfer) => SwitchFailureState, + setPending: (pending: boolean) => void, + setFailed: (failed: boolean) => void, + ) => { + setPending(true); + setFailed(false); void runBrowserEffect( - Schema.decodeUnknownEffect(SwitchLegalEntityPayloadSchema)({ legalEntityId }).pipe( - Effect.flatMap((payload) => switchLegalEntity(payload, { locale: language })), + switching.pipe( Effect.matchEffect({ - onFailure: (error) => Effect.succeed(legalEntitySwitchFailureState(error)), + onFailure: (error) => Effect.succeed(switchFailureState(error)), onSuccess: () => Effect.succeed('switched' as const), }), Effect.flatMap((outcome) => outcome === 'authentication-required' || outcome === 'switched' ? reload() - : Effect.sync(() => setLegalEntitySwitchFailed(true)), + : Effect.sync(() => setFailed(true)), ), Effect.matchEffect({ onFailure: (error) => Effect.sync(() => { void error; - setLegalEntitySwitchFailed(true); + setFailed(true); }), onSuccess: Effect.succeed, }), - Effect.ensuring(Effect.sync(() => setLegalEntitySwitchPending(false))), + Effect.ensuring(Effect.sync(() => setPending(false))), ), ); }; + const handleLegalEntityChange = (legalEntityId: string) => { + if ( + model === undefined || + legalEntitySwitchPending || + legalEntityId === model.selectedLegalEntityId + ) { + return; + } + runSwitch( + Schema.decodeUnknownEffect(SwitchLegalEntityPayloadSchema)({ legalEntityId }).pipe( + Effect.flatMap((payload) => switchLegalEntity(payload, { locale: language })), + ), + legalEntitySwitchFailureState, + setLegalEntitySwitchPending, + setLegalEntitySwitchFailed, + ); + }; + const handleTenantChange = (tenantId: string) => { if ( model === undefined || @@ -124,30 +139,13 @@ export const useShellControls = (model: AuthenticatedHomePageModel | undefined) ) { return; } - setTenantSwitchPending(true); - setTenantSwitchFailed(false); - void runBrowserEffect( + runSwitch( Schema.decodeUnknownEffect(SwitchTenantPayloadSchema)({ tenantId }).pipe( Effect.flatMap((payload) => switchTenant(payload, { locale: language })), - Effect.matchEffect({ - onFailure: (error) => Effect.succeed(tenantSwitchFailureState(error)), - onSuccess: () => Effect.succeed('switched' as const), - }), - Effect.flatMap((outcome) => - outcome === 'authentication-required' || outcome === 'switched' - ? reload() - : Effect.sync(() => setTenantSwitchFailed(true)), - ), - Effect.matchEffect({ - onFailure: (error) => - Effect.sync(() => { - void error; - setTenantSwitchFailed(true); - }), - onSuccess: Effect.succeed, - }), - Effect.ensuring(Effect.sync(() => setTenantSwitchPending(false))), ), + tenantSwitchFailureState, + setTenantSwitchPending, + setTenantSwitchFailed, ); }; diff --git a/app/apps/shell-super-app/src/routes/vertical-components.tsx b/app/apps/shell-super-app/src/routes/vertical-components.tsx deleted file mode 100644 index 52c1dd43a..000000000 --- a/app/apps/shell-super-app/src/routes/vertical-components.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Generated composition entry retained for workspace tooling. -export { VerticalShowcase } from '../../shared/vertical-showcase'; diff --git a/app/apps/shell-super-app/src/routes/vertical-components.worker.tsx b/app/apps/shell-super-app/src/routes/vertical-components.worker.tsx deleted file mode 100644 index 52c1dd43a..000000000 --- a/app/apps/shell-super-app/src/routes/vertical-components.worker.tsx +++ /dev/null @@ -1,2 +0,0 @@ -// Generated composition entry retained for workspace tooling. -export { VerticalShowcase } from '../../shared/vertical-showcase'; diff --git a/app/apps/shell-super-app/src/ultramodern-build.ts b/app/apps/shell-super-app/src/ultramodern-build.ts deleted file mode 100644 index eefaf129e..000000000 --- a/app/apps/shell-super-app/src/ultramodern-build.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { - ultramodernBuildArtifact, - ultramodernApiMarker, - ultramodernDeliveryUnit, - ultramodernUiMarker, - ultramodernVerticalIdentity, -} from '../shared/ultramodern-build'; diff --git a/app/apps/shell-super-app/tests/e2e/auth-fixture.ts b/app/apps/shell-super-app/tests/e2e/auth-fixture.ts index b8fcb69d2..692c5c9a6 100644 --- a/app/apps/shell-super-app/tests/e2e/auth-fixture.ts +++ b/app/apps/shell-super-app/tests/e2e/auth-fixture.ts @@ -111,32 +111,17 @@ export const createAuthenticationFixture = async () => { .where(eq(principalAuthBindings.providerSubjectId, existingUser.id)), ), ); + const tenantIds = Object.values(e2eTenants).map(({ tenantId }) => tenantId); + const principalIds = Object.values(e2eTenants).map(({ principalId }) => principalId); await coreDatabase .delete(principalAuthBindings) - .where(eq(principalAuthBindings.principalId, e2eTenants.first.principalId)); - await coreDatabase - .delete(principalAuthBindings) - .where(eq(principalAuthBindings.principalId, e2eTenants.second.principalId)); - await coreDatabase - .delete(tenantModuleStates) - .where(eq(tenantModuleStates.tenantId, e2eTenants.first.tenantId)); + .where(inArray(principalAuthBindings.principalId, principalIds)); await coreDatabase .delete(tenantModuleStates) - .where(eq(tenantModuleStates.tenantId, e2eTenants.second.tenantId)); - await coreDatabase - .delete(legalEntities) - .where(eq(legalEntities.tenantId, e2eTenants.first.tenantId)); - await coreDatabase - .delete(legalEntities) - .where(eq(legalEntities.tenantId, e2eTenants.second.tenantId)); - await coreDatabase - .delete(principals) - .where(eq(principals.principalId, e2eTenants.first.principalId)); - await coreDatabase - .delete(principals) - .where(eq(principals.principalId, e2eTenants.second.principalId)); - await coreDatabase.delete(tenants).where(eq(tenants.tenantId, e2eTenants.first.tenantId)); - await coreDatabase.delete(tenants).where(eq(tenants.tenantId, e2eTenants.second.tenantId)); + .where(inArray(tenantModuleStates.tenantId, tenantIds)); + await coreDatabase.delete(legalEntities).where(inArray(legalEntities.tenantId, tenantIds)); + await coreDatabase.delete(principals).where(inArray(principals.principalId, principalIds)); + await coreDatabase.delete(tenants).where(inArray(tenants.tenantId, tenantIds)); }; await cleanup(); diff --git a/app/apps/shell-super-app/tests/e2e/login.spec.ts b/app/apps/shell-super-app/tests/e2e/login.spec.ts index 81f191650..372580dd2 100644 --- a/app/apps/shell-super-app/tests/e2e/login.spec.ts +++ b/app/apps/shell-super-app/tests/e2e/login.spec.ts @@ -37,6 +37,24 @@ const gotoHydratedLogin = async (page: Page, language: 'cs' | 'en') => { }); }; +const login = async (page: Page, language: 'cs' | 'en') => { + await gotoHydratedLogin(page, language); + const form = hydratedLoginForm(page); + const labels = + language === 'en' + ? { login: /^Login\s*\*$/u, password: /^Password/u, submit: 'Login', url: /\/en\/?$/u } + : { + login: /^Přihlašovací jméno\s*\*$/u, + password: /^Heslo/u, + submit: 'Přihlásit se', + url: /\/cs\/?$/u, + }; + await form.getByRole('textbox', { name: labels.login }).fill(e2eCredentials.email); + await form.getByLabel(labels.password).fill(e2eCredentials.password); + await form.getByRole('button', { name: labels.submit }).click(); + await expect(page).toHaveURL(labels.url); +}; + let cleanupFixture: (() => Promise) | undefined; test.beforeAll( @@ -48,34 +66,22 @@ test.beforeAll( test.afterAll(async () => await cleanupFixture?.()); -test('renders the exact anonymous English and Czech home states', async ({ page }) => - await page - .goto('/en/') - .then( - async () => - await Promise.all([ - expect(page.getByRole('link', { name: 'Login' })).toBeVisible(), - expect(page.getByRole('link')).toHaveCount(1), - expect(page.getByRole('button')).toHaveCount(0), - expect(page.getByRole('checkbox')).toHaveCount(0), - expect(page.locator('header[aria-label]')).toHaveCount(0), - expect(page.getByRole('complementary')).toHaveCount(0), - expect(page.getByRole('region')).toHaveCount(0), - ]), - ) - .then(async () => await page.goto('/cs/')) - .then( - async () => - await Promise.all([ - expect(page.getByRole('link', { name: 'Přihlásit se' })).toBeVisible(), - expect(page.getByRole('link')).toHaveCount(1), - expect(page.getByRole('button')).toHaveCount(0), - expect(page.getByRole('checkbox')).toHaveCount(0), - expect(page.locator('header[aria-label]')).toHaveCount(0), - expect(page.getByRole('complementary')).toHaveCount(0), - expect(page.getByRole('region')).toHaveCount(0), - ]), - )); +test('renders the exact anonymous English and Czech home states', async ({ page }) => { + const expectAnonymousHome = async (language: string, label: string) => { + await page.goto(`/${language}/`); + await Promise.all([ + expect(page.getByRole('link', { name: label })).toBeVisible(), + expect(page.getByRole('link')).toHaveCount(1), + expect(page.getByRole('button')).toHaveCount(0), + expect(page.getByRole('checkbox')).toHaveCount(0), + expect(page.locator('header[aria-label]')).toHaveCount(0), + expect(page.getByRole('complementary')).toHaveCount(0), + expect(page.getByRole('region')).toHaveCount(0), + ]); + }; + await expectAnonymousHome('en', 'Login'); + await expectAnonymousHome('cs', 'Přihlásit se'); +}); test('keeps English and Czech login pages free of authenticated dashboard chrome', async ({ page, @@ -169,43 +175,49 @@ test('loads localized English and Czech Contacts pages only after login', async await page.goto('/cs/contacts'); await expect(page.getByRole('heading', { name: 'Contacts' })).toHaveCount(0); - await gotoHydratedLogin(page, 'cs'); - const form = hydratedLoginForm(page); - await form - .getByRole('textbox', { name: /^Přihlašovací jméno\s*\*$/u }) - .fill(e2eCredentials.email); - await form.getByLabel(/^Heslo/u).fill(e2eCredentials.password); - await form.getByRole('button', { name: 'Přihlásit se' }).click(); - await expect(page).toHaveURL(/\/cs\/?$/u); + await login(page, 'cs'); await expect(page.getByText('Nasazení modulu je dočasně nedostupné.')).toHaveCount(0); const contactsLink = page.locator('a[href="/cs/contacts"]'); await expect(contactsLink).toHaveAttribute('href', '/cs/contacts'); await contactsLink.click(); - await expect(page).toHaveURL(/\/cs\/contacts\/?$/u); - await expect(page.getByRole('heading', { name: 'Kontakty' })).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Modul' })).toHaveCount(0); - await expect( - page.getByText( + const expectContacts = async (content: { + description: string; + empty: string; + heading: string; + module: string; + placeholder: string; + url: RegExp; + }) => { + await expect(page).toHaveURL(content.url); + await expect(page.getByRole('heading', { name: content.heading })).toBeVisible(); + await expect(page.getByRole('heading', { name: content.module })).toHaveCount(0); + await expect(page.getByText(content.description)).toBeVisible(); + await expect(page.getByText(content.placeholder)).toHaveCount(0); + await expect(page.getByText(content.empty)).toHaveCount(0); + }; + await expectContacts({ + description: 'Party Registry uchovává kanonické strany, protistrany a jejich profily zapojení v jednom modulu.', - ), - ).toBeVisible(); - await expect(page.getByText('Tato stránka je připravena k implementaci.')).toHaveCount(0); - await expect(page.getByText('Zatím zde není žádný obsah.')).toHaveCount(0); + empty: 'Zatím zde není žádný obsah.', + heading: 'Kontakty', + module: 'Modul', + placeholder: 'Tato stránka je připravena k implementaci.', + url: /\/cs\/contacts\/?$/u, + }); await expect(page.getByRole('complementary', { name: 'Postranní panel přehledu' })).toBeVisible(); await page.goto('/en/contacts'); - await expect(page).toHaveURL(/\/en\/contacts\/?$/u); - await expect(page.getByRole('heading', { name: 'Contacts' })).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Module' })).toHaveCount(0); - await expect( - page.getByText( + await expectContacts({ + description: 'Party Registry keeps canonical Parties, Counterparties, and their engagement profiles in one module.', - ), - ).toBeVisible(); - await expect(page.getByText('This page is ready for implementation.')).toHaveCount(0); - await expect(page.getByText('No content has been added yet.')).toHaveCount(0); + empty: 'No content has been added yet.', + heading: 'Contacts', + module: 'Module', + placeholder: 'This page is ready for implementation.', + url: /\/en\/contacts\/?$/u, + }); await expect(page.getByText('The module is temporarily unavailable. Try again.')).toHaveCount(0); const dashboardSidebar = page.getByRole('complementary', { name: 'Dashboard sidebar' }); await expect(dashboardSidebar).toBeVisible(); @@ -225,12 +237,7 @@ test('loads localized English and Czech Contacts pages only after login', async test('keeps authenticated Shell chrome on search and guarded direct-target routes', async ({ page, }) => { - await gotoHydratedLogin(page, 'en'); - const form = hydratedLoginForm(page); - await form.getByRole('textbox', { name: /^Login\s*\*$/u }).fill(e2eCredentials.email); - await form.getByLabel(/^Password/u).fill(e2eCredentials.password); - await form.getByRole('button', { name: 'Login' }).click(); - await expect(page).toHaveURL(/\/en\/?$/u); + await login(page, 'en'); const expectPersistentShell = async (path: string, status: string) => { await page.goto(path); @@ -295,12 +302,7 @@ test('persists an English session, logs out, clears the cookie, and stays anonym test('switches tenant by pointer, fully reloads, and persists the selected context', async ({ page, }) => { - await gotoHydratedLogin(page, 'en'); - const form = hydratedLoginForm(page); - await form.getByRole('textbox', { name: /^Login\s*\*$/u }).fill(e2eCredentials.email); - await form.getByLabel(/^Password/u).fill(e2eCredentials.password); - await form.getByRole('button', { name: 'Login' }).click(); - await expect(page).toHaveURL(/\/en\/?$/u); + await login(page, 'en'); const tenant = page.getByRole('combobox', { name: 'Current tenant' }); await expect(tenant).toContainText(e2eTenants.first.name); @@ -335,14 +337,7 @@ test('retains Czech tenant context after one failed switch and supports keyboard page, }) => { let failSwitch = true; - await gotoHydratedLogin(page, 'cs'); - const form = hydratedLoginForm(page); - await form - .getByRole('textbox', { name: /^Přihlašovací jméno\s*\*$/u }) - .fill(e2eCredentials.email); - await form.getByLabel(/^Heslo/u).fill(e2eCredentials.password); - await form.getByRole('button', { name: 'Přihlásit se' }).click(); - await expect(page).toHaveURL(/\/cs\/?$/u); + await login(page, 'cs'); await page.route(`**${shellAuthenticationApiContract.switchTenantPath}`, async (route) => { if (failSwitch) { failSwitch = false; @@ -464,12 +459,7 @@ test('keeps the authenticated dashboard reachable without horizontal overflow at page, }) => { await page.setViewportSize({ height: 667, width: 375 }); - await gotoHydratedLogin(page, 'en'); - const form = hydratedLoginForm(page); - await form.getByRole('textbox', { name: /^Login\s*\*$/u }).fill(e2eCredentials.email); - await form.getByLabel(/^Password/u).fill(e2eCredentials.password); - await form.getByRole('button', { name: 'Login' }).click(); - await expect(page).toHaveURL(/\/en\/?$/u); + await login(page, 'en'); await page.route( `**${shellAuthenticationApiContract.switchTenantPath}`, async (route) => await route.abort('failed'), diff --git a/app/apps/shell-super-app/tests/integration/auth-runtime.test.ts b/app/apps/shell-super-app/tests/integration/auth-runtime.test.ts index fea9924f7..f837a56bc 100644 --- a/app/apps/shell-super-app/tests/integration/auth-runtime.test.ts +++ b/app/apps/shell-super-app/tests/integration/auth-runtime.test.ts @@ -1,4 +1,5 @@ import { makeTestDatabaseFromPool } from '../../../../packages/core-runtime/tests/support/database.ts'; +import { purgeFixtureRows } from '../../../../packages/core-runtime/tests/support/fixture-cleanup.ts'; import { Scope as NativeScope, Exit as NativeExit, Effect, Layer, Predicate, Schema } from 'effect'; import { runEffectTestSync as runNativeSync, @@ -105,6 +106,39 @@ const cookieHeader = (setCookieHeaders: readonly string[]) => const headerValue = (headers: Headers, name: string): string => headers.get(name) ?? ''; const optionalText = (value: string | undefined): string => value ?? ''; +/** + * Once a principal binding is revoked or removed, the very next session resolution must fail + * closed with the identity forbidden error rather than degrade to an anonymous or stale session. + */ +const assertSessionForbidden = async ( + resolution: Effect.Effect, +): Promise => { + const failure = await runEffectTestPromise(Effect.flip(resolution)); + assert.ok(Schema.is(Schema.TaggedStruct('OntosIdentityForbiddenError', {}))(failure)); +}; + +/** + * Verifies an issued gateway assertion against the issuer key pair and decodes its trusted + * principal so each gateway test keeps its own expectations instead of the jose plumbing. + */ +const verifiedGatewayAssertion = async ( + assertionResponse: Response, + publicKey: Parameters[1], +): Promise<{ principal: typeof TrustedPrincipalContextSchema.Type; token: string }> => { + const assertion = Schema.decodeUnknownSync(TokenResponseSchema)(await assertionResponse.json()); + const verified = await jwtVerify(assertion.token, publicKey, { + algorithms: ['EdDSA'], + audience: 'inventory-stock', + currentDate: new Date(1_700_000_001_000), + issuer: 'https://shell.example.test', + }); + return { + principal: Schema.decodeUnknownSync(TrustedPrincipalContextSchema)( + verified.payload['principal'], + ), + token: assertion.token, + }; +}; const assertOptionalField = ( value: Value | null | undefined, key: Key, @@ -254,61 +288,43 @@ test('creates, resolves, persists, revokes, and signs out a Better Auth session' const generatedFixtureRoot = await mkdtemp(path.join(tmpdir(), 'ontos-auth-runtime-')); const cleanup = async () => { - await runEffectTestPromise( + await purgeFixtureRows([ coreDatabase.delete(dataAccessEvents).where(eq(dataAccessEvents.tenantId, tenantId)), - ); - await runEffectTestPromise( coreDatabase.delete(auditEvents).where(eq(auditEvents.tenantId, tenantId)), - ); - await runEffectTestPromise( coreDatabase.delete(actionInvocations).where(eq(actionInvocations.tenantId, tenantId)), - ); + ]); const existingUsers = await runEffectTestPromise( authDatabase.select({ id: user.id }).from(user).where(eq(user.email, email)), ); await Promise.all( existingUsers.map(async (existingUser) => { - await runEffectTestPromise( + await purgeFixtureRows([ coreDatabase .delete(principalAuthBindings) .where(eq(principalAuthBindings.providerSubjectId, existingUser.id)), - ); - await runEffectTestPromise( authDatabase.delete(session).where(eq(session.userId, existingUser.id)), - ); - await runEffectTestPromise( authDatabase.delete(account).where(eq(account.userId, existingUser.id)), - ); - await runEffectTestPromise(authDatabase.delete(user).where(eq(user.id, existingUser.id))); + authDatabase.delete(user).where(eq(user.id, existingUser.id)), + ]); }), ); - await runEffectTestPromise( + await purgeFixtureRows([ coreDatabase .delete(principalAuthBindings) .where(eq(principalAuthBindings.principalId, principalId)), - ); - await runEffectTestPromise( coreDatabase.delete(tenantModuleStates).where(eq(tenantModuleStates.tenantId, tenantId)), - ); - await runEffectTestPromise( coreDatabase .delete(tenantModuleStates) .where(eq(tenantModuleStates.tenantId, foreignTenantId)), - ); - await runEffectTestPromise( coreDatabase.delete(principals).where(eq(principals.principalId, principalId)), - ); - await runEffectTestPromise( coreDatabase .delete(legalEntities) .where(eq(legalEntities.legalEntityId, fixtureLegalEntityId)), - ); - await runEffectTestPromise(coreDatabase.delete(tenants).where(eq(tenants.tenantId, tenantId))); - await runEffectTestPromise( + coreDatabase.delete(tenants).where(eq(tenants.tenantId, tenantId)), coreDatabase.delete(tenants).where(eq(tenants.tenantId, foreignTenantId)), - ); + ]); }; try { @@ -883,15 +899,9 @@ test('creates, resolves, persists, revokes, and signs out a Better Auth session' }), ); assert.equal(assertionResponse.status, 200, await assertionResponse.clone().text()); - const assertion = Schema.decodeUnknownSync(TokenResponseSchema)(await assertionResponse.json()); - const verifiedAssertion = await jwtVerify(assertion.token, pair.publicKey, { - algorithms: ['EdDSA'], - audience: 'inventory-stock', - currentDate: new Date(1_700_000_001_000), - issuer: 'https://shell.example.test', - }); - const verifiedPrincipal = Schema.decodeUnknownSync(TrustedPrincipalContextSchema)( - verifiedAssertion.payload['principal'], + const { principal: verifiedPrincipal, token: assertionToken } = await verifiedGatewayAssertion( + assertionResponse, + pair.publicKey, ); assert.equal(verifiedPrincipal.authBindingId, fixtureAuthBindingId); assert.match(optionalText(verifiedPrincipal.authContextRef), /^better-auth-session:/u); @@ -937,7 +947,7 @@ test('creates, resolves, persists, revokes, and signs out a Better Auth session' assert.ok(Predicate.isFunction(verifyActionPrincipal)); const generatedPrincipal = Schema.decodeUnknownSync(TrustedPrincipalContextSchema)( await runEffectTestPromise( - verifyActionPrincipal(`Bearer ${assertion.token}`, { + verifyActionPrincipal(`Bearer ${assertionToken}`, { currentTimeSeconds: Effect.succeed(1_700_000_001), environment: { ONTOS_GATEWAY_ISSUER: 'https://shell.example.test', @@ -1024,14 +1034,11 @@ test('creates, resolves, persists, revokes, and signs out a Better Auth session' .set({ revokedAt: new Date('2026-09-01T00:00:00.000Z'), status: 'revoked' }) .where(eq(principalAuthBindings.providerSubjectId, betterAuthUserId)), ); - const revoked = await runEffectTestPromise( - Effect.flip( - authentication - .currentSession(authenticatedHeaders) - .pipe(Effect.provide(authenticationContextLayer)), - ), + await assertSessionForbidden( + authentication + .currentSession(authenticatedHeaders) + .pipe(Effect.provide(authenticationContextLayer)), ); - assert.ok(Schema.is(Schema.TaggedStruct('OntosIdentityForbiddenError', {}))(revoked)); const forbiddenModulesResponse = await unavailableHandler.handler( new Request(`${configuration.baseUrl}/shell/composition`, { headers: authenticatedHeaders, @@ -1158,57 +1165,40 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session ); const handlers: { readonly dispose: () => Promise }[] = []; - const cleanup = async () => { - await runEffectTestPromise( + const fixtureTenants = [firstTenantId, secondTenantId]; + // Ordered child-before-parent so every delete respects the owned foreign keys. + const cleanup = async (): Promise => { + await purgeFixtureRows([ coreDatabase .delete(dataAccessEvents) - .where(inArray(dataAccessEvents.tenantId, [firstTenantId, secondTenantId])), - ); + .where(inArray(dataAccessEvents.tenantId, fixtureTenants)), + ]); const existingUsers = await runEffectTestPromise( authDatabase.select({ id: user.id }).from(user).where(eq(user.email, multiEmail)), ); const existingUserIds = existingUsers.map(({ id }) => id); if (existingUserIds.length > 0) { - await runEffectTestPromise( + await purgeFixtureRows([ coreDatabase .delete(principalAuthBindings) .where(inArray(principalAuthBindings.providerSubjectId, existingUserIds)), - ); - await runEffectTestPromise( authDatabase.delete(session).where(inArray(session.userId, existingUserIds)), - ); - await runEffectTestPromise( authDatabase.delete(account).where(inArray(account.userId, existingUserIds)), - ); - await runEffectTestPromise( authDatabase.delete(user).where(inArray(user.id, existingUserIds)), - ); + ]); } - await runEffectTestPromise( - coreDatabase.delete(tenantModuleStates).where(eq(tenantModuleStates.tenantId, firstTenantId)), - ); - await runEffectTestPromise( + await purgeFixtureRows([ coreDatabase .delete(tenantModuleStates) - .where(eq(tenantModuleStates.tenantId, secondTenantId)), - ); - await runEffectTestPromise( - coreDatabase.delete(principals).where(eq(principals.principalId, firstPrincipalId)), - ); - await runEffectTestPromise( - coreDatabase.delete(principals).where(eq(principals.principalId, secondPrincipalId)), - ); - await runEffectTestPromise( + .where(inArray(tenantModuleStates.tenantId, fixtureTenants)), + coreDatabase + .delete(principals) + .where(inArray(principals.principalId, [firstPrincipalId, secondPrincipalId])), coreDatabase .delete(legalEntities) .where(inArray(legalEntities.legalEntityId, [firstLegalEntityId, secondLegalEntityId])), - ); - await runEffectTestPromise( - coreDatabase.delete(tenants).where(eq(tenants.tenantId, firstTenantId)), - ); - await runEffectTestPromise( - coreDatabase.delete(tenants).where(eq(tenants.tenantId, secondTenantId)), - ); + coreDatabase.delete(tenants).where(inArray(tenants.tenantId, fixtureTenants)), + ]); }; try { @@ -1313,12 +1303,29 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session cookie: authenticatedCookie, origin: configuration.baseUrl, }); - const initialSessions = await runEffectTestPromise( - authDatabase - .select({ activeTenantId: session.activeTenantId }) - .from(session) - .where(eq(session.userId, betterAuthUserId)), - ); + // Every tenant-switch case posts the same authenticated envelope; only the target tenant and + // the optional correlation header vary between the authorization, resolver, and persistence + // failures asserted below. + const tenantSwitchRequest = (target: string, extraHeaders: Record = {}) => + new Request(`${configuration.baseUrl}/auth/tenant/switch`, { + body: JSON.stringify({ tenantId: target }), + headers: new Headers({ + 'content-type': 'application/json', + cookie: authenticatedCookie, + origin: configuration.baseUrl, + ...extraHeaders, + }), + method: 'POST', + }); + const readActiveTenantIds = async () => + await runEffectTestPromise( + authDatabase + .select({ activeTenantId: session.activeTenantId }) + .from(session) + .where(eq(session.userId, betterAuthUserId)), + ); + + const initialSessions = await readActiveTenantIds(); assertOptionalField(initialSessions[0], 'activeTenantId', firstTenantId); const pair = await generateKeyPair('EdDSA', { crv: 'Ed25519', extractable: true }); @@ -1382,23 +1389,10 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session }); const forbiddenResponse = await runtime.handler( - new Request(`${configuration.baseUrl}/auth/tenant/switch`, { - body: JSON.stringify({ tenantId: '31000000-0000-4000-8000-000000000099' }), - headers: new Headers({ - 'content-type': 'application/json', - cookie: authenticatedCookie, - origin: configuration.baseUrl, - }), - method: 'POST', - }), + tenantSwitchRequest('31000000-0000-4000-8000-000000000099'), ); assert.equal(forbiddenResponse.status, 403); - const sessionsAfterForbiddenSwitch = await runEffectTestPromise( - authDatabase - .select({ activeTenantId: session.activeTenantId }) - .from(session) - .where(eq(session.userId, betterAuthUserId)), - ); + const sessionsAfterForbiddenSwitch = await readActiveTenantIds(); assertOptionalField(sessionsAfterForbiddenSwitch[0], 'activeTenantId', firstTenantId); await runEffectTestPromise( @@ -1407,24 +1401,9 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session .set({ status: 'disabled' }) .where(eq(principals.principalId, secondPrincipalId)), ); - const inactiveTargetResponse = await runtime.handler( - new Request(`${configuration.baseUrl}/auth/tenant/switch`, { - body: JSON.stringify({ tenantId: secondTenantId }), - headers: new Headers({ - 'content-type': 'application/json', - cookie: authenticatedCookie, - origin: configuration.baseUrl, - }), - method: 'POST', - }), - ); + const inactiveTargetResponse = await runtime.handler(tenantSwitchRequest(secondTenantId)); assert.equal(inactiveTargetResponse.status, 403); - const sessionsAfterInactiveSwitch = await runEffectTestPromise( - authDatabase - .select({ activeTenantId: session.activeTenantId }) - .from(session) - .where(eq(session.userId, betterAuthUserId)), - ); + const sessionsAfterInactiveSwitch = await readActiveTenantIds(); assertOptionalField(sessionsAfterInactiveSwitch[0], 'activeTenantId', firstTenantId); await runEffectTestPromise( coreDatabase @@ -1459,23 +1438,10 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session ).createHandler(); handlers.push(resolverUnavailableRuntime); const resolverUnavailableResponse = await resolverUnavailableRuntime.handler( - new Request(`${configuration.baseUrl}/auth/tenant/switch`, { - body: JSON.stringify({ tenantId: secondTenantId }), - headers: new Headers({ - 'content-type': 'application/json', - cookie: authenticatedCookie, - origin: configuration.baseUrl, - }), - method: 'POST', - }), + tenantSwitchRequest(secondTenantId), ); assert.equal(resolverUnavailableResponse.status, 503); - const sessionsAfterResolverFailure = await runEffectTestPromise( - authDatabase - .select({ activeTenantId: session.activeTenantId }) - .from(session) - .where(eq(session.userId, betterAuthUserId)), - ); + const sessionsAfterResolverFailure = await readActiveTenantIds(); assertOptionalField(sessionsAfterResolverFailure[0], 'activeTenantId', firstTenantId); // Drizzle has no query-builder failure injection. This temporary trigger raises PostgreSQL's @@ -1509,23 +1475,10 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session ); try { const persistenceUnavailableResponse = await runtime.handler( - new Request(`${configuration.baseUrl}/auth/tenant/switch`, { - body: JSON.stringify({ tenantId: secondTenantId }), - headers: new Headers({ - 'content-type': 'application/json', - cookie: authenticatedCookie, - origin: configuration.baseUrl, - }), - method: 'POST', - }), + tenantSwitchRequest(secondTenantId), ); assert.equal(persistenceUnavailableResponse.status, 503); - const sessionsAfterPersistenceFailure = await runEffectTestPromise( - authDatabase - .select({ activeTenantId: session.activeTenantId }) - .from(session) - .where(eq(session.userId, betterAuthUserId)), - ); + const sessionsAfterPersistenceFailure = await readActiveTenantIds(); assertOptionalField(sessionsAfterPersistenceFailure[0], 'activeTenantId', firstTenantId); } finally { await runEffectTestPromise( @@ -1552,17 +1505,7 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session ); assertOptionalField(sessionsBeforeSwitch[0], 'activeLegalEntityId', firstLegalEntityId); - const switchResponse = await runtime.handler( - new Request(`${configuration.baseUrl}/auth/tenant/switch`, { - body: JSON.stringify({ tenantId: secondTenantId }), - headers: new Headers({ - 'content-type': 'application/json', - cookie: authenticatedCookie, - origin: configuration.baseUrl, - }), - method: 'POST', - }), - ); + const switchResponse = await runtime.handler(tenantSwitchRequest(secondTenantId)); assert.equal(switchResponse.status, 200); assert.deepEqual(await switchResponse.json(), { selectedTenantId: secondTenantId }); const sessionsAfterSwitch = await runEffectTestPromise( @@ -1608,15 +1551,9 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session method: 'POST', }), ); - const assertion = Schema.decodeUnknownSync(TokenResponseSchema)(await assertionResponse.json()); - const verified = await jwtVerify(assertion.token, pair.publicKey, { - algorithms: ['EdDSA'], - audience: 'inventory-stock', - currentDate: new Date(1_700_000_001_000), - issuer: 'https://shell.example.test', - }); - const verifiedPrincipal = Schema.decodeUnknownSync(TrustedPrincipalContextSchema)( - verified.payload['principal'], + const { principal: verifiedPrincipal } = await verifiedGatewayAssertion( + assertionResponse, + pair.publicKey, ); assert.equal(verifiedPrincipal.authBindingId, secondAuthBindingId); assert.match(optionalText(verifiedPrincipal.authContextRef), /^better-auth-session:/u); @@ -1656,15 +1593,8 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session ); try { const unexpectedSwitchResponse = await runtime.handler( - new Request(`${configuration.baseUrl}/auth/tenant/switch`, { - body: JSON.stringify({ tenantId: firstTenantId }), - headers: new Headers({ - 'content-type': 'application/json', - cookie: authenticatedCookie, - origin: configuration.baseUrl, - 'x-correlation-id': 'unexpected-switch-persistence-test', - }), - method: 'POST', + tenantSwitchRequest(firstTenantId, { + 'x-correlation-id': 'unexpected-switch-persistence-test', }), ); assert.equal(unexpectedSwitchResponse.status, 500); @@ -1672,12 +1602,7 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session await unexpectedSwitchResponse.text(), /secret auth persistence defect|P0001/u, ); - const sessionsAfterUnexpectedSwitchFailure = await runEffectTestPromise( - authDatabase - .select({ activeTenantId: session.activeTenantId }) - .from(session) - .where(eq(session.userId, betterAuthUserId)), - ); + const sessionsAfterUnexpectedSwitchFailure = await readActiveTenantIds(); assertOptionalField( sessionsAfterUnexpectedSwitchFailure[0], 'activeTenantId', @@ -1704,12 +1629,7 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session await unexpectedLegacyUpgradeResponse.text(), /secret auth persistence defect|P0001/u, ); - const sessionsAfterUnexpectedLegacyUpgrade = await runEffectTestPromise( - authDatabase - .select({ activeTenantId: session.activeTenantId }) - .from(session) - .where(eq(session.userId, betterAuthUserId)), - ); + const sessionsAfterUnexpectedLegacyUpgrade = await readActiveTenantIds(); assertOptionalField(sessionsAfterUnexpectedLegacyUpgrade[0], 'activeTenantId', null); } finally { await runEffectTestPromise( @@ -1734,12 +1654,7 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session .pipe(Effect.provide(multiAuthenticationContextLayer)), ); assertOptionalField(upgradedSession.identity, 'tenantId', firstTenantId); - const upgradedSessionRows = await runEffectTestPromise( - authDatabase - .select({ activeTenantId: session.activeTenantId }) - .from(session) - .where(eq(session.userId, betterAuthUserId)), - ); + const upgradedSessionRows = await readActiveTenantIds(); assertOptionalField(upgradedSessionRows[0], 'activeTenantId', firstTenantId); await runEffectTestPromise( @@ -1753,14 +1668,11 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session .set({ revokedAt: new Date('2026-09-01T00:00:00.000Z'), status: 'revoked' }) .where(eq(principalAuthBindings.tenantId, secondTenantId)), ); - const revokedSession = await runEffectTestPromise( - Effect.flip( - authentication - .currentSession(authenticatedHeaders) - .pipe(Effect.provide(multiAuthenticationContextLayer)), - ), + await assertSessionForbidden( + authentication + .currentSession(authenticatedHeaders) + .pipe(Effect.provide(multiAuthenticationContextLayer)), ); - assert.ok(Schema.is(Schema.TaggedStruct('OntosIdentityForbiddenError', {}))(revokedSession)); await runEffectTestPromise( coreDatabase .update(principalAuthBindings) @@ -1784,15 +1696,10 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session .delete(principalAuthBindings) .where(eq(principalAuthBindings.tenantId, secondTenantId)), ); - const sessionWithRemovedBinding = await runEffectTestPromise( - Effect.flip( - authentication - .currentSession(authenticatedHeaders) - .pipe(Effect.provide(multiAuthenticationContextLayer)), - ), - ); - assert.ok( - Schema.is(Schema.TaggedStruct('OntosIdentityForbiddenError', {}))(sessionWithRemovedBinding), + await assertSessionForbidden( + authentication + .currentSession(authenticatedHeaders) + .pipe(Effect.provide(multiAuthenticationContextLayer)), ); } finally { await Promise.all(handlers.map(async ({ dispose }) => await dispose())); diff --git a/app/apps/shell-super-app/tests/integration/generated-owner-isolation.test.ts b/app/apps/shell-super-app/tests/integration/generated-owner-isolation.test.ts index cfcb531b6..1bb9104f0 100644 --- a/app/apps/shell-super-app/tests/integration/generated-owner-isolation.test.ts +++ b/app/apps/shell-super-app/tests/integration/generated-owner-isolation.test.ts @@ -1,3 +1,4 @@ +import { makeContextAccessDouble } from '../support/context-access-double.ts'; import { makeFaultInjectableCoreDatabase, TestQueryHook, @@ -41,7 +42,6 @@ import { } from '@app/core-runtime'; import type { ActionRegistration, - ContextAccessService, DomainEventContractMap, GatewayAssertionRedemption, InstalledModuleCatalog, @@ -1157,21 +1157,7 @@ test('generated owner enforces tenant and legal-entity isolation through Shell, }, ]); - const unavailableContextAccess: ContextAccessService = { - legalEntities: ({ legalEntityIds }) => - Effect.succeed(legalEntityIds.map((key) => ({ decision: 'unavailable' as const, key }))), - modules: ({ moduleIds }) => - Effect.succeed(moduleIds.map((key) => ({ decision: 'unavailable' as const, key }))), - resources: ({ resources }) => - Effect.succeed( - resources.map(({ moduleId, resourceId, resourceType }) => ({ - decision: 'unavailable' as const, - key: `${moduleId}:${resourceType}:${resourceId}`, - })), - ), - tenants: ({ tenantIds }) => - Effect.succeed(tenantIds.map((key) => ({ decision: 'unavailable' as const, key }))), - }; + const unavailableContextAccess = makeContextAccessDouble('unavailable'); const unavailableResolver: OperationalScopeResolverService = makeOperationalScopeResolver( makeOperationalScopeRepository(runtimeDatabase), unavailableContextAccess, diff --git a/app/apps/shell-super-app/tests/integration/identity-modes-runtime.test.ts b/app/apps/shell-super-app/tests/integration/identity-modes-runtime.test.ts index 9f480a544..25cdc1c7e 100644 --- a/app/apps/shell-super-app/tests/integration/identity-modes-runtime.test.ts +++ b/app/apps/shell-super-app/tests/integration/identity-modes-runtime.test.ts @@ -1,4 +1,5 @@ import { makeTestDatabaseFromPool } from '../../../../packages/core-runtime/tests/support/database.ts'; +import { purgeFixtureRows } from '../../../../packages/core-runtime/tests/support/fixture-cleanup.ts'; import { Scope as NativeScope, Exit as NativeExit, Context, Effect, Predicate } from 'effect'; import { runEffectTestSync as runNativeSync, @@ -172,36 +173,26 @@ void test('verifies provider keys and completes live support impersonation with const ids = [originalUserId, targetUserId, secondAdministratorUserId].filter( (id) => id.length > 0, ); - await runEffectTestPromise( + await purgeFixtureRows([ authDatabase .delete(supportImpersonationRecovery) .where(eq(supportImpersonationRecovery.tenantId, tenantId)), - ); - await runEffectTestPromise( authDatabase.delete(apikey).where(inArray(apikey.referenceId, ids)), - ); - await runEffectTestPromise(authDatabase.delete(session).where(inArray(session.userId, ids))); - await runEffectTestPromise(authDatabase.delete(account).where(inArray(account.userId, ids))); - await runEffectTestPromise(authDatabase.delete(user).where(inArray(user.id, ids))); + authDatabase.delete(session).where(inArray(session.userId, ids)), + authDatabase.delete(account).where(inArray(account.userId, ids)), + authDatabase.delete(user).where(inArray(user.id, ids)), + ]); } - await runEffectTestPromise( + await purgeFixtureRows([ coreDatabase.delete(dataAccessEvents).where(eq(dataAccessEvents.tenantId, tenantId)), - ); - await runEffectTestPromise( coreDatabase.delete(auditEvents).where(eq(auditEvents.tenantId, tenantId)), - ); - await runEffectTestPromise( coreDatabase.delete(actionInvocations).where(eq(actionInvocations.tenantId, tenantId)), - ); - await runEffectTestPromise( coreDatabase .delete(principalAuthBindings) .where(eq(principalAuthBindings.tenantId, tenantId)), - ); - await runEffectTestPromise( coreDatabase.delete(principals).where(eq(principals.tenantId, tenantId)), - ); - await runEffectTestPromise(coreDatabase.delete(tenants).where(eq(tenants.tenantId, tenantId))); + coreDatabase.delete(tenants).where(eq(tenants.tenantId, tenantId)), + ]); }; try { diff --git a/app/apps/shell-super-app/tests/support/context-access-double.ts b/app/apps/shell-super-app/tests/support/context-access-double.ts new file mode 100644 index 000000000..7ad75cc03 --- /dev/null +++ b/app/apps/shell-super-app/tests/support/context-access-double.ts @@ -0,0 +1,18 @@ +import type { ContextAccessService } from '@app/core-runtime'; +import { Effect } from 'effect'; + +export const makeContextAccessDouble = ( + decision: 'allowed' | 'unavailable', +): ContextAccessService => ({ + legalEntities: ({ legalEntityIds }) => + Effect.succeed(legalEntityIds.map((key) => ({ decision, key }))), + modules: ({ moduleIds }) => Effect.succeed(moduleIds.map((key) => ({ decision, key }))), + resources: ({ resources }) => + Effect.succeed( + resources.map(({ moduleId, resourceId, resourceType }) => ({ + decision, + key: `${moduleId}:${resourceType}:${resourceId}`, + })), + ), + tenants: ({ tenantIds }) => Effect.succeed(tenantIds.map((key) => ({ decision, key }))), +}); diff --git a/app/apps/shell-super-app/tests/unit/auth-contract.test.ts b/app/apps/shell-super-app/tests/unit/auth-contract.test.ts index 8bcc58fb7..d94f8d2cd 100644 --- a/app/apps/shell-super-app/tests/unit/auth-contract.test.ts +++ b/app/apps/shell-super-app/tests/unit/auth-contract.test.ts @@ -78,34 +78,43 @@ test('publishes authentication, identity lifecycle, and gateway operations', () expect(legalEntityEndpoints).toEqual(['availableLegalEntities', 'switchLegalEntity']); expect(tenantEndpoints).toEqual(['availableTenants', 'switchTenant']); expect(resourceEndpoints).toEqual(['attachMedia', 'resourceDetail', 'search']); + expect( + Object.fromEntries( + Object.values(ShellAuthenticationApi.groups).flatMap((group) => + Object.entries(group.endpoints).map(([name, endpoint]) => [name, endpoint.path]), + ), + ), + ).toEqual({ + attachMedia: '/shell/resource/media-attachment', + availableLegalEntities: '/auth/legal-entities', + availableTenants: '/auth/tenants', + changePrincipalStatus: '/auth/identity/principal-status', + createNonHumanPrincipal: '/auth/identity/principals', + currentSession: '/auth/session', + issueApiKeyGatewayContext: '/auth/api-key/gateway-context', + issueGatewayContext: '/auth/gateway-context', + issueManagedApiKey: '/auth/identity/api-keys/managed', + issueSelfApiKey: '/auth/identity/api-keys/self', + listManagedApiKeys: '/auth/identity/api-keys/managed/list', + listSelfApiKeys: '/auth/identity/api-keys/self/list', + resolveModuleTarget: '/shell/module-target', + resourceDetail: '/shell/resource', + rotateManagedApiKey: '/auth/identity/api-keys/managed/rotate', + rotateSelfApiKey: '/auth/identity/api-keys/self/rotate', + search: '/shell/search', + setManagedApiKeyStatus: '/auth/identity/api-keys/managed/status', + setSelfApiKeyStatus: '/auth/identity/api-keys/self/status', + shellComposition: '/shell/composition', + signIn: '/auth/sign-in', + signOut: '/auth/sign-out', + startSupportImpersonation: '/auth/identity/impersonation/start', + stopSupportImpersonation: '/auth/identity/impersonation/stop', + switchLegalEntity: '/auth/legal-entity/switch', + switchTenant: '/auth/tenant/switch', + }); expect(shellAuthenticationApiContract).toEqual({ apiPrefix: '/shell-super-app-api', - availableLegalEntitiesPath: '/shell-super-app-api/auth/legal-entities', - availableTenantsPath: '/shell-super-app-api/auth/tenants', - changePrincipalStatusPath: '/shell-super-app-api/auth/identity/principal-status', - compositionPath: '/shell-super-app-api/shell/composition', - createNonHumanPrincipalPath: '/shell-super-app-api/auth/identity/principals', - currentSessionPath: '/shell-super-app-api/auth/session', - issueApiKeyGatewayContextPath: '/shell-super-app-api/auth/api-key/gateway-context', - issueGatewayContextPath: '/shell-super-app-api/auth/gateway-context', - issueManagedApiKeyPath: '/shell-super-app-api/auth/identity/api-keys/managed', - issueSelfApiKeyPath: '/shell-super-app-api/auth/identity/api-keys/self', - listManagedApiKeysPath: '/shell-super-app-api/auth/identity/api-keys/managed/list', - listSelfApiKeysPath: '/shell-super-app-api/auth/identity/api-keys/self/list', - mediaAttachmentPath: '/shell-super-app-api/shell/resource/media-attachment', - ownerId: 'shell-super-app', - resolveModuleTargetPath: '/shell-super-app-api/shell/module-target', - resourceDetailPath: '/shell-super-app-api/shell/resource', - rotateManagedApiKeyPath: '/shell-super-app-api/auth/identity/api-keys/managed/rotate', - rotateSelfApiKeyPath: '/shell-super-app-api/auth/identity/api-keys/self/rotate', - searchPath: '/shell-super-app-api/shell/search', - setManagedApiKeyStatusPath: '/shell-super-app-api/auth/identity/api-keys/managed/status', - setSelfApiKeyStatusPath: '/shell-super-app-api/auth/identity/api-keys/self/status', signInPath: '/shell-super-app-api/auth/sign-in', - signOutPath: '/shell-super-app-api/auth/sign-out', - startSupportImpersonationPath: '/shell-super-app-api/auth/identity/impersonation/start', - stopSupportImpersonationPath: '/shell-super-app-api/auth/identity/impersonation/stop', - switchLegalEntityPath: '/shell-super-app-api/auth/legal-entity/switch', switchTenantPath: '/shell-super-app-api/auth/tenant/switch', }); expect([...authenticationEndpoints, ...gatewayEndpoints].join(':')).not.toMatch( diff --git a/app/apps/shell-super-app/tests/unit/auth-schema.test.ts b/app/apps/shell-super-app/tests/unit/auth-schema.test.ts index ac4238b95..fafb3ee0d 100644 --- a/app/apps/shell-super-app/tests/unit/auth-schema.test.ts +++ b/app/apps/shell-super-app/tests/unit/auth-schema.test.ts @@ -101,9 +101,26 @@ test('matches the generated API Key and Admin plugin persistence fields', () => test('reports missing and unexpected authentication tables', () => { expect( - compareAuthCatalog(['auth.user', 'auth.session', 'auth.account', 'auth.unexpected']), + compareAuthCatalog([ + 'auth.user', + 'auth.session', + 'auth.account', + 'auth.unexpected', + 'auth.unexpected', + ]), ).toEqual({ missing: ['auth.apikey', 'auth.support_impersonation_recovery', 'auth.verification'], unexpected: ['auth.unexpected'], }); }); + +test('accepts unordered duplicate auth table rows without mutating the inventory', () => { + expect(compareAuthCatalog([...expectedAuthTableCatalog.toReversed(), 'auth.user'])).toEqual({ + missing: [], + unexpected: [], + }); + expect(compareAuthCatalog([])).toEqual({ + missing: expectedAuthTableCatalog.toSorted(), + unexpected: [], + }); +}); diff --git a/app/apps/shell-super-app/tests/unit/configuration-provider.test.ts b/app/apps/shell-super-app/tests/unit/configuration-provider.test.ts new file mode 100644 index 000000000..46dac9d52 --- /dev/null +++ b/app/apps/shell-super-app/tests/unit/configuration-provider.test.ts @@ -0,0 +1,61 @@ +import { runEffectTestPromise } from '@app/core-runtime/testing/effect-runtime'; +import { expect, rs, test } from '@rstest/core'; +import { Config, ConfigProvider, Effect } from 'effect'; +import { loadAuthConfig } from '../../api/auth/config.ts'; +import { loadGatewayIssuerConfig } from '../../api/auth/gateway-issuer-config.ts'; +import { loadConfigurationProvider } from '../../api/auth/configuration-provider.ts'; +import { loadEnvironmentFileProvider } from '../../api/auth/environment-file-provider.ts'; + +rs.mock('../../api/auth/environment-file-provider.ts', () => ({ + loadEnvironmentFileProvider: rs.fn(() => Effect.succeed(ConfigProvider.fromEnvRecord({}))), +})); + +test('explicit environment overrides file values and absent keys fall back to the file', async () => { + rs.mocked(loadEnvironmentFileProvider).mockReturnValue( + Effect.succeed(ConfigProvider.fromEnvRecord({ SECRET: 'file-secret', URL: 'file-url' })), + ); + const result = await runEffectTestPromise( + loadConfigurationProvider( + { environment: { URL: 'explicit-url' }, envPath: 'fixture-path' }, + () => 'unreadable', + ).pipe( + Effect.flatMap((provider) => + Config.all({ secret: Config.string('SECRET'), url: Config.string('URL') }).parse(provider), + ), + ), + ); + expect(result).toEqual({ secret: 'file-secret', url: 'explicit-url' }); + expect(loadEnvironmentFileProvider).toHaveBeenCalledWith('fixture-path', expect.any(Function)); +}); + +const read = ( + options: { readonly environment?: Readonly>> } = {}, +) => + loadConfigurationProvider(options, () => 'unreadable').pipe( + Effect.flatMap((provider) => Config.string('ONTOS_PROVIDER_TEST').parse(provider)), + ); + +test('an explicit empty environment does not fall through to process values', async () => { + rs.stubEnv('ONTOS_PROVIDER_TEST', 'process-value'); + rs.mocked(loadEnvironmentFileProvider).mockReturnValue( + Effect.succeed(ConfigProvider.fromEnvRecord({ ONTOS_PROVIDER_TEST: 'file-value' })), + ); + try { + expect(await runEffectTestPromise(read())).toBe('process-value'); + expect(await runEffectTestPromise(read({ environment: {} }))).toBe('file-value'); + } finally { + rs.unstubAllEnvs(); + } +}); + +test('file loading failures retain the parser-specific typed error and safe reason', async () => { + rs.mocked(loadEnvironmentFileProvider).mockImplementation((_path, failure) => + Effect.fail(failure()), + ); + const authFailure = await runEffectTestPromise(Effect.flip(loadAuthConfig({ environment: {} }))); + const gatewayFailure = await runEffectTestPromise( + Effect.flip(loadGatewayIssuerConfig({ environment: {} })), + ); + expect(authFailure.reason).toBe('Unable to load the root authentication environment'); + expect(gatewayFailure.reason).toBe('Unable to load the Shell gateway signing environment'); +}); diff --git a/app/apps/shell-super-app/tests/unit/impersonation-service.test.ts b/app/apps/shell-super-app/tests/unit/impersonation-service.test.ts index d6c520c56..12069313e 100644 --- a/app/apps/shell-super-app/tests/unit/impersonation-service.test.ts +++ b/app/apps/shell-super-app/tests/unit/impersonation-service.test.ts @@ -1,3 +1,4 @@ +import { makeContextAccessDouble } from '../support/context-access-double.ts'; import { runEffectTestPromise } from '@app/core-runtime/testing/effect-runtime'; // @effect-diagnostics asyncFunction:off -- Existing compatibility boundary; expires: 2026-12-31. import { expect, test } from '@rstest/core'; @@ -13,7 +14,6 @@ import { } from '@app/core-runtime'; import type { ActionRuntimeService, - ContextAccessService, PrincipalResolverService, SupportRecoveryPrincipalContextResolverService, } from '@app/core-runtime'; @@ -78,21 +78,7 @@ const providePrincipalManagementRepository = Effect.provideService( PrincipalManagementRepository, principalManagementRepository, ); -const contextAccess: ContextAccessService = { - legalEntities: ({ legalEntityIds }) => - Effect.succeed(legalEntityIds.map((key) => ({ decision: 'allowed' as const, key }))), - modules: ({ moduleIds }) => - Effect.succeed(moduleIds.map((key) => ({ decision: 'allowed' as const, key }))), - resources: ({ resources }) => - Effect.succeed( - resources.map(({ moduleId, resourceId, resourceType }) => ({ - decision: 'allowed' as const, - key: `${moduleId}:${resourceType}:${resourceId}`, - })), - ), - tenants: ({ tenantIds }) => - Effect.succeed(tenantIds.map((key) => ({ decision: 'allowed' as const, key }))), -}; +const contextAccess = makeContextAccessDouble('allowed'); const provideContextAccess = Effect.provideService(ContextAccess, contextAccess); const makeService = (options: { diff --git a/app/apps/shell-super-app/tests/unit/layout.test.tsx b/app/apps/shell-super-app/tests/unit/layout.test.tsx index 79cb6ef44..9bb964656 100644 --- a/app/apps/shell-super-app/tests/unit/layout.test.tsx +++ b/app/apps/shell-super-app/tests/unit/layout.test.tsx @@ -45,7 +45,11 @@ rstest.mock('@modern-js/plugin-i18n/runtime', () => ({ 'shell.dashboard.brand': 'OntOS', 'shell.dashboard.header.label': 'Dashboard header', 'shell.dashboard.legalEntity.accessibleLabel': 'Current legal entity', + 'shell.dashboard.legalEntity.failed': 'Legal entity switching failed. Try again.', + 'shell.dashboard.legalEntity.pending': 'Switching legal entity…', 'shell.dashboard.legalEntity.placeholder': 'Select a legal entity', + 'shell.dashboard.legalEntity.unavailable': + 'Legal entity choices are temporarily unavailable.', 'shell.dashboard.navigation.home': 'Home', 'shell.dashboard.navigation.label': 'Dashboard navigation', 'shell.dashboard.sidebar.label': 'Dashboard sidebar', @@ -473,3 +477,104 @@ test('associates failed tenant feedback and keeps multiple choices operable', () expect(trigger.getAttribute('aria-invalid')).toBe('true'); expect(screen.getByText('Tenant switching failed. Try again.')).toBeTruthy(); }); + +test('names the legal-entity selector by its own label and keeps a sole choice operable', () => { + const { rerender } = render( + + Content + , + ); + + const legalEntity = screen.getByRole('combobox', { name: 'Current legal entity' }); + expect(legalEntity.hasAttribute('aria-label')).toBe(false); + expect(legalEntity.hasAttribute('aria-describedby')).toBe(false); + expect(legalEntity.hasAttribute('disabled')).toBe(false); + expect(screen.getByRole('combobox', { name: 'Current tenant' }).getAttribute('aria-label')).toBe( + 'Current tenant', + ); + expect(screen.queryByText('Select a legal entity')).toBeNull(); + + const { currentLegalEntityId: _selectedLegalEntityId, ...unselectedLegalEntityProps } = + tenantProps; + rerender( + + Content + , + ); + + expect(screen.getByText('Select a legal entity')).toBeTruthy(); +}); + +interface LegalEntitySelectorStateCase { + readonly disabled: boolean; + readonly name: string; + readonly overrides: Partial< + Pick< + ComponentProps, + 'legalEntityState' | 'legalEntitySwitchFailed' | 'legalEntitySwitchPending' + > + >; + readonly statusText: string; +} + +const legalEntitySelectorStateCases: LegalEntitySelectorStateCase[] = [ + { + disabled: true, + name: 'the legal entities are unavailable', + overrides: { legalEntityState: 'unavailable' }, + statusText: 'Legal entity choices are temporarily unavailable.', + }, + { + disabled: true, + name: 'a legal-entity switch is pending', + overrides: { legalEntitySwitchPending: true }, + statusText: 'Switching legal entity…', + }, + { + disabled: false, + name: 'a legal-entity switch failed', + overrides: { legalEntitySwitchFailed: true }, + statusText: 'Legal entity switching failed. Try again.', + }, +]; + +test.each(legalEntitySelectorStateCases)( + 'associates legal-entity feedback with its own selector when $name', + ({ disabled, overrides, statusText }) => { + render( + + Content + , + ); + + const legalEntity = screen.getByRole('combobox', { name: 'Current legal entity' }); + expect(legalEntity.hasAttribute('disabled')).toBe(disabled); + expect(legalEntity.getAttribute('aria-describedby')).toBe('legal-entity-switch-status'); + expect(screen.getByText(statusText)).toBeTruthy(); + expect( + screen.getByRole('combobox', { name: 'Current tenant' }).getAttribute('aria-describedby'), + ).toBeNull(); + }, +); diff --git a/app/apps/shell-super-app/tests/unit/routes/home/page.test.tsx b/app/apps/shell-super-app/tests/unit/routes/home/page.test.tsx index 1697c3b8e..957199f87 100644 --- a/app/apps/shell-super-app/tests/unit/routes/home/page.test.tsx +++ b/app/apps/shell-super-app/tests/unit/routes/home/page.test.tsx @@ -7,9 +7,12 @@ import type { ReactNode } from 'react'; import { AppIdSchema, GroupKeySchema, + LegalEntityAccessForbiddenProblemSchema, LegalEntityIdSchema, ModuleIdSchema, PrincipalIdSchema, + TenantAccessForbiddenProblemSchema, + TenantAuthenticationRequiredProblemSchema, TenantIdSchema, } from '../../../../shared/api.ts'; import { HomeView } from '../../../../src/routes/[lang]/page.tsx'; @@ -219,3 +222,115 @@ test('logout clears the authenticated composition together', async () => { expect(navigateMock).toHaveBeenCalledWith({ reloadDocument: true, to: '/en/login' }), ); }); + +const tenantAuthenticationRequired = Schema.decodeUnknownSync( + TenantAuthenticationRequiredProblemSchema, +)({ + _tag: 'TenantAuthenticationRequiredProblem', + detail: 'The tenant session expired.', + status: 401, + title: 'Tenant authentication required', + type: 'https://ontos.dev/problems/tenant-authentication-required', +}); +const tenantAccessForbidden = Schema.decodeUnknownSync(TenantAccessForbiddenProblemSchema)({ + _tag: 'TenantAccessForbiddenProblem', + detail: 'The principal cannot use this tenant.', + status: 403, + title: 'Tenant access forbidden', + type: 'https://ontos.dev/problems/tenant-access-forbidden', +}); +const legalEntityAccessForbidden = Schema.decodeUnknownSync( + LegalEntityAccessForbiddenProblemSchema, +)({ + _tag: 'LegalEntityAccessForbiddenProblem', + detail: 'The principal cannot use this legal entity.', + status: 403, + title: 'Legal entity access forbidden', + type: 'https://ontos.dev/problems/legal-entity-access-forbidden', +}); + +interface SwitchFailureCase { + readonly comboboxName: string; + readonly failedText: string; + readonly failure: + | typeof legalEntityAccessForbidden + | typeof tenantAccessForbidden + | typeof tenantAuthenticationRequired; + readonly name: string; + readonly optionName: string; + readonly pendingText: string; + readonly reloads: boolean; + readonly switchMock: typeof switchLegalEntityMock; +} + +const switchFailureCases: SwitchFailureCase[] = [ + { + comboboxName: 'Current tenant', + failedText: 'Tenant switching failed', + failure: tenantAccessForbidden, + name: 'a forbidden tenant switch', + optionName: 'Zeta tenant', + pendingText: 'Switching tenant', + reloads: false, + switchMock: switchTenantMock, + }, + { + comboboxName: 'Current legal entity', + failedText: 'Legal entity switching failed', + failure: legalEntityAccessForbidden, + name: 'a forbidden legal-entity switch', + optionName: 'Beta company', + pendingText: 'Switching legal entity', + reloads: false, + switchMock: switchLegalEntityMock, + }, + { + comboboxName: 'Current tenant', + failedText: 'Tenant switching failed', + failure: tenantAuthenticationRequired, + name: 'an unauthenticated tenant switch', + optionName: 'Zeta tenant', + pendingText: 'Switching tenant', + reloads: true, + switchMock: switchTenantMock, + }, + { + comboboxName: 'Current legal entity', + failedText: 'Legal entity switching failed', + failure: tenantAuthenticationRequired, + name: 'an unauthenticated legal-entity switch', + optionName: 'Beta company', + pendingText: 'Switching legal entity', + reloads: true, + switchMock: switchLegalEntityMock, + }, +]; + +test.each(switchFailureCases)( + 'settles $name into its own selector without leaving it pending', + async ({ comboboxName, failedText, failure, optionName, pendingText, reloads, switchMock }) => { + switchMock.mockReturnValue(Effect.fail(failure)); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('combobox', { name: comboboxName })); + await user.click(await screen.findByRole('option', { name: optionName })); + await waitFor(() => expect(switchMock).toHaveBeenCalledTimes(1)); + + if (reloads) { + await waitFor(() => + expect(navigateMock).toHaveBeenCalledWith({ reloadDocument: true, to: '.' }), + ); + await waitFor(() => expect(screen.queryByText(pendingText)).toBeNull()); + expect(screen.queryByText(failedText)).toBeNull(); + } else { + await waitFor(() => expect(screen.getByText(failedText)).toBeTruthy()); + expect(navigateMock).not.toHaveBeenCalled(); + expect(screen.queryByText(pendingText)).toBeNull(); + } + + expect(screen.getByRole('combobox', { name: comboboxName }).hasAttribute('disabled')).toBe( + false, + ); + }, +); diff --git a/app/apps/shell-super-app/tests/unit/routes/login/locales.test.ts b/app/apps/shell-super-app/tests/unit/routes/login/locales.test.ts index e122b2fd7..059935c02 100644 --- a/app/apps/shell-super-app/tests/unit/routes/login/locales.test.ts +++ b/app/apps/shell-super-app/tests/unit/routes/login/locales.test.ts @@ -3,17 +3,26 @@ import cs from '../../../../locales/cs/shell.json'; import en from '../../../../locales/en/shell.json'; import { ultramodernRouteMetadata } from '../../../../src/routes/ultramodern-route-metadata'; +test.each([ + ['login', cs.shell.login, en.shell.login], + ['login.field', cs.shell.login.field, en.shell.login.field], + ['login.required', cs.shell.login.required, en.shell.login.required], + ['login.toast', cs.shell.login.toast, en.shell.login.toast], + ['modules', cs.shell.modules, en.shell.modules], + ['modules.active', cs.shell.modules.active, en.shell.modules.active], + ['modules.state', cs.shell.modules.state, en.shell.modules.state], + ['dashboard', cs.shell.dashboard, en.shell.dashboard], + ['dashboard.account', cs.shell.dashboard.account, en.shell.dashboard.account], + ['dashboard.header', cs.shell.dashboard.header, en.shell.dashboard.header], + ['dashboard.home', cs.shell.dashboard.home, en.shell.dashboard.home], + ['dashboard.navigation', cs.shell.dashboard.navigation, en.shell.dashboard.navigation], + ['dashboard.sidebar', cs.shell.dashboard.sidebar, en.shell.dashboard.sidebar], + ['dashboard.tenant', cs.shell.dashboard.tenant, en.shell.dashboard.tenant], +])('aligns Czech and English %s translation keys', (_name, czech, english) => { + expect(Object.keys(czech).toSorted()).toEqual(Object.keys(english).toSorted()); +}); + test('keeps the Czech and English login translation contracts aligned', () => { - expect(Object.keys(cs.shell.login).toSorted()).toEqual(Object.keys(en.shell.login).toSorted()); - expect(Object.keys(cs.shell.login.field).toSorted()).toEqual( - Object.keys(en.shell.login.field).toSorted(), - ); - expect(Object.keys(cs.shell.login.required).toSorted()).toEqual( - Object.keys(en.shell.login.required).toSorted(), - ); - expect(Object.keys(cs.shell.login.toast).toSorted()).toEqual( - Object.keys(en.shell.login.toast).toSorted(), - ); expect(en.shell.login.submit).toBe('Login'); }); @@ -29,41 +38,11 @@ test('includes the login route in the generated metadata manifest', () => { }); test('keeps the Czech and English active-module translation contracts aligned', () => { - expect(Object.keys(cs.shell.modules).toSorted()).toEqual( - Object.keys(en.shell.modules).toSorted(), - ); - expect(Object.keys(cs.shell.modules.active).toSorted()).toEqual( - Object.keys(en.shell.modules.active).toSorted(), - ); - expect(Object.keys(cs.shell.modules.state).toSorted()).toEqual( - Object.keys(en.shell.modules.state).toSorted(), - ); expect(en.shell.modules.state.active).toBe('Active'); expect(cs.shell.modules.state.active).toBe('Aktivní'); }); test('keeps the exact Czech and English dashboard translation contracts aligned', () => { - expect(Object.keys(cs.shell.dashboard).toSorted()).toEqual( - Object.keys(en.shell.dashboard).toSorted(), - ); - expect(Object.keys(cs.shell.dashboard.account).toSorted()).toEqual( - Object.keys(en.shell.dashboard.account).toSorted(), - ); - expect(Object.keys(cs.shell.dashboard.header).toSorted()).toEqual( - Object.keys(en.shell.dashboard.header).toSorted(), - ); - expect(Object.keys(cs.shell.dashboard.home).toSorted()).toEqual( - Object.keys(en.shell.dashboard.home).toSorted(), - ); - expect(Object.keys(cs.shell.dashboard.navigation).toSorted()).toEqual( - Object.keys(en.shell.dashboard.navigation).toSorted(), - ); - expect(Object.keys(cs.shell.dashboard.sidebar).toSorted()).toEqual( - Object.keys(en.shell.dashboard.sidebar).toSorted(), - ); - expect(Object.keys(cs.shell.dashboard.tenant).toSorted()).toEqual( - Object.keys(en.shell.dashboard.tenant).toSorted(), - ); expect(Object.keys(en.shell.dashboard.tenant).toSorted()).toEqual([ 'accessibleLabel', 'failed', diff --git a/app/apps/shell-super-app/tests/unit/routes/login/page.test.tsx b/app/apps/shell-super-app/tests/unit/routes/login/page.test.tsx index 1c63ded68..eae9573be 100644 --- a/app/apps/shell-super-app/tests/unit/routes/login/page.test.tsx +++ b/app/apps/shell-super-app/tests/unit/routes/login/page.test.tsx @@ -101,154 +101,141 @@ test('shows the required login controls through the UI kit', () => { ); }); -test('shows both field errors and one Toast when both values are missing', async () => { +interface LoginValidationCase { + readonly focus: 'login' | 'password' | 'submit'; + readonly login: string; + readonly loginInvalid: boolean; + readonly name: string; + readonly password: string; + readonly passwordInvalid: boolean; +} + +const focusTargets = { + login: getLogin, + password: getPassword, + submit: getSubmit, +}; + +const submitLogin = async (login: string, password: string) => { const user = userEvent.setup(); renderLogin(); - - await user.click(getSubmit()).then(() => { - const login = getLogin(); - const password = getPassword(); - - expect(login.getAttribute('aria-invalid')).toBe('true'); - expect(password.getAttribute('aria-invalid')).toBe('true'); - expect(screen.getByText('Enter your login.')).toBeTruthy(); - expect(screen.getByText('Enter your password.')).toBeTruthy(); - expect(screen.getAllByText('Login details are incomplete')).toHaveLength(1); - expect(screen.getByText('Fill in both required fields.')).toBeTruthy(); - expect(document.activeElement).toBe(login); - }); -}); + if (login.length > 0) { + await user.type(getLogin(), login); + } + if (password.length > 0) { + await user.type(getPassword(), password); + } + await user.click(getSubmit()); + return user; +}; + +const validationCases: LoginValidationCase[] = [ + { + focus: 'login', + login: '', + loginInvalid: true, + name: 'both values are missing', + password: '', + passwordInvalid: true, + }, + { + focus: 'login', + login: '', + loginInvalid: true, + name: 'only the Password is present', + password: 'secret', + passwordInvalid: false, + }, + { + focus: 'password', + login: 'admin', + loginInvalid: false, + name: 'only the Login is present', + password: '', + passwordInvalid: true, + }, + { + focus: 'login', + login: ' ', + loginInvalid: true, + name: 'the Login holds only whitespace', + password: 'secret', + passwordInvalid: false, + }, + { + focus: 'submit', + login: 'admin', + loginInvalid: false, + name: 'the Password is a single space', + password: ' ', + passwordInvalid: false, + }, +]; + +test.each(validationCases)( + 'marks, explains and focuses exactly the missing fields when $name', + async ({ focus, login, loginInvalid, password, passwordInvalid }) => { + await submitLogin(login, password); + + const incompleteToasts = loginInvalid || passwordInvalid ? 1 : 0; + expect(getLogin().getAttribute('aria-invalid')).toBe(loginInvalid ? 'true' : null); + expect(getPassword().getAttribute('aria-invalid')).toBe(passwordInvalid ? 'true' : null); + expect(screen.queryAllByText('Enter your login.')).toHaveLength(loginInvalid ? 1 : 0); + expect(screen.queryAllByText('Enter your password.')).toHaveLength(passwordInvalid ? 1 : 0); + expect(screen.queryAllByText('Login details are incomplete')).toHaveLength(incompleteToasts); + expect(screen.queryAllByText('Fill in both required fields.')).toHaveLength(incompleteToasts); + expect(document.activeElement).toBe(focusTargets[focus]()); + }, +); test('creates one Toast per repeated invalid submission', async () => { - const user = userEvent.setup(); - renderLogin(); + const user = await submitLogin('', ''); - await user - .click(getSubmit()) - .then(async () => await user.click(getSubmit())) - .then(() => { - expect(screen.getAllByText('Login details are incomplete')).toHaveLength(2); - expect(screen.getAllByText('Fill in both required fields.')).toHaveLength(2); - }); -}); + await user.click(getSubmit()); -test('shows only the Login error when the password is present', async () => { - const user = userEvent.setup(); - renderLogin(); - - await user - .type(getPassword(), 'secret') - .then(async () => await user.click(getSubmit())) - .then(() => { - expect(getLogin().getAttribute('aria-invalid')).toBe('true'); - expect(getPassword().getAttribute('aria-invalid')).toBeNull(); - expect(screen.getByText('Enter your login.')).toBeTruthy(); - expect(screen.queryByText('Enter your password.')).toBeNull(); - expect(screen.getAllByText('Login details are incomplete')).toHaveLength(1); - expect(document.activeElement).toBe(getLogin()); - }); -}); - -test('shows only the Password error when the login is present', async () => { - const user = userEvent.setup(); - renderLogin(); - - await user - .type(getLogin(), 'admin') - .then(async () => await user.click(getSubmit())) - .then(() => { - expect(getLogin().getAttribute('aria-invalid')).toBeNull(); - expect(getPassword().getAttribute('aria-invalid')).toBe('true'); - expect(screen.queryByText('Enter your login.')).toBeNull(); - expect(screen.getByText('Enter your password.')).toBeTruthy(); - expect(screen.getAllByText('Login details are incomplete')).toHaveLength(1); - expect(document.activeElement).toBe(getPassword()); - }); -}); - -test('treats a whitespace-only Login as missing', async () => { - const user = userEvent.setup(); - renderLogin(); - - await user - .type(getLogin(), ' ') - .then(async () => await user.type(getPassword(), 'secret')) - .then(async () => await user.click(getSubmit())) - .then(() => { - expect(getLogin().getAttribute('aria-invalid')).toBe('true'); - expect(screen.getByText('Enter your login.')).toBeTruthy(); - expect(document.activeElement).toBe(getLogin()); - }); -}); - -test('accepts a non-empty whitespace Password', async () => { - const user = userEvent.setup(); - renderLogin(); - - await user - .type(getLogin(), 'admin') - .then(async () => await user.type(getPassword(), ' ')) - .then(async () => await user.click(getSubmit())) - .then(() => { - expect(getLogin().getAttribute('aria-invalid')).toBeNull(); - expect(getPassword().getAttribute('aria-invalid')).toBeNull(); - expect(screen.queryByText('Login details are incomplete')).toBeNull(); - }); + expect(screen.getAllByText('Login details are incomplete')).toHaveLength(2); + expect(screen.getAllByText('Fill in both required fields.')).toHaveLength(2); }); test('clears stale errors after both fields are corrected', async () => { - const user = userEvent.setup(); - renderLogin(); + const user = await submitLogin('', ''); - await user - .click(getSubmit()) - .then(async () => await user.type(getLogin(), 'admin')) - .then(async () => await user.type(getPassword(), 'secret')) - .then(async () => await user.click(getSubmit())) - .then(() => { - expect(getLogin().getAttribute('aria-invalid')).toBeNull(); - expect(getPassword().getAttribute('aria-invalid')).toBeNull(); - expect(screen.queryByText('Enter your login.')).toBeNull(); - expect(screen.queryByText('Enter your password.')).toBeNull(); - }); + await user.type(getLogin(), 'admin'); + await user.type(getPassword(), 'secret'); + await user.click(getSubmit()); + + expect(getLogin().getAttribute('aria-invalid')).toBeNull(); + expect(getPassword().getAttribute('aria-invalid')).toBeNull(); + expect(screen.queryByText('Enter your login.')).toBeNull(); + expect(screen.queryByText('Enter your password.')).toBeNull(); }); test('runs the same validation when submitted with Enter', async () => { const user = userEvent.setup(); renderLogin(); - await user - .click(getLogin()) - .then(async () => await user.keyboard('{Enter}')) - .then(() => { - expect(getLogin().getAttribute('aria-invalid')).toBe('true'); - expect(getPassword().getAttribute('aria-invalid')).toBe('true'); - expect(screen.getAllByText('Login details are incomplete')).toHaveLength(1); - expect(document.activeElement).toBe(getLogin()); - }); + await user.click(getLogin()); + await user.keyboard('{Enter}'); + + expect(getLogin().getAttribute('aria-invalid')).toBe('true'); + expect(getPassword().getAttribute('aria-invalid')).toBe('true'); + expect(screen.getAllByText('Login details are incomplete')).toHaveLength(1); + expect(document.activeElement).toBe(getLogin()); }); test('submits valid values through the Shell authentication client and navigates home', async () => { - const user = userEvent.setup(); - renderLogin(); + await submitLogin('admin', 'secret'); - await user - .type(getLogin(), 'admin') - .then(async () => await user.type(getPassword(), 'secret')) - .then(async () => await user.click(getSubmit())) - .then( - async () => - await waitFor(() => { - expect(signInMock).toHaveBeenCalledWith( - { - email: 'admin', - password: Redacted.make('secret'), - }, - { locale: 'en' }, - ); - expect(runBrowserEffectMock).toHaveBeenCalledTimes(1); - expect(navigateMock).toHaveBeenCalledWith({ to: '/en/' }); - expect(screen.queryByText('Login details are incomplete')).toBeNull(); - }), + await waitFor(() => { + expect(signInMock).toHaveBeenCalledWith( + { + email: 'admin', + password: Redacted.make('secret'), + }, + { locale: 'en' }, ); + expect(runBrowserEffectMock).toHaveBeenCalledTimes(1); + expect(navigateMock).toHaveBeenCalledWith({ to: '/en/' }); + expect(screen.queryByText('Login details are incomplete')).toBeNull(); + }); }); diff --git a/app/apps/shell-super-app/tests/unit/routes/modules/page.test.tsx b/app/apps/shell-super-app/tests/unit/routes/modules/page.test.tsx index 1fab832ab..40bfa0aba 100644 --- a/app/apps/shell-super-app/tests/unit/routes/modules/page.test.tsx +++ b/app/apps/shell-super-app/tests/unit/routes/modules/page.test.tsx @@ -100,6 +100,72 @@ const resolvedModel: ResolvedPageModel = { target: targetFixture('contacts.core.page-customers', 'contacts.core.page.customers'), }; +interface ExactPageCase { + readonly componentKey: string; + readonly entrypointKey: string; + readonly renderedText: string; + readonly routeParams: ResolvedPageModel['routeParams']; + readonly writable: boolean; +} + +const exactPageCases: ExactPageCase[] = [ + { + componentKey: 'contacts.core.page-customers-list', + entrypointKey: 'contacts.core.page.customers-list', + renderedText: 'contacts.core.page-customers-list:static', + routeParams: {}, + writable: true, + }, + { + componentKey: 'contacts.core.page-customer-detail', + entrypointKey: 'contacts.core.page.customer-detail', + renderedText: 'contacts.core.page-customer-detail:11111111-1111-4111-8111-111111111111', + routeParams: { id: '11111111-1111-4111-8111-111111111111' }, + writable: true, + }, + { + componentKey: 'contacts.core.page-customer-edit', + entrypointKey: 'contacts.core.page.customer-edit', + renderedText: 'contacts.core.page-customer-edit:customer-1', + routeParams: { id: 'customer-1' }, + writable: false, + }, + { + componentKey: 'contacts.core.page-customer-create', + entrypointKey: 'contacts.core.page.customer-create', + renderedText: 'contacts.core.page-customer-create:untrusted-route-context', + routeParams: { id: 'untrusted-route-context' }, + writable: true, + }, + { + componentKey: 'contacts.core.page-contact-detail', + entrypointKey: 'contacts.core.page.contact-detail', + renderedText: 'contacts.core.page-contact-detail:11111111-1111-4111-8111-111111111111', + routeParams: { + contactId: '33333333-3333-4333-8333-333333333333', + id: '11111111-1111-4111-8111-111111111111', + }, + writable: true, + }, + { + componentKey: 'contacts.core.page-contact-edit', + entrypointKey: 'contacts.core.page.contact-edit', + renderedText: 'contacts.core.page-contact-edit:11111111-1111-4111-8111-111111111111', + routeParams: { + contactId: '33333333-3333-4333-8333-333333333333', + id: '11111111-1111-4111-8111-111111111111', + }, + writable: false, + }, + { + componentKey: 'contacts.core.page-contact-create', + entrypointKey: 'contacts.core.page.contact-create', + renderedText: 'contacts.core.page-contact-create:11111111-1111-4111-8111-111111111111', + routeParams: { id: '11111111-1111-4111-8111-111111111111' }, + writable: false, + }, +]; + beforeEach(() => { loadRemotePageMock.mockResolvedValue({ default: ({ @@ -180,150 +246,21 @@ test('passes an empty route-parameter record to a resolved static page', async ( expect(await screen.findByText('contacts.core.page-customers:static')).toBeTruthy(); }); -test('loads the generated Customers list page as a static exact target', async () => { - const customersListModel: ResolvedPageModel = { - ...resolvedModel, - routeParams: {}, - target: targetFixture('contacts.core.page-customers-list', 'contacts.core.page.customers-list'), - }; - useLoaderDataMock.mockReturnValue(customersListModel); - render(); - expect(findApprovedVerticalPageClientMock).toHaveBeenCalledWith(customersListModel.target); - await waitFor(() => expect(loadRemotePageMock).toHaveBeenCalledTimes(1)); - expect(await screen.findByText('contacts.core.page-customers-list:static')).toBeTruthy(); -}); - -test('loads the approved Customer-detail remote once with the exact declared Customer ID', async () => { - const customerDetailModel: ResolvedPageModel = { - ...resolvedModel, - routeParams: { id: '11111111-1111-4111-8111-111111111111' }, - target: targetFixture( - 'contacts.core.page-customer-detail', - 'contacts.core.page.customer-detail', - ), - }; - useLoaderDataMock.mockReturnValue(customerDetailModel); - render(); - expect(findApprovedVerticalPageClientMock).toHaveBeenCalledWith(customerDetailModel.target); - await waitFor(() => expect(loadRemotePageMock).toHaveBeenCalledTimes(1)); - expect( - await screen.findByText( - 'contacts.core.page-customer-detail:11111111-1111-4111-8111-111111111111', - ), - ).toBeTruthy(); -}); - -test('loads the approved Contact-detail remote once with both exact hierarchical IDs', async () => { - const contactDetailModel: ResolvedPageModel = { - ...resolvedModel, - routeParams: { - contactId: '33333333-3333-4333-8333-333333333333', - id: '11111111-1111-4111-8111-111111111111', - }, - target: targetFixture('contacts.core.page-contact-detail', 'contacts.core.page.contact-detail'), - }; - useLoaderDataMock.mockReturnValue(contactDetailModel); - render(); - - expect(findApprovedVerticalPageClientMock).toHaveBeenCalledWith(contactDetailModel.target); - await waitFor(() => expect(loadRemotePageMock).toHaveBeenCalledTimes(1)); - expect(remotePropsMock).toHaveBeenCalledWith({ - routeParams: contactDetailModel.routeParams, - target: contactDetailModel.target, - }); -}); - -test('passes ContactEdit both hierarchical IDs and the resolved fail-closed target', async () => { - const contactEditModel: ResolvedPageModel = { - ...resolvedModel, - routeParams: { - contactId: '33333333-3333-4333-8333-333333333333', - id: '11111111-1111-4111-8111-111111111111', - }, - target: targetFixture( - 'contacts.core.page-contact-edit', - 'contacts.core.page.contact-edit', - false, - ), - }; - useLoaderDataMock.mockReturnValue(contactEditModel); - render(); - - expect(findApprovedVerticalPageClientMock).toHaveBeenCalledWith(contactEditModel.target); - await waitFor(() => expect(loadRemotePageMock).toHaveBeenCalledTimes(1)); - expect(remotePropsMock).toHaveBeenCalledWith({ - routeParams: contactEditModel.routeParams, - target: contactEditModel.target, - }); -}); - -test('passes CustomerEdit its exact ID and fail-closed writable target', async () => { - const customerEditModel: ResolvedPageModel = { - ...resolvedModel, - routeParams: { id: 'customer-1' }, - target: targetFixture( - 'contacts.core.page-customer-edit', - 'contacts.core.page.customer-edit', - false, - ), - }; - useLoaderDataMock.mockReturnValue(customerEditModel); - render(); - - expect(findApprovedVerticalPageClientMock).toHaveBeenCalledWith(customerEditModel.target); - await waitFor(() => expect(loadRemotePageMock).toHaveBeenCalledTimes(1)); - expect(remotePropsMock).toHaveBeenCalledWith({ - routeParams: { id: 'customer-1' }, - target: customerEditModel.target, - }); - expect(await screen.findByText('contacts.core.page-customer-edit:customer-1')).toBeTruthy(); -}); - -test('passes CustomerCreate its bounded route context and resolved writable target', async () => { - const customerCreateModel: ResolvedPageModel = { - ...resolvedModel, - routeParams: { id: 'untrusted-route-context' }, - target: targetFixture( - 'contacts.core.page-customer-create', - 'contacts.core.page.customer-create', - ), - }; - useLoaderDataMock.mockReturnValue(customerCreateModel); - render(); - - expect(findApprovedVerticalPageClientMock).toHaveBeenCalledWith(customerCreateModel.target); - await waitFor(() => expect(loadRemotePageMock).toHaveBeenCalledTimes(1)); - expect(remotePropsMock).toHaveBeenCalledWith({ - routeParams: { id: 'untrusted-route-context' }, - target: customerCreateModel.target, - }); - expect( - await screen.findByText('contacts.core.page-customer-create:untrusted-route-context'), - ).toBeTruthy(); -}); +test.each(exactPageCases)( + 'loads the approved $componentKey remote once with its exact route context and resolved target', + async ({ componentKey, entrypointKey, renderedText, routeParams, writable }) => { + const exactModel: ResolvedPageModel = { + ...resolvedModel, + routeParams, + target: targetFixture(componentKey, entrypointKey, writable), + }; + useLoaderDataMock.mockReturnValue(exactModel); -test('passes ContactCreate its exact ID and fail-closed writable target', async () => { - const contactCreateModel: ResolvedPageModel = { - ...resolvedModel, - routeParams: { id: '11111111-1111-4111-8111-111111111111' }, - target: targetFixture( - 'contacts.core.page-contact-create', - 'contacts.core.page.contact-create', - false, - ), - }; - useLoaderDataMock.mockReturnValue(contactCreateModel); - render(); + render(); - expect(findApprovedVerticalPageClientMock).toHaveBeenCalledWith(contactCreateModel.target); - await waitFor(() => expect(loadRemotePageMock).toHaveBeenCalledTimes(1)); - expect(remotePropsMock).toHaveBeenCalledWith({ - routeParams: { id: '11111111-1111-4111-8111-111111111111' }, - target: contactCreateModel.target, - }); - expect( - await screen.findByText( - 'contacts.core.page-contact-create:11111111-1111-4111-8111-111111111111', - ), - ).toBeTruthy(); -}); + expect(findApprovedVerticalPageClientMock).toHaveBeenCalledWith(exactModel.target); + await waitFor(() => expect(loadRemotePageMock).toHaveBeenCalledTimes(1)); + expect(remotePropsMock).toHaveBeenCalledWith({ routeParams, target: exactModel.target }); + expect(await screen.findByText(renderedText)).toBeTruthy(); + }, +); diff --git a/app/docs/quality-audit.md b/app/docs/quality-audit.md index 578bb8100..bf06a7209 100644 --- a/app/docs/quality-audit.md +++ b/app/docs/quality-audit.md @@ -8,6 +8,9 @@ mise exec -- pnpm quality:audit --tool knip mise exec -- pnpm quality:audit --tool jscpd mise exec -- pnpm quality:audit --tool fallow mise exec -- pnpm quality:audit:test +# Enforce only after a fresh full audit (not --tool): +mise exec -- pnpm quality:audit +mise exec -- pnpm quality:audit:gate ``` The audit reports findings. Existing unused-code, duplication, and complexity findings do not fail the audit command. Missing tools, invalid reports, configuration failures, or an empty analysis are failures and retain diagnostics. A successful report does not prove every reported item should be removed or extracted. @@ -16,7 +19,13 @@ The runner verifies each installed analyzer and invokes its package's JavaScript The default output is `.codex/reports/quality-audit/`. Use `--output ` to select another directory outside the configured source roots. An output such as `scripts/reports`, including a symlink that resolves there, fails before analyzer snapshots are written, so reports cannot become source inputs. Read `summary.md` for the result, `summary.json` for structured status, and the raw analyzer reports and stderr for evidence. Reports are generated artifacts and should not be committed as an accepted baseline. Local run artifacts remain available until the user removes them; this deliberately retains review evidence. The runner does not automatically delete an arbitrary directory supplied through `--output`. -The separate **Quality Audit Reports** workflow publishes reports on pull requests and pushes to `main` and `stage`; it can also be run manually. It is not added to branch-required checks or stage deployment prerequisites in this rollout. Existing formatting, lint, type, architecture, and behavioral gates retain their current behavior. The audit does not install or activate local Git hooks. CI installs dependencies with `--ignore-scripts` to avoid lifecycle-script mutations while collecting reports. +The separate **Quality Audit** workflow runs the full audit, enforces calibrated guardrails, and publishes reports on pull requests and pushes to `main` and `stage`; it can also be run manually. It is not added to branch-required checks or stage deployment prerequisites in this rollout. Existing formatting, lint, type, architecture, and behavioral gates retain their current behavior. The audit does not install or activate local Git hooks. CI installs dependencies with `--ignore-scripts` to avoid lifecycle-script mutations while collecting reports. Summary publication and artifact upload run even when the audit or gate fails. + +## Enforced guardrails + +`quality:audit` remains report-only. `quality:audit:gate` reads the resulting `summary.json` and fails for any calibrated Knip finding, JSCPD token clone pair, Fallow strict clone group, or normalized control-flow health finding. Fallow semantic similarity and UI-only weighted cognitive findings remain advisory. The gate consumes the runner's normalization; it does not recompute analyzer findings or treat native Knip modeled usages as debt. + +The gate requires all six unique expected analyzer results, reported statuses, empty error diagnostics, valid nonnegative integer counts, nonempty analysis coverage, and consistent normalized totals. Missing, duplicate, unknown, malformed, failed, or partial `--tool` summaries cannot pass. The preceding full audit establishes freshness; no timestamp, hash receipt, or accepted-debt baseline is involved. For a custom audit output, pass `quality:audit:gate --summary /summary.json`. ## What each report answers diff --git a/app/package.json b/app/package.json index 95d510e01..b4a119ebb 100644 --- a/app/package.json +++ b/app/package.json @@ -24,7 +24,7 @@ "local:initialize": "node ./scripts/initialize-local-development.mts", "test:unit": "pnpm -r --if-present run test:unit && pnpm -r --if-present run test:component", "test:integration": "pnpm -r --if-present run test:integration", - "test:scripts": "node --test scripts/local-environment-values.test.mts scripts/tests/audit-database-trust-boundaries.test.mts scripts/tests/authorization-rollout-contract.test.mts scripts/tests/check-authorization-readiness.test.mts scripts/tests/database-access-boundaries.test.mts scripts/tests/initialize-local-development.test.mts scripts/tests/locki-feature.test.mts scripts/tests/migrate-contacts-authorization.test.mts scripts/tests/module-entrypoint-boundaries.test.mts scripts/tests/plan-deployment-impact.test.mts scripts/tests/protected-entrypoint-inventory.test.mts scripts/tests/provision-current-action-authorization.test.mts scripts/tests/report-fail-closed-authorization-impact.test.mts scripts/tests/api-only-tooling.test.mts scripts/tests/root-environment.test.mts scripts/tests/typecheck-project-references.test.mts scripts/tests/ultramodern-command.test.mts", + "test:scripts": "node --test scripts/tests/boundary-source-structure.test.mts scripts/local-environment-values.test.mts scripts/tests/audit-database-trust-boundaries.test.mts scripts/tests/authorization-rollout-contract.test.mts scripts/tests/check-authorization-readiness.test.mts scripts/tests/database-access-boundaries.test.mts scripts/tests/initialize-local-development.test.mts scripts/tests/locki-feature.test.mts scripts/tests/migrate-contacts-authorization.test.mts scripts/tests/module-entrypoint-boundaries.test.mts scripts/tests/plan-deployment-impact.test.mts scripts/tests/protected-entrypoint-inventory.test.mts scripts/tests/provision-current-action-authorization.test.mts scripts/tests/report-fail-closed-authorization-impact.test.mts scripts/tests/api-only-tooling.test.mts scripts/tests/root-environment.test.mts scripts/tests/typecheck-project-references.test.mts scripts/tests/ultramodern-command.test.mts", "test:lint-rules": "node --test tools/oxlint/effect-native/tests/*.test.mts", "typecheck:lint-rules": "tsc -p tools/oxlint/effect-native/tsconfig.json", "lint:effect": "node tools/oxlint/effect-native/report.mts", @@ -67,7 +67,7 @@ "module-entrypoints:check": "node ./scripts/check-module-entrypoint-boundaries.mts", "check:module-contracts": "node ./scripts/check-ontos-module-contracts.mts", "typecheck": "node ./scripts/ultramodern-typecheck.mts --build tsconfig.json", - "check": "pnpm format:check && pnpm typecheck:lint-rules && pnpm test:lint-rules && pnpm lint && pnpm action:test:unit && pnpm typecheck && pnpm skills:check && pnpm i18n:boundaries && pnpm api:check && pnpm database-access:check && pnpm module-entrypoints:check && pnpm check:module-contracts && pnpm contract:check && pnpm performance:readiness", + "check": "pnpm format:check && pnpm typecheck:lint-rules && pnpm test:lint-rules && pnpm lint && pnpm action:test:unit && pnpm typecheck && pnpm skills:check && pnpm i18n:boundaries && pnpm api:check && pnpm database-access:check && pnpm module-entrypoints:check && pnpm check:module-contracts && pnpm contract:check && pnpm performance:readiness && pnpm quality:check", "database-access:check": "node ./scripts/check-database-access-boundaries.mts", "format": "oxfmt . '!repos/**'", "format:check": "oxfmt --check . '!repos/**'", @@ -85,7 +85,9 @@ "authorization:impact:report": "node ./scripts/report-fail-closed-authorization-impact.mts", "authorization:readiness:check": "node ./scripts/check-authorization-readiness.mts", "quality:audit": "node ./scripts/quality-audit.mts", - "quality:audit:test": "node --test ./scripts/tests/quality-audit.test.mts ./scripts/tests/quality-audit-model.test.mts ./scripts/tests/quality-audit-runtime-model.test.mts" + "quality:audit:gate": "node ./scripts/quality-audit-gate.mts", + "quality:check": "pnpm quality:audit && pnpm quality:audit:gate", + "quality:audit:test": "node --test ./scripts/tests/quality-audit.test.mts ./scripts/tests/quality-audit-model.test.mts ./scripts/tests/quality-audit-runtime-model.test.mts ./scripts/tests/quality-audit-gate.test.mts" }, "dependencies": { "@authzed/authzed-node": "1.6.1", diff --git a/app/packages/core-runtime/scripts/verify-db-schema.mts b/app/packages/core-runtime/scripts/verify-db-schema.mts index 8e2cea252..4d57569e2 100644 --- a/app/packages/core-runtime/scripts/verify-db-schema.mts +++ b/app/packages/core-runtime/scripts/verify-db-schema.mts @@ -1,6 +1,6 @@ import type { EffectDrizzleQueryError } from 'drizzle-orm/effect-core'; // @effect-diagnostics processEnv:off globalConsole:off strictEffectProvide:off -- Existing compatibility boundary; expires: 2026-12-31. -import { sql } from 'drizzle-orm'; +import { getTableName, sql } from 'drizzle-orm'; import { Effect, Layer, Schema } from 'effect'; import type { CatalogEntry } from '../src/db/catalog.ts'; import { compareApplicationCatalog } from '../src/db/catalog.ts'; @@ -314,55 +314,29 @@ const verifyDatabase = Effect.gen(function* verifyDatabaseEffect() { }); } const typedQueries = [ - verifyTypedQuery('tenants', () => database.executor.select().from(tenants).limit(0)), - verifyTypedQuery('legal_entities', () => - database.executor.select().from(legalEntities).limit(0), - ), - verifyTypedQuery('principals', () => database.executor.select().from(principals).limit(0)), - verifyTypedQuery('principal_auth_bindings', () => - database.executor.select().from(principalAuthBindings).limit(0), - ), - verifyTypedQuery('tenant_module_states', () => - database.executor.select().from(tenantModuleStates).limit(0), - ), - verifyTypedQuery('action_invocations', () => - database.executor.select().from(actionInvocations).limit(0), - ), - verifyTypedQuery('tenant_module_state_changes', () => - database.executor.select().from(tenantModuleStateChanges).limit(0), - ), - verifyTypedQuery('audit_events', () => database.executor.select().from(auditEvents).limit(0)), - verifyTypedQuery('data_access_events', () => - database.executor.select().from(dataAccessEvents).limit(0), - ), - verifyTypedQuery('domain_events', () => database.executor.select().from(domainEvents).limit(0)), - verifyTypedQuery('outbox_messages', () => - database.executor.select().from(outboxMessages).limit(0), - ), - verifyTypedQuery('outbox_deliveries', () => - database.executor.select().from(outboxDeliveries).limit(0), - ), - verifyTypedQuery('outbox_attempts', () => - database.executor.select().from(outboxAttempts).limit(0), - ), - verifyTypedQuery('media_assets', () => database.executor.select().from(mediaAssets).limit(0)), - verifyTypedQuery('media_links', () => database.executor.select().from(mediaLinks).limit(0)), - verifyTypedQuery('evidence_references', () => - database.executor.select().from(evidenceReferences).limit(0), - ), - verifyTypedQuery('search_index_entries', () => - database.executor.select().from(searchIndexEntries).limit(0), - ), - verifyTypedQuery('search_projection_generations', () => - database.executor.select().from(searchProjectionGenerations).limit(0), - ), - verifyTypedQuery('search_projection_rebuilds', () => - database.executor.select().from(searchProjectionRebuilds).limit(0), - ), - verifyTypedQuery('worker_checkpoints', () => - database.executor.select().from(workerCheckpoints).limit(0), - ), - ] as const; + tenants, + legalEntities, + principals, + principalAuthBindings, + tenantModuleStates, + actionInvocations, + tenantModuleStateChanges, + auditEvents, + dataAccessEvents, + domainEvents, + outboxMessages, + outboxDeliveries, + outboxAttempts, + mediaAssets, + mediaLinks, + evidenceReferences, + searchIndexEntries, + searchProjectionGenerations, + searchProjectionRebuilds, + workerCheckpoints, + ].map((table) => + verifyTypedQuery(getTableName(table), () => database.executor.select().from(table).limit(0)), + ); for (const query of typedQueries) { yield* query; diff --git a/app/packages/core-runtime/src/actions/context.ts b/app/packages/core-runtime/src/actions/context.ts index 350141d1f..733e7474d 100644 --- a/app/packages/core-runtime/src/actions/context.ts +++ b/app/packages/core-runtime/src/actions/context.ts @@ -1,4 +1,10 @@ import { Schema } from 'effect'; +import { + decodedStringBrand, + nonEmptyString, + TargetModuleKeySchema, + TargetResourceIdSchema, +} from './string-schemas.ts'; import type { Effect } from 'effect'; import type { DataAccessEventInput, @@ -13,24 +19,9 @@ import type { OperationalScope } from '../operations/context.ts'; export { TrustedPrincipalContextSchema } from './principal-context.ts'; export type { TrustedPrincipalContext } from './principal-context.ts'; -const nonEmptyString = Schema.String.check(Schema.isMinLength(1)); -const CorrelationIdSchema = nonEmptyString.pipe( - Schema.brand('CorrelationId'), - Schema.decodeTo(Schema.String), -); -const IdempotencyKeySchema = nonEmptyString.pipe( - Schema.brand('IdempotencyKey'), - Schema.decodeTo(Schema.String), -); -const TargetModuleKeySchema = nonEmptyString.pipe( - Schema.brand('TargetModuleKey'), - Schema.decodeTo(Schema.String), -); -const TargetResourceIdSchema = nonEmptyString.pipe( - Schema.brand('TargetResourceId'), - Schema.decodeTo(Schema.String), -); -const TraceIdSchema = nonEmptyString.pipe(Schema.brand('TraceId'), Schema.decodeTo(Schema.String)); +const CorrelationIdSchema = decodedStringBrand(nonEmptyString, 'CorrelationId'); +const IdempotencyKeySchema = decodedStringBrand(nonEmptyString, 'IdempotencyKey'); +const TraceIdSchema = decodedStringBrand(nonEmptyString, 'TraceId'); export const ActionTransportMetadataSchema = Schema.Struct({ correlationId: CorrelationIdSchema, diff --git a/app/packages/core-runtime/src/actions/error-schema.ts b/app/packages/core-runtime/src/actions/error-schema.ts new file mode 100644 index 000000000..819e78eb2 --- /dev/null +++ b/app/packages/core-runtime/src/actions/error-schema.ts @@ -0,0 +1,13 @@ +import type { Cause } from 'effect'; +import { Schema } from 'effect'; + +export const actionErrorSchema = < + const Tag extends string, + const Fields extends Schema.Struct.Fields, +>( + tag: Tag, + fields: Fields, +) => { + type Contract = Schema.TaggedStruct; + return Schema.TaggedError()(tag, fields); +}; diff --git a/app/packages/core-runtime/src/actions/errors.ts b/app/packages/core-runtime/src/actions/errors.ts index 55a79a9cf..5f0398bae 100644 --- a/app/packages/core-runtime/src/actions/errors.ts +++ b/app/packages/core-runtime/src/actions/errors.ts @@ -1,4 +1,5 @@ import { Cause, Schema } from 'effect'; +import { actionErrorSchema } from './error-schema.ts'; import type { ActionTransactionError } from './transaction-error.ts'; import type { ModuleStateCheckUnavailableError, @@ -17,159 +18,75 @@ const ActionInvocationIdSchema = Schema.String.pipe( Schema.decodeTo(Schema.String), ); -const actionPayloadValidationFields = { +const ActionPayloadValidationErrorValue = actionErrorSchema('ActionPayloadValidationError', { code: Schema.Literal('action_payload_invalid'), ...safeReason, -}; -const ActionPayloadValidationErrorContract = Schema.TaggedStruct( - 'ActionPayloadValidationError', - actionPayloadValidationFields, -); -type ActionPayloadValidationErrorSelf = typeof ActionPayloadValidationErrorContract.Type & - Cause.YieldableError; -const ActionPayloadValidationErrorValue = Schema.TaggedError()( - 'ActionPayloadValidationError', - actionPayloadValidationFields, -); +}); export type ActionPayloadValidationError = InstanceType; export { ActionPayloadValidationErrorValue as ActionPayloadValidationError }; -const actionResultValidationFields = { +const ActionResultValidationErrorValue = actionErrorSchema('ActionResultValidationError', { code: Schema.Literal('action_result_invalid'), ...safeReason, -}; -const ActionResultValidationErrorContract = Schema.TaggedStruct( - 'ActionResultValidationError', - actionResultValidationFields, -); -type ActionResultValidationErrorSelf = typeof ActionResultValidationErrorContract.Type & - Cause.YieldableError; -const ActionResultValidationErrorValue = Schema.TaggedError()( - 'ActionResultValidationError', - actionResultValidationFields, -); +}); export type ActionResultValidationError = InstanceType; export { ActionResultValidationErrorValue as ActionResultValidationError }; -const actionTrustedContextValidationFields = { - code: Schema.Literal('action_trusted_context_invalid'), - ...safeReason, -}; -const ActionTrustedContextValidationErrorContract = Schema.TaggedStruct( +const ActionTrustedContextValidationErrorValue = actionErrorSchema( 'ActionTrustedContextValidationError', - actionTrustedContextValidationFields, + { + code: Schema.Literal('action_trusted_context_invalid'), + ...safeReason, + }, ); -type ActionTrustedContextValidationErrorSelf = - typeof ActionTrustedContextValidationErrorContract.Type & Cause.YieldableError; -const ActionTrustedContextValidationErrorValue = - Schema.TaggedError()( - 'ActionTrustedContextValidationError', - actionTrustedContextValidationFields, - ); export type ActionTrustedContextValidationError = InstanceType< typeof ActionTrustedContextValidationErrorValue >; export { ActionTrustedContextValidationErrorValue as ActionTrustedContextValidationError }; -const actionIdempotencyKeyRequiredFields = { +const ActionIdempotencyKeyRequiredValue = actionErrorSchema('ActionIdempotencyKeyRequired', { code: Schema.Literal('action_idempotency_key_required'), ...safeReason, -}; -const ActionIdempotencyKeyRequiredContract = Schema.TaggedStruct( - 'ActionIdempotencyKeyRequired', - actionIdempotencyKeyRequiredFields, -); -type ActionIdempotencyKeyRequiredSelf = typeof ActionIdempotencyKeyRequiredContract.Type & - Cause.YieldableError; -const ActionIdempotencyKeyRequiredValue = Schema.TaggedError()( - 'ActionIdempotencyKeyRequired', - actionIdempotencyKeyRequiredFields, -); +}); export type ActionIdempotencyKeyRequired = InstanceType; export { ActionIdempotencyKeyRequiredValue as ActionIdempotencyKeyRequired }; -const actionPermissionDeniedFields = { +const ActionPermissionDeniedValue = actionErrorSchema('ActionPermissionDenied', { code: Schema.Literal('action_permission_denied'), ...safeReason, -}; -const ActionPermissionDeniedContract = Schema.TaggedStruct( - 'ActionPermissionDenied', - actionPermissionDeniedFields, -); -type ActionPermissionDeniedSelf = typeof ActionPermissionDeniedContract.Type & Cause.YieldableError; -const ActionPermissionDeniedValue = Schema.TaggedError()( - 'ActionPermissionDenied', - actionPermissionDeniedFields, -); +}); export type ActionPermissionDenied = InstanceType; export { ActionPermissionDeniedValue as ActionPermissionDenied }; -const actionPermissionCheckFields = { +const ActionPermissionCheckErrorValue = actionErrorSchema('ActionPermissionCheckError', { code: Schema.Literal('action_permission_check_failed'), ...safeReason, -}; -const ActionPermissionCheckErrorContract = Schema.TaggedStruct( - 'ActionPermissionCheckError', - actionPermissionCheckFields, -); -type ActionPermissionCheckErrorSelf = typeof ActionPermissionCheckErrorContract.Type & - Cause.YieldableError; -const ActionPermissionCheckErrorValue = Schema.TaggedError()( - 'ActionPermissionCheckError', - actionPermissionCheckFields, -); +}); export type ActionPermissionCheckError = InstanceType; export { ActionPermissionCheckErrorValue as ActionPermissionCheckError }; -const actionAlreadyCommittedFields = { +const ActionAlreadyCommittedValue = actionErrorSchema('ActionAlreadyCommitted', { code: Schema.Literal('action_already_committed'), invocationId: ActionInvocationIdSchema, ...safeReason, -}; -const ActionAlreadyCommittedContract = Schema.TaggedStruct( - 'ActionAlreadyCommitted', - actionAlreadyCommittedFields, -); -type ActionAlreadyCommittedSelf = typeof ActionAlreadyCommittedContract.Type & Cause.YieldableError; -const ActionAlreadyCommittedValue = Schema.TaggedError()( - 'ActionAlreadyCommitted', - actionAlreadyCommittedFields, -); +}); export type ActionAlreadyCommitted = InstanceType; export { ActionAlreadyCommittedValue as ActionAlreadyCommitted }; -const actionRequestHashConflictFields = { +const ActionRequestHashConflictValue = actionErrorSchema('ActionRequestHashConflict', { code: Schema.Literal('action_request_hash_conflict'), ...safeReason, -}; -const ActionRequestHashConflictContract = Schema.TaggedStruct( - 'ActionRequestHashConflict', - actionRequestHashConflictFields, -); -type ActionRequestHashConflictSelf = typeof ActionRequestHashConflictContract.Type & - Cause.YieldableError; -const ActionRequestHashConflictValue = Schema.TaggedError()( - 'ActionRequestHashConflict', - actionRequestHashConflictFields, -); +}); export type ActionRequestHashConflict = InstanceType; export { ActionRequestHashConflictValue as ActionRequestHashConflict }; -const actionInvocationPersistenceFields = { - code: Schema.Literal('action_invocation_persistence_failed'), - ...safeReason, -}; -const ActionInvocationPersistenceErrorContract = Schema.TaggedStruct( +const ActionInvocationPersistenceErrorValue = actionErrorSchema( 'ActionInvocationPersistenceError', - actionInvocationPersistenceFields, + { + code: Schema.Literal('action_invocation_persistence_failed'), + ...safeReason, + }, ); -type ActionInvocationPersistenceErrorSelf = typeof ActionInvocationPersistenceErrorContract.Type & - Cause.YieldableError; -const ActionInvocationPersistenceErrorValue = - Schema.TaggedError()( - 'ActionInvocationPersistenceError', - actionInvocationPersistenceFields, - ); export type ActionInvocationPersistenceError = InstanceType< typeof ActionInvocationPersistenceErrorValue >; @@ -205,122 +122,54 @@ export const createActionInvocationPersistenceErrorWithCause = export const getActionInvocationPersistenceErrorCause = ActionInvocationPersistenceErrorInternals.readCause; -const actionInvocationNotFoundFields = { +const ActionInvocationNotFoundValue = actionErrorSchema('ActionInvocationNotFound', { code: Schema.Literal('action_invocation_not_found'), ...safeReason, -}; -const ActionInvocationNotFoundContract = Schema.TaggedStruct( - 'ActionInvocationNotFound', - actionInvocationNotFoundFields, -); -type ActionInvocationNotFoundSelf = typeof ActionInvocationNotFoundContract.Type & - Cause.YieldableError; -const ActionInvocationNotFoundValue = Schema.TaggedError()( - 'ActionInvocationNotFound', - actionInvocationNotFoundFields, -); +}); export type ActionInvocationNotFound = InstanceType; export { ActionInvocationNotFoundValue as ActionInvocationNotFound }; -const actionInvocationStateFields = { +const ActionInvocationStateErrorValue = actionErrorSchema('ActionInvocationStateError', { code: Schema.Literal('action_invocation_state_invalid'), ...safeReason, -}; -const ActionInvocationStateErrorContract = Schema.TaggedStruct( - 'ActionInvocationStateError', - actionInvocationStateFields, -); -type ActionInvocationStateErrorSelf = typeof ActionInvocationStateErrorContract.Type & - Cause.YieldableError; -const ActionInvocationStateErrorValue = Schema.TaggedError()( - 'ActionInvocationStateError', - actionInvocationStateFields, -); +}); export type ActionInvocationStateError = InstanceType; export { ActionInvocationStateErrorValue as ActionInvocationStateError }; -const actionCollectorFields = { +const ActionCollectorErrorValue = actionErrorSchema('ActionCollectorError', { code: Schema.Literal('action_collector_invalid'), ...safeReason, -}; -const ActionCollectorErrorContract = Schema.TaggedStruct( - 'ActionCollectorError', - actionCollectorFields, -); -type ActionCollectorErrorSelf = typeof ActionCollectorErrorContract.Type & Cause.YieldableError; -const ActionCollectorErrorValue = Schema.TaggedError()( - 'ActionCollectorError', - actionCollectorFields, -); +}); export type ActionCollectorError = InstanceType; export { ActionCollectorErrorValue as ActionCollectorError }; -const actionHandlerExecutionFields = { +const ActionHandlerExecutionErrorValue = actionErrorSchema('ActionHandlerExecutionError', { code: Schema.Literal('action_handler_execution_failed'), ...safeReason, -}; -const ActionHandlerExecutionErrorContract = Schema.TaggedStruct( - 'ActionHandlerExecutionError', - actionHandlerExecutionFields, -); -type ActionHandlerExecutionErrorSelf = typeof ActionHandlerExecutionErrorContract.Type & - Cause.YieldableError; -const ActionHandlerExecutionErrorValue = Schema.TaggedError()( - 'ActionHandlerExecutionError', - actionHandlerExecutionFields, -); +}); export type ActionHandlerExecutionError = InstanceType; export { ActionHandlerExecutionErrorValue as ActionHandlerExecutionError }; -const actionPolicyDeniedFields = { +const ActionPolicyDeniedValue = actionErrorSchema('ActionPolicyDenied', { code: Schema.Literal('action_policy_denied'), policyReasonCode: Schema.String, ...safeReason, -}; -const ActionPolicyDeniedContract = Schema.TaggedStruct( - 'ActionPolicyDenied', - actionPolicyDeniedFields, -); -type ActionPolicyDeniedSelf = typeof ActionPolicyDeniedContract.Type & Cause.YieldableError; -const ActionPolicyDeniedValue = Schema.TaggedError()( - 'ActionPolicyDenied', - actionPolicyDeniedFields, -); +}); export type ActionPolicyDenied = InstanceType; export { ActionPolicyDeniedValue as ActionPolicyDenied }; -const actionPolicyEvaluationFields = { +const ActionPolicyEvaluationErrorValue = actionErrorSchema('ActionPolicyEvaluationError', { code: Schema.Literal('action_policy_evaluation_failed'), ...safeReason, -}; -const ActionPolicyEvaluationErrorContract = Schema.TaggedStruct( - 'ActionPolicyEvaluationError', - actionPolicyEvaluationFields, -); -type ActionPolicyEvaluationErrorSelf = typeof ActionPolicyEvaluationErrorContract.Type & - Cause.YieldableError; -const ActionPolicyEvaluationErrorValue = Schema.TaggedError()( - 'ActionPolicyEvaluationError', - actionPolicyEvaluationFields, -); +}); export type ActionPolicyEvaluationError = InstanceType; export { ActionPolicyEvaluationErrorValue as ActionPolicyEvaluationError }; -const actionCommitIndeterminateFields = { +const ActionCommitIndeterminateValue = actionErrorSchema('ActionCommitIndeterminate', { code: Schema.Literal('action_commit_indeterminate'), invocationId: ActionInvocationIdSchema, ...safeReason, -}; -const ActionCommitIndeterminateContract = Schema.TaggedStruct( - 'ActionCommitIndeterminate', - actionCommitIndeterminateFields, -); -type ActionCommitIndeterminateSelf = typeof ActionCommitIndeterminateContract.Type & - Cause.YieldableError; -const ActionCommitIndeterminateValue = Schema.TaggedError()( - 'ActionCommitIndeterminate', - actionCommitIndeterminateFields, -); +}); export type ActionCommitIndeterminate = InstanceType; export { ActionCommitIndeterminateValue as ActionCommitIndeterminate }; diff --git a/app/packages/core-runtime/src/actions/events.ts b/app/packages/core-runtime/src/actions/events.ts index ff2438d81..a3fa824aa 100644 --- a/app/packages/core-runtime/src/actions/events.ts +++ b/app/packages/core-runtime/src/actions/events.ts @@ -1,35 +1,17 @@ import { Schema } from 'effect'; +import { + decodedStringBrand, + nonEmptyString, + TargetModuleKeySchema, + TargetResourceIdSchema, +} from './string-schemas.ts'; -const nonEmptyString = Schema.String.check(Schema.isMinLength(1)); const nonNegativeInteger = Schema.Finite.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)); -const EvidencePolicyKeySchema = nonEmptyString.pipe( - Schema.brand('EvidencePolicyKey'), - Schema.decodeTo(Schema.String), -); -const ProducerModuleKeySchema = nonEmptyString.pipe( - Schema.brand('ProducerModuleKey'), - Schema.decodeTo(Schema.String), -); -const ServingModuleKeySchema = nonEmptyString.pipe( - Schema.brand('ServingModuleKey'), - Schema.decodeTo(Schema.String), -); -const SubjectModuleKeySchema = nonEmptyString.pipe( - Schema.brand('SubjectModuleKey'), - Schema.decodeTo(Schema.String), -); -const SubjectResourceIdSchema = nonEmptyString.pipe( - Schema.brand('SubjectResourceId'), - Schema.decodeTo(Schema.String), -); -const TargetModuleKeySchema = nonEmptyString.pipe( - Schema.brand('TargetModuleKey'), - Schema.decodeTo(Schema.String), -); -const TargetResourceIdSchema = nonEmptyString.pipe( - Schema.brand('TargetResourceId'), - Schema.decodeTo(Schema.String), -); +const EvidencePolicyKeySchema = decodedStringBrand(nonEmptyString, 'EvidencePolicyKey'); +const ProducerModuleKeySchema = decodedStringBrand(nonEmptyString, 'ProducerModuleKey'); +const ServingModuleKeySchema = decodedStringBrand(nonEmptyString, 'ServingModuleKey'); +const SubjectModuleKeySchema = decodedStringBrand(nonEmptyString, 'SubjectModuleKey'); +const SubjectResourceIdSchema = decodedStringBrand(nonEmptyString, 'SubjectResourceId'); export type DomainEventContractMap = Readonly>>; diff --git a/app/packages/core-runtime/src/actions/principal-context.ts b/app/packages/core-runtime/src/actions/principal-context.ts index 9fbc8131a..ea007f317 100644 --- a/app/packages/core-runtime/src/actions/principal-context.ts +++ b/app/packages/core-runtime/src/actions/principal-context.ts @@ -1,21 +1,12 @@ import { Schema } from 'effect'; +import { decodedStringBrand, nonEmptyString } from './string-schemas.ts'; const uuid = Schema.String.check(Schema.isUUID()); -const nonEmptyString = Schema.String.check(Schema.isMinLength(1)); -const AuthBindingIdSchema = uuid.pipe( - Schema.brand('AuthBindingId'), - Schema.decodeTo(Schema.String), -); -const ImpersonatedByPrincipalIdSchema = uuid.pipe( - Schema.brand('ImpersonatedByPrincipalId'), - Schema.decodeTo(Schema.String), -); -const LegalEntityIdSchema = uuid.pipe( - Schema.brand('LegalEntityId'), - Schema.decodeTo(Schema.String), -); -const PrincipalIdSchema = uuid.pipe(Schema.brand('PrincipalId'), Schema.decodeTo(Schema.String)); -const TenantIdSchema = uuid.pipe(Schema.brand('TenantId'), Schema.decodeTo(Schema.String)); +const AuthBindingIdSchema = decodedStringBrand(uuid, 'AuthBindingId'); +const ImpersonatedByPrincipalIdSchema = decodedStringBrand(uuid, 'ImpersonatedByPrincipalId'); +const LegalEntityIdSchema = decodedStringBrand(uuid, 'LegalEntityId'); +const PrincipalIdSchema = decodedStringBrand(uuid, 'PrincipalId'); +const TenantIdSchema = decodedStringBrand(uuid, 'TenantId'); const TrustedPrincipalContextFieldsSchema = Schema.Struct({ authBindingId: Schema.optionalKey(AuthBindingIdSchema), diff --git a/app/packages/core-runtime/src/actions/repository.ts b/app/packages/core-runtime/src/actions/repository.ts index d72b133f9..4a18837b3 100644 --- a/app/packages/core-runtime/src/actions/repository.ts +++ b/app/packages/core-runtime/src/actions/repository.ts @@ -164,7 +164,7 @@ const markInvocationRejected = Effect.fnUntraced(function* markInvocationRejecte actionInvocationId: string, ) { const completedAt = yield* DateTime.nowAsDate; - return yield* transaction + const rejected = yield* transaction .update(actionInvocations) .set({ completedAt, status: 'rejected' }) .where( @@ -175,6 +175,12 @@ const markInvocationRejected = Effect.fnUntraced(function* markInvocationRejecte ), ) .returning({ actionInvocationId: actionInvocations.actionInvocationId }); + if (rejected.length !== 1) { + return yield* new RepositoryInvariantError({ + reason: 'The Action invocation could not be marked rejected', + }); + } + return yield* Effect.void; }); export const computeActionRequestHash = (input: ActionRequestHashInput): string => { @@ -599,17 +605,9 @@ export const makeActionRepository = (): ActionRepositoryService => { }) .pipe(Effect.mapError((cause) => transactionFailure(failureReason, cause))); - const rejected = yield* markInvocationRejected(transaction, input.actionInvocationId).pipe( + yield* markInvocationRejected(transaction, input.actionInvocationId).pipe( Effect.mapError((cause) => transactionFailure(failureReason, cause)), ); - if (rejected.length !== 1) { - return yield* transactionFailure( - failureReason, - new RepositoryInvariantError({ - reason: 'The Action invocation could not be marked rejected', - }), - ); - } return yield* Effect.void; }, ); @@ -673,33 +671,14 @@ export const makeActionRepository = (): ActionRepositoryService => { ); yield* transaction .insert(auditEvents) - .values([ - { - actionInvocationId: input.actionInvocationId, - auditProfile: input.auditProfile, - authBindingId: input.principal.authBindingId, - authContextRef: input.principal.authContextRef, - authMethod: input.principal.authMethod, - eventType: 'action.policy_checked', - evidenceJson: policyEvidence, - impersonatedByPrincipalId: input.principal.impersonatedByPrincipalId, - legalEntityId: input.principal.legalEntityId, - outcome: 'denied', - outcomeCode: input.reasonCode, - outcomeStage: 'policy', - principalId: input.principal.principalId, - targetModuleKey: input.transport.targetModuleKey, - targetResourceId: input.transport.targetResourceId, - targetResourceType: input.transport.targetResourceType, - tenantId: input.principal.tenantId, - }, - { + .values( + ['action.policy_checked', 'action.rejected'].map((eventType) => ({ actionInvocationId: input.actionInvocationId, auditProfile: input.auditProfile, authBindingId: input.principal.authBindingId, authContextRef: input.principal.authContextRef, authMethod: input.principal.authMethod, - eventType: 'action.rejected', + eventType, evidenceJson: policyEvidence, impersonatedByPrincipalId: input.principal.impersonatedByPrincipalId, legalEntityId: input.principal.legalEntityId, @@ -711,21 +690,13 @@ export const makeActionRepository = (): ActionRepositoryService => { targetResourceId: input.transport.targetResourceId, targetResourceType: input.transport.targetResourceType, tenantId: input.principal.tenantId, - }, - ]) + })), + ) .pipe(Effect.mapError((cause) => persistenceFailure(failureReason, cause))); - const rejected = yield* markInvocationRejected(transaction, input.actionInvocationId).pipe( + yield* markInvocationRejected(transaction, input.actionInvocationId).pipe( Effect.mapError((cause) => persistenceFailure(failureReason, cause)), ); - if (rejected.length !== 1) { - return yield* persistenceFailure( - failureReason, - new RepositoryInvariantError({ - reason: 'The Action invocation could not be marked rejected', - }), - ); - } return yield* Effect.void; }, ); diff --git a/app/packages/core-runtime/src/actions/string-schemas.ts b/app/packages/core-runtime/src/actions/string-schemas.ts new file mode 100644 index 000000000..5a60e8183 --- /dev/null +++ b/app/packages/core-runtime/src/actions/string-schemas.ts @@ -0,0 +1,9 @@ +import { Schema } from 'effect'; + +export const nonEmptyString = Schema.String.check(Schema.isMinLength(1)); + +export const decodedStringBrand = (schema: Schema.String, brand: Brand) => + schema.pipe(Schema.brand(brand), Schema.decodeTo(Schema.String)); + +export const TargetModuleKeySchema = decodedStringBrand(nonEmptyString, 'TargetModuleKey'); +export const TargetResourceIdSchema = decodedStringBrand(nonEmptyString, 'TargetResourceId'); diff --git a/app/packages/core-runtime/src/actions/transaction-error.ts b/app/packages/core-runtime/src/actions/transaction-error.ts index 3a6843fa2..4be1c7ee6 100644 --- a/app/packages/core-runtime/src/actions/transaction-error.ts +++ b/app/packages/core-runtime/src/actions/transaction-error.ts @@ -1,18 +1,10 @@ import { Cause, Schema } from 'effect'; +import { actionErrorSchema } from './error-schema.ts'; -const actionTransactionFields = { +const ActionTransactionErrorValue = actionErrorSchema('ActionTransactionError', { code: Schema.Literal('action_transaction_failed'), reason: Schema.String, -}; -const ActionTransactionErrorContract = Schema.TaggedStruct( - 'ActionTransactionError', - actionTransactionFields, -); -type ActionTransactionErrorSelf = typeof ActionTransactionErrorContract.Type & Cause.YieldableError; -const ActionTransactionErrorValue = Schema.TaggedError()( - 'ActionTransactionError', - actionTransactionFields, -); +}); export type ActionTransactionError = InstanceType; const ActionTransactionErrorInternals = (() => { let createWithCause: ( diff --git a/app/packages/core-runtime/src/auth/principal-administration-reads.ts b/app/packages/core-runtime/src/auth/principal-administration-reads.ts index 23663ec8c..037b2e739 100644 --- a/app/packages/core-runtime/src/auth/principal-administration-reads.ts +++ b/app/packages/core-runtime/src/auth/principal-administration-reads.ts @@ -21,12 +21,11 @@ const bindingMetadata = Schema.Struct({ revokedAt: Schema.OptionFromNullOr(Schema.DateTimeUtc), status: BindingStatusSchema, }); -const SelfInput = Schema.Struct(paginationInput); +const PaginationInput = Schema.Struct(paginationInput); const SelfResult = Schema.Struct({ items: Schema.Array(bindingMetadata), nextOffset: Schema.OptionFromNullOr(Schema.Finite), }); -const ManagedInput = Schema.Struct(paginationInput); const ManagedItem = Schema.Struct({ authBindingId: Schema.OptionFromNullOr(AuthBindingIdSchema), bindingCreatedAt: Schema.OptionFromNullOr(Schema.DateTimeUtc), @@ -201,7 +200,7 @@ const services = ( }); export const selfApiKeyBindingsRead = defineRead< - typeof SelfInput, + typeof PaginationInput, typeof SelfResult, 'core.identity', IdentityReadServices, @@ -221,7 +220,7 @@ export const selfApiKeyBindingsRead = defineRead< captureMode: 'metadata_only', policyKey: 'core.identity.self-api-key-bindings.access.v1', }, - inputSchema: SelfInput, + inputSchema: PaginationInput, legalEntityScope: 'optional', owningModuleKey: 'core.identity', permissionTarget: 'tenant', @@ -239,7 +238,7 @@ export const selfApiKeyBindingsRead = defineRead< ); export const managedPrincipalsRead = defineRead< - typeof ManagedInput, + typeof PaginationInput, typeof ManagedResult, 'core.identity', IdentityReadServices, @@ -259,7 +258,7 @@ export const managedPrincipalsRead = defineRead< captureMode: 'metadata_only', policyKey: 'core.identity.managed-principals.access.v1', }, - inputSchema: ManagedInput, + inputSchema: PaginationInput, legalEntityScope: 'optional', owningModuleKey: 'core.identity', permissionTarget: 'tenant', diff --git a/app/packages/core-runtime/src/auth/principal-management.ts b/app/packages/core-runtime/src/auth/principal-management.ts index 0d128b2ff..24f6895f9 100644 --- a/app/packages/core-runtime/src/auth/principal-management.ts +++ b/app/packages/core-runtime/src/auth/principal-management.ts @@ -301,16 +301,12 @@ const changePrincipalStatusFor = (persistence: PrincipalManagementPersistence) = if (target.value.status !== input.expectedStatus) { return yield* conflict('The principal status changed concurrently'); } - if (target.value.status === 'archived' || input.newStatus === target.value.status) { + if (!principalTransitionAllowed(target.value.status, input.newStatus)) { return yield* conflict('The principal status transition is not allowed'); } if (input.newStatus !== 'active' && !hasStatusChangeReason(input.reason)) { return yield* invalid('A reason is required for disable or archive'); } - const allowed = principalTransitionAllowed(target.value.status, input.newStatus); - if (!allowed) { - return yield* conflict('The principal status transition is not allowed'); - } const updated = yield* persistence.updatePrincipalStatus(input); if (Option.isNone(updated)) { return yield* conflict('The principal status changed concurrently'); @@ -422,19 +418,12 @@ const setApiKeyBindingStatusFor = (persistence: PrincipalManagementPersistence) if (binding.value.bindingStatus !== input.expectedStatus) { return yield* conflict('The binding status changed concurrently'); } - if ( - binding.value.bindingStatus === 'revoked' || - binding.value.bindingStatus === input.newStatus - ) { + if (!bindingTransitionAllowed(binding.value.bindingStatus, input.newStatus)) { return yield* conflict('The binding transition is not allowed'); } if (input.newStatus === 'revoked' && !hasStatusChangeReason(input.reason)) { return yield* invalid('A reason is required for revocation'); } - const allowed = bindingTransitionAllowed(binding.value.bindingStatus, input.newStatus); - if (!allowed) { - return yield* conflict('The binding transition is not allowed'); - } const updated = yield* persistence.updateApiKeyBindingStatus(input); if (Option.isNone(updated)) { return yield* conflict('The binding status changed concurrently'); diff --git a/app/packages/core-runtime/src/db/schema.ts b/app/packages/core-runtime/src/db/schema.ts index 59c15c7df..919554755 100644 --- a/app/packages/core-runtime/src/db/schema.ts +++ b/app/packages/core-runtime/src/db/schema.ts @@ -16,6 +16,8 @@ import { uuid, } from 'drizzle-orm/pg-core'; +import type { AnyPgColumn } from 'drizzle-orm/pg-core'; + export const CORE_SCHEMA_NAME = 'core'; export const CORE_TABLE_INVENTORY = [ @@ -233,6 +235,32 @@ export const tenantModuleStates = coreSchema.table( ], ); +const authContextForeignKeys = ( + prefix: string, + table: { + readonly authBindingId: AnyPgColumn; + readonly impersonatedByPrincipalId: AnyPgColumn; + readonly principalId: AnyPgColumn; + readonly tenantId: AnyPgColumn; + }, +) => [ + foreignKey({ + columns: [table.tenantId, table.principalId], + foreignColumns: [principals.tenantId, principals.principalId], + name: `${prefix}_tenant_principal_fk`, + }).onDelete('restrict'), + foreignKey({ + columns: [table.tenantId, table.authBindingId], + foreignColumns: [principalAuthBindings.tenantId, principalAuthBindings.principalAuthBindingId], + name: `${prefix}_tenant_auth_binding_fk`, + }).onDelete('restrict'), + foreignKey({ + columns: [table.tenantId, table.impersonatedByPrincipalId], + foreignColumns: [principals.tenantId, principals.principalId], + name: `${prefix}_tenant_impersonator_fk`, + }).onDelete('restrict'), +]; + export const actionInvocations = coreSchema.table( 'action_invocations', { @@ -275,24 +303,7 @@ export const actionInvocations = coreSchema.table( foreignColumns: [legalEntities.tenantId, legalEntities.legalEntityId], name: 'core_action_invocations_tenant_legal_entity_fk', }).onDelete('restrict'), - foreignKey({ - columns: [table.tenantId, table.principalId], - foreignColumns: [principals.tenantId, principals.principalId], - name: 'core_action_invocations_tenant_principal_fk', - }).onDelete('restrict'), - foreignKey({ - columns: [table.tenantId, table.authBindingId], - foreignColumns: [ - principalAuthBindings.tenantId, - principalAuthBindings.principalAuthBindingId, - ], - name: 'core_action_invocations_tenant_auth_binding_fk', - }).onDelete('restrict'), - foreignKey({ - columns: [table.tenantId, table.impersonatedByPrincipalId], - foreignColumns: [principals.tenantId, principals.principalId], - name: 'core_action_invocations_tenant_impersonator_fk', - }).onDelete('restrict'), + ...authContextForeignKeys('core_action_invocations', table), check( 'core_action_invocations_auth_method_ck', sql`${table.authMethod} is null or ${table.authMethod} in ('session', 'api_key', 'system', 'support_impersonation')`, @@ -384,24 +395,7 @@ export const auditEvents = coreSchema.table( foreignColumns: [actionInvocations.tenantId, actionInvocations.actionInvocationId], name: 'core_audit_events_tenant_invocation_fk', }).onDelete('restrict'), - foreignKey({ - columns: [table.tenantId, table.principalId], - foreignColumns: [principals.tenantId, principals.principalId], - name: 'core_audit_events_tenant_principal_fk', - }).onDelete('restrict'), - foreignKey({ - columns: [table.tenantId, table.authBindingId], - foreignColumns: [ - principalAuthBindings.tenantId, - principalAuthBindings.principalAuthBindingId, - ], - name: 'core_audit_events_tenant_auth_binding_fk', - }).onDelete('restrict'), - foreignKey({ - columns: [table.tenantId, table.impersonatedByPrincipalId], - foreignColumns: [principals.tenantId, principals.principalId], - name: 'core_audit_events_tenant_impersonator_fk', - }).onDelete('restrict'), + ...authContextForeignKeys('core_audit_events', table), check( 'core_audit_events_outcome_ck', sql`${table.outcome} in ('allowed', 'denied', 'succeeded', 'failed')`, @@ -460,24 +454,7 @@ export const dataAccessEvents = coreSchema.table( foreignColumns: [actionInvocations.tenantId, actionInvocations.actionInvocationId], name: 'core_data_access_events_tenant_invocation_fk', }).onDelete('restrict'), - foreignKey({ - columns: [table.tenantId, table.principalId], - foreignColumns: [principals.tenantId, principals.principalId], - name: 'core_data_access_events_tenant_principal_fk', - }).onDelete('restrict'), - foreignKey({ - columns: [table.tenantId, table.authBindingId], - foreignColumns: [ - principalAuthBindings.tenantId, - principalAuthBindings.principalAuthBindingId, - ], - name: 'core_data_access_events_tenant_auth_binding_fk', - }).onDelete('restrict'), - foreignKey({ - columns: [table.tenantId, table.impersonatedByPrincipalId], - foreignColumns: [principals.tenantId, principals.principalId], - name: 'core_data_access_events_tenant_impersonator_fk', - }).onDelete('restrict'), + ...authContextForeignKeys('core_data_access_events', table), check( 'core_data_access_events_outcome_ck', sql`${table.outcome} in ('allowed', 'denied', 'failed')`, diff --git a/app/packages/core-runtime/src/index.ts b/app/packages/core-runtime/src/index.ts index 3dd91839f..bd4e2bb7b 100644 --- a/app/packages/core-runtime/src/index.ts +++ b/app/packages/core-runtime/src/index.ts @@ -613,13 +613,11 @@ export type { export { OutboxClaimLostError, OutboxHandlerExecutionError, - OutboxModuleStateError, OutboxPayloadDecodeError, OutboxPollerConfigError, OutboxPersistenceError, OutboxWorkerDescriptorError, } from './outbox/errors.ts'; -export type { OutboxWorkerError } from './outbox/errors.ts'; export type { OutboxWorkerHealth, OutboxWorkerHealthServer } from './outbox/health.ts'; export { parseOutboxPollingConfig, runOutboxPollingLoop } from './outbox/poller.ts'; export type { diff --git a/app/packages/core-runtime/src/modules/actions/bind-managed-api-key.action.ts b/app/packages/core-runtime/src/modules/actions/bind-managed-api-key.action.ts index 2589d9154..4c6cca535 100644 --- a/app/packages/core-runtime/src/modules/actions/bind-managed-api-key.action.ts +++ b/app/packages/core-runtime/src/modules/actions/bind-managed-api-key.action.ts @@ -26,14 +26,11 @@ const BindManagedApiKeyResultSchema = Schema.Struct({ authBindingId: AuthBindingIdSchema, status: Schema.Literal('active'), }); -type BindApiKey = PrincipalManagementRepositoryService['bindApiKey']; -type Input = Parameters[0]; -type Result = ReturnType; const handle = Effect.fn('BindManagedApiKeyAction.handle')(function* bindManagedApiKeyActionHandle( payload: BindManagedApiKeyPayload, context: ActionHandlerContext< Readonly>, - { readonly bind: (input: Input) => Result } + { readonly bind: PrincipalManagementRepositoryService['bindApiKey'] } >, ) { const result = yield* context.services.bind({ diff --git a/app/packages/core-runtime/src/modules/actions/change-principal-status.action.ts b/app/packages/core-runtime/src/modules/actions/change-principal-status.action.ts index 345808b18..21558ed78 100644 --- a/app/packages/core-runtime/src/modules/actions/change-principal-status.action.ts +++ b/app/packages/core-runtime/src/modules/actions/change-principal-status.action.ts @@ -30,15 +30,12 @@ export type ChangePrincipalStatusPayload = Schema.Schema.Type< typeof ChangePrincipalStatusPayloadSchema >; const ChangePrincipalStatusResultSchema = Schema.Struct({ previousStatus: status, status }); -type ChangePrincipalStatus = PrincipalManagementRepositoryService['changePrincipalStatus']; -type Input = Parameters[0]; -type Result = ReturnType; const handle = Effect.fn('ChangePrincipalStatusAction.handle')( function* changePrincipalStatusActionHandle( payload: ChangePrincipalStatusPayload, context: ActionHandlerContext< Readonly>, - { readonly change: (input: Input) => Result } + { readonly change: PrincipalManagementRepositoryService['changePrincipalStatus'] } >, ) { const result = yield* context.services.change({ ...payload, tenantId: context.scope.tenantId }); diff --git a/app/packages/core-runtime/src/modules/actions/create-non-human-principal.action.ts b/app/packages/core-runtime/src/modules/actions/create-non-human-principal.action.ts index 43ec75b36..4a950f315 100644 --- a/app/packages/core-runtime/src/modules/actions/create-non-human-principal.action.ts +++ b/app/packages/core-runtime/src/modules/actions/create-non-human-principal.action.ts @@ -23,15 +23,12 @@ const CreateNonHumanPrincipalResultSchema = Schema.Struct({ principalId: PrincipalIdSchema, status: Schema.Literal('active'), }); -type CreateNonHumanPrincipal = PrincipalManagementRepositoryService['createNonHumanPrincipal']; -type Input = Parameters[0]; -type Result = ReturnType; const handle = ( payload: CreateNonHumanPrincipalPayload, context: ActionHandlerContext< Readonly>, - { readonly create: (input: Input) => Result } + { readonly create: PrincipalManagementRepositoryService['createNonHumanPrincipal'] } >, ) => context.services.create({ ...payload, tenantId: context.scope.tenantId }).pipe( diff --git a/app/packages/core-runtime/src/modules/actions/set-managed-api-key-binding-status.action.ts b/app/packages/core-runtime/src/modules/actions/set-managed-api-key-binding-status.action.ts index da5e406d4..66382e9f3 100644 --- a/app/packages/core-runtime/src/modules/actions/set-managed-api-key-binding-status.action.ts +++ b/app/packages/core-runtime/src/modules/actions/set-managed-api-key-binding-status.action.ts @@ -37,15 +37,12 @@ const SetManagedApiKeyBindingStatusResultSchema = Schema.Struct({ previousStatus: status, status, }); -type SetStatus = PrincipalManagementRepositoryService['setApiKeyBindingStatus']; -type Input = Parameters[0]; -type Result = ReturnType; const handle = Effect.fn('SetManagedApiKeyBindingStatusAction.handle')( function* setManagedApiKeyBindingStatusActionHandle( payload: SetManagedApiKeyBindingStatusPayload, context: ActionHandlerContext< Readonly>, - { readonly setStatus: (input: Input) => Result } + { readonly setStatus: PrincipalManagementRepositoryService['setApiKeyBindingStatus'] } >, ) { const result = yield* context.services.setStatus({ diff --git a/app/packages/core-runtime/src/modules/actions/set-self-api-key-binding-status.action.ts b/app/packages/core-runtime/src/modules/actions/set-self-api-key-binding-status.action.ts index 072cc47f8..22080e8c6 100644 --- a/app/packages/core-runtime/src/modules/actions/set-self-api-key-binding-status.action.ts +++ b/app/packages/core-runtime/src/modules/actions/set-self-api-key-binding-status.action.ts @@ -35,15 +35,12 @@ const SetSelfApiKeyBindingStatusResultSchema = Schema.Struct({ previousStatus: status, status, }); -type SetStatus = PrincipalManagementRepositoryService['setApiKeyBindingStatus']; -type Input = Parameters[0]; -type Result = ReturnType; const handle = Effect.fn('SetSelfApiKeyBindingStatusAction.handle')( function* setSelfApiKeyBindingStatusActionHandle( payload: SetSelfApiKeyBindingStatusPayload, context: ActionHandlerContext< Readonly>, - { readonly setStatus: (input: Input) => Result } + { readonly setStatus: PrincipalManagementRepositoryService['setApiKeyBindingStatus'] } >, ) { const result = yield* context.services.setStatus({ diff --git a/app/packages/core-runtime/src/outbox/errors.ts b/app/packages/core-runtime/src/outbox/errors.ts index ede6ac5f3..85ee42172 100644 --- a/app/packages/core-runtime/src/outbox/errors.ts +++ b/app/packages/core-runtime/src/outbox/errors.ts @@ -53,18 +53,6 @@ const OutboxClaimLostErrorValue = Schema.TaggedError() export type OutboxClaimLostError = InstanceType; export { OutboxClaimLostErrorValue as OutboxClaimLostError }; -const OutboxModuleStateErrorContract = Schema.TaggedStruct('OutboxModuleStateError', { - code: Schema.Literal('outbox_consumer_module_inactive'), - ...reason, -}); -type OutboxModuleStateErrorSelf = typeof OutboxModuleStateErrorContract.Type & Cause.YieldableError; -const OutboxModuleStateErrorValue = Schema.TaggedError()( - 'OutboxModuleStateError', - { code: Schema.Literal('outbox_consumer_module_inactive'), ...reason }, -); -export type OutboxModuleStateError = InstanceType; -export { OutboxModuleStateErrorValue as OutboxModuleStateError }; - const OutboxHandlerExecutionErrorContract = Schema.TaggedStruct('OutboxHandlerExecutionError', { code: Schema.Literal('outbox_handler_execution_failed'), ...reason, @@ -91,15 +79,6 @@ const OutboxPollerConfigErrorValue = Schema.TaggedError; export { OutboxPollerConfigErrorValue as OutboxPollerConfigError }; -export type OutboxWorkerError = - | OutboxClaimLostError - | OutboxHandlerExecutionError - | OutboxModuleStateError - | OutboxPayloadDecodeError - | OutboxPollerConfigError - | OutboxPersistenceError - | OutboxWorkerDescriptorError; - const PERSISTENCE_CAUSE_PROPERTY = 'ontosOutboxPersistenceCause'; export const outboxPersistenceError = ( diff --git a/app/packages/core-runtime/src/search/persistence.ts b/app/packages/core-runtime/src/search/persistence.ts index 60cd8acc1..1e1f9a7b7 100644 --- a/app/packages/core-runtime/src/search/persistence.ts +++ b/app/packages/core-runtime/src/search/persistence.ts @@ -604,6 +604,15 @@ const transactionOperations = makeTransactionOperations(); export const makePostgresCoreSearchProjectionStore = ( database: CoreSearchPersistenceDatabase, ): CoreSearchProjectionStoreService => { + const runTransaction = ( + body: (transaction: CoreTransaction) => Effect.Effect, + ) => + database.executor.transaction(body).pipe( + Effect.catchDefect((defect) => + isSqlError(defect) ? Effect.fail(defect) : Effect.die(defect), + ), + Effect.catchTag('SqlError', (failure) => Effect.fail(unavailable(failure))), + ); const apply: CoreSearchProjectionStoreService['apply'] = Effect.fn( 'CoreSearchProjectionStore.applyPostgres', )(function* applyCoreSearchProjection(input: CoreSearchProjectionInput) { @@ -611,24 +620,14 @@ export const makePostgresCoreSearchProjectionStore = ( const updatedAt = DateTime.toDateUtc(yield* DateTime.now); const transactionBody = (transaction: CoreTransaction) => transactionOperations.applyMutationTransaction(transaction, mutation, updatedAt); - yield* database.executor.transaction(transactionBody).pipe( - Effect.catchDefect((defect) => - isSqlError(defect) ? Effect.fail(defect) : Effect.die(defect), - ), - Effect.catchTag('SqlError', (failure) => Effect.fail(unavailable(failure))), - ); + yield* runTransaction(transactionBody); }); const queryCandidates: CoreSearchProjectionStoreService['queryCandidates'] = Effect.fn( 'CoreSearchProjectionStore.queryCandidatesPostgres', )(function* queryCoreSearchCandidates(input: CoreSearchQuery) { const transactionBody = (transaction: CoreTransaction) => transactionOperations.queryCandidatesTransaction(transaction, input); - const documents = yield* database.executor.transaction(transactionBody).pipe( - Effect.catchDefect((defect) => - isSqlError(defect) ? Effect.fail(defect) : Effect.die(defect), - ), - Effect.catchTag('SqlError', (failure) => Effect.fail(unavailable(failure))), - ); + const documents = yield* runTransaction(transactionBody); return yield* Schema.decodeUnknownEffect(Schema.Array(CoreSearchProjectionDocumentSchema))( documents, ).pipe(Effect.mapError(unavailable)); @@ -640,12 +639,7 @@ export const makePostgresCoreSearchProjectionStore = ( const updatedAt = DateTime.toDateUtc(yield* DateTime.now); const transactionBody = (transaction: CoreTransaction) => transactionOperations.replaceProjectionTransaction(transaction, replacement, updatedAt); - yield* database.executor.transaction(transactionBody).pipe( - Effect.catchDefect((defect) => - isSqlError(defect) ? Effect.fail(defect) : Effect.die(defect), - ), - Effect.catchTag('SqlError', (failure) => Effect.fail(unavailable(failure))), - ); + yield* runTransaction(transactionBody); }); return Object.freeze({ apply, queryCandidates, replace }); }; diff --git a/app/packages/core-runtime/src/testing/actions.ts b/app/packages/core-runtime/src/testing/actions.ts index 8e8dbd7b9..552933087 100644 --- a/app/packages/core-runtime/src/testing/actions.ts +++ b/app/packages/core-runtime/src/testing/actions.ts @@ -209,39 +209,31 @@ const actionTestHarness = (options: ActionTestHarnessOptions = {}) => { status: 'succeeded', }); }); + const recordRejection = ( + input: Input, + denials: Input[], + ) => + find(input.actionInvocationId).pipe( + Effect.flatMap((invocation) => + Effect.sync(() => { + denials.push(input); + invocations.set(input.actionInvocationId, { + ...invocation, + completedAt: completionTime(), + status: 'rejected', + }); + }), + ), + ); const repository: ActionRepositoryService = { createOrResolveInvocation: (_executor, input) => Effect.suspend(() => prepare(input)), - finalizePolicyDenial: (_executor, input) => - find(input.actionInvocationId).pipe( - Effect.flatMap((invocation) => - Effect.sync(() => { - policyDenials.push(input); - invocations.set(input.actionInvocationId, { - ...invocation, - completedAt: completionTime(), - status: 'rejected', - }); - }), - ), - ), + finalizePolicyDenial: (_executor, input) => recordRejection(input, policyDenials), flushSuccess: (_transaction, input) => Effect.sync(() => { pendingCommit.push(commitSuccess(input)); }), lockInvocation: (_transaction, id) => Effect.suspend(() => find(id)), - rejectPermissionDenied: (_executor, input) => - find(input.actionInvocationId).pipe( - Effect.flatMap((invocation) => - Effect.sync(() => { - permissionDenials.push(input); - invocations.set(input.actionInvocationId, { - ...invocation, - completedAt: completionTime(), - status: 'rejected', - }); - }), - ), - ), + rejectPermissionDenied: (_executor, input) => recordRejection(input, permissionDenials), resolveInvocation: (_executor, input) => Effect.suspend(() => { const invocation = invocations.get(input.invocationId); diff --git a/app/packages/core-runtime/tests/integration/action-permission.test.ts b/app/packages/core-runtime/tests/integration/action-permission.test.ts index e0e80af38..a019fc5f1 100644 --- a/app/packages/core-runtime/tests/integration/action-permission.test.ts +++ b/app/packages/core-runtime/tests/integration/action-permission.test.ts @@ -529,6 +529,26 @@ effectTest( ), ); +const runFailedAction = ( + runtime: ReturnType, + actionKey: string, + key: string, + moduleStateKey: string, + executions: ExecutionCounter, +) => + Effect.flip( + runtime.runAction({ + payload: undefined, + principal, + registration: registration( + actionKey, + moduleStateKey, + incrementExecution.bind(undefined, executions), + ), + transport: transport(key, moduleStateKey), + }), + ); + effectTest( 'persists one normalized terminal denial and no business or collected evidence', withDatabase((database) => @@ -537,18 +557,7 @@ effectTest( const key = 'missing'; const moduleStateKey = `${actionPrefix}.state.missing`; const failure = yield* runWithLivePermission(database, (runtime) => - Effect.flip( - runtime.runAction({ - payload: undefined, - principal, - registration: registration( - actionKeys.missing, - moduleStateKey, - incrementExecution.bind(undefined, executions), - ), - transport: transport(key, moduleStateKey), - }), - ), + runFailedAction(runtime, actionKeys.missing, key, moduleStateKey, executions), ); const [invocation] = yield* database.executor .select() @@ -794,18 +803,7 @@ effectTest( const failure = yield* runWithLivePermission( database, (runtime) => - Effect.flip( - runtime.runAction({ - payload: undefined, - principal, - registration: registration( - actionKeys.unavailable, - moduleStateKey, - incrementExecution.bind(undefined, executions), - ), - transport: transport(key, moduleStateKey), - }), - ), + runFailedAction(runtime, actionKeys.unavailable, key, moduleStateKey, executions), { ...spiceDbConfig, preSharedKey: 'invalid-integration-key' }, ); const [invocation] = yield* database.executor diff --git a/app/packages/core-runtime/tests/integration/action-runtime.test.ts b/app/packages/core-runtime/tests/integration/action-runtime.test.ts index d56b7fbe4..84f3b35bb 100644 --- a/app/packages/core-runtime/tests/integration/action-runtime.test.ts +++ b/app/packages/core-runtime/tests/integration/action-runtime.test.ts @@ -150,6 +150,27 @@ const withDatabase = ( type ContextServiceContract = Parameters[0]; +const withTransactionOverride = ( + database: ContextServiceContract, + override: Pick, +): ContextServiceContract => ({ + executor: Object.assign(Object.create(database.executor), override), +}); + +const invocationEvidence = (database: ContextServiceContract, key: string) => + Effect.gen(function* readInvocationEvidence() { + const [invocation] = yield* database.executor + .select() + .from(actionInvocations) + .where(eq(actionInvocations.idempotencyKey, key)); + assert.ok(invocation); + const audits = yield* database.executor + .select() + .from(auditEvents) + .where(eq(auditEvents.actionInvocationId, invocation.actionInvocationId)); + return { audits, invocation }; + }); + const EvidencePersistenceStageSchema = Schema.Literals([ 'audit', 'data-access', @@ -190,12 +211,7 @@ const withEvidencePersistenceFailure = ( ); }), } satisfies Pick; - const executor: ContextServiceContract['executor'] = Object.assign( - Object.create(database.executor), - transactionOverride, - ); - - return { executor }; + return withTransactionOverride(database, transactionOverride); }; const databasePromise = async ( @@ -266,41 +282,23 @@ before(async () => { after(async () => { await databasePromise(async (database) => { await runEffectTestPromise( - database.executor.delete(outboxMessages).where(eq(outboxMessages.tenantId, tenantId)), - ); - await runEffectTestPromise( - database.executor.delete(domainEvents).where(eq(domainEvents.tenantId, tenantId)), - ); - await runEffectTestPromise( - database.executor.delete(dataAccessEvents).where(eq(dataAccessEvents.tenantId, tenantId)), - ); - await runEffectTestPromise( - database.executor.delete(auditEvents).where(eq(auditEvents.tenantId, tenantId)), - ); - await runEffectTestPromise( - database.executor - .delete(tenantModuleStateChanges) - .where(eq(tenantModuleStateChanges.tenantId, tenantId)), - ); - await runEffectTestPromise( - database.executor.delete(tenantModuleStates).where(eq(tenantModuleStates.tenantId, tenantId)), - ); - await runEffectTestPromise( - database.executor.delete(actionInvocations).where(eq(actionInvocations.tenantId, tenantId)), - ); - await runEffectTestPromise( - database.executor - .delete(principalAuthBindings) - .where(eq(principalAuthBindings.tenantId, tenantId)), - ); - await runEffectTestPromise( - database.executor.delete(principals).where(eq(principals.tenantId, tenantId)), - ); - await runEffectTestPromise( - database.executor.delete(legalEntities).where(eq(legalEntities.tenantId, tenantId)), - ); - await runEffectTestPromise( - database.executor.delete(tenants).where(eq(tenants.tenantId, tenantId)), + Effect.forEach( + [ + outboxMessages, + domainEvents, + dataAccessEvents, + auditEvents, + tenantModuleStateChanges, + tenantModuleStates, + actionInvocations, + principalAuthBindings, + principals, + legalEntities, + tenants, + ], + (table) => database.executor.delete(table).where(eq(table.tenantId, tenantId)), + { discard: true }, + ), ); }); }); @@ -686,19 +684,7 @@ void test('commits allowed Policy checkpoints atomically before handler success }), ); - const [invocation] = await runEffectTestPromise( - database.executor - .select() - .from(actionInvocations) - .where(eq(actionInvocations.idempotencyKey, key)), - ); - assert.ok(invocation); - const audits = await runEffectTestPromise( - database.executor - .select() - .from(auditEvents) - .where(eq(auditEvents.actionInvocationId, invocation.actionInvocationId)), - ); + const { audits, invocation } = await runEffectTestPromise(invocationEvidence(database, key)); assert.deepEqual(observed, ['policy', 'handler']); assert.equal(invocation.status, 'succeeded'); @@ -924,19 +910,7 @@ void test('rolls back every denied-Policy finalization persistence failure', asy }), ), ); - const [invocation] = await runEffectTestPromise( - database.executor - .select() - .from(actionInvocations) - .where(eq(actionInvocations.idempotencyKey, key)), - ); - assert.ok(invocation); - const audits = await runEffectTestPromise( - database.executor - .select() - .from(auditEvents) - .where(eq(auditEvents.actionInvocationId, invocation.actionInvocationId)), - ); + const { audits, invocation } = await runEffectTestPromise(invocationEvidence(database, key)); assert.equal( failureTag(exit), @@ -1193,19 +1167,7 @@ void test('keeps Policy rejection terminal and deduplicates repeated and concurr }; const first = await runEffectTestPromise(Effect.exit(runtime.runAction(input))); const retry = await runEffectTestPromise(Effect.exit(runtime.runAction(input))); - const [invocation] = await runEffectTestPromise( - database.executor - .select() - .from(actionInvocations) - .where(eq(actionInvocations.idempotencyKey, key)), - ); - assert.ok(invocation); - const audits = await runEffectTestPromise( - database.executor - .select() - .from(auditEvents) - .where(eq(auditEvents.actionInvocationId, invocation.actionInvocationId)), - ); + const { audits, invocation } = await runEffectTestPromise(invocationEvidence(database, key)); assert.equal(failureTag(first), 'ActionPolicyDenied'); assert.equal(failureTag(retry), 'ActionInvocationStateError'); @@ -1324,19 +1286,7 @@ void test('never lets a losing Policy denial replace a running or successful inv Effect.exit(deniedRuntime.runAction({ ...sharedInput, registration: denied })), ); const [successResult, rejectedExit] = await Promise.all([success, rejected]); - const [invocation] = await runEffectTestPromise( - database.executor - .select() - .from(actionInvocations) - .where(eq(actionInvocations.idempotencyKey, key)), - ); - assert.ok(invocation); - const audits = await runEffectTestPromise( - database.executor - .select() - .from(auditEvents) - .where(eq(auditEvents.actionInvocationId, invocation.actionInvocationId)), - ); + const { audits, invocation } = await runEffectTestPromise(invocationEvidence(database, key)); assert.equal(successResult.value, 'same'); assert.equal(failureTag(rejectedExit), 'ActionInvocationPersistenceError'); @@ -1568,12 +1518,8 @@ void test('resolves a lost commit acknowledgement from the durable succeeded mar .transaction(transactionBody) .pipe(Effect.andThen(Effect.die(acknowledgementLost))), } satisfies Pick; - const uncertainExecutor: ContextServiceContract['executor'] = Object.assign( - Object.create(database.executor), - uncertainTransaction, - ); const uncertainRuntime = makeActionRuntime( - { executor: uncertainExecutor }, + withTransactionOverride(database, uncertainTransaction), repository, allowedPermission, testOperationalScopeResolver, @@ -1689,12 +1635,8 @@ void test('resolves a lost commit acknowledgement from the durable succeeded mar ) .pipe(Effect.catchCause(() => Effect.die(acknowledgementLost))), } satisfies Pick; - const uncertainRollbackExecutor: ContextServiceContract['executor'] = Object.assign( - Object.create(database.executor), - uncertainRollbackTransaction, - ); const uncertainOpenRuntime = makeActionRuntime( - { executor: uncertainRollbackExecutor }, + withTransactionOverride(database, uncertainRollbackTransaction), repository, allowedPermission, testOperationalScopeResolver, diff --git a/app/packages/core-runtime/tests/integration/legal-entity-context.test.ts b/app/packages/core-runtime/tests/integration/legal-entity-context.test.ts index c8fb46bad..096231989 100644 --- a/app/packages/core-runtime/tests/integration/legal-entity-context.test.ts +++ b/app/packages/core-runtime/tests/integration/legal-entity-context.test.ts @@ -1,25 +1,13 @@ -import { - makeEffectTestCallback as nativeTestCallback, - makeEffectTestCallback, -} from '@app/core-runtime/testing/effect-runtime'; +import { makeEffectTestCallback } from '@app/core-runtime/testing/effect-runtime'; import { eq } from 'drizzle-orm'; -import { Effect, Exit as NativeExit, Scope as NativeScope } from 'effect'; +import { Effect } from 'effect'; import assert from 'node:assert/strict'; -import test, { after as afterNativeDatabase } from 'node:test'; -import { Pool } from 'pg'; +import test from 'node:test'; import { makeLegalEntityContext } from '../../src/auth/legal-entity-context.ts'; import { loadDatabaseConfig } from '../../src/db/config.ts'; -import { coreRelations, legalEntities, tenants } from '../../src/db/schema.ts'; -import { makeTestDatabaseFromPool } from '../support/database.ts'; -import { runEffectTestSync as runNativeSync } from '../support/effect-runtime.ts'; - -const nativeDatabaseScope = runNativeSync(NativeScope.make()); -const databaseEffect = (operation: () => PromiseLike) => - Effect.promise(() => operation()); -afterNativeDatabase( - NativeScope.close(nativeDatabaseScope, NativeExit.void).pipe(nativeTestCallback), -); +import { legalEntities, tenants } from '../../src/db/schema.ts'; +import { makeCoreDatabase } from '../../src/db/client.ts'; const tenantOne = '11000000-0000-4000-8000-000000000001'; const tenantTwo = '11000000-0000-4000-8000-000000000002'; @@ -28,18 +16,11 @@ const activeTwo = '21000000-0000-4000-8000-000000000002'; const suspended = '21000000-0000-4000-8000-000000000003'; const foreign = '21000000-0000-4000-8000-000000000004'; -const effectTest = (name: string, effect: Effect.Effect): void => { - test(name, makeEffectTestCallback(effect)); -}; - -effectTest( +test( 'lists and validates only active legal entities inside the exact tenant', Effect.gen(function* legalEntityContextIntegration() { const configuration = yield* loadDatabaseConfig(); - const pool = new Pool({ connectionString: configuration.connectionString }); - const database = yield* makeTestDatabaseFromPool(pool, coreRelations).pipe( - NativeScope.provide(nativeDatabaseScope), - ); + const { executor: database } = yield* makeCoreDatabase(configuration); const context = makeLegalEntityContext({ executor: database }); const cleanup = Effect.gen(function* cleanLegalEntityContextFixtures() { yield* database.delete(legalEntities).where(eq(legalEntities.tenantId, tenantOne)); @@ -113,9 +94,6 @@ effectTest( assert.equal(inactiveError._tag, 'LegalEntityContextInactiveError'); const missingError = yield* Effect.flip(context.validateSelection(tenantOne, foreign)); assert.equal(missingError._tag, 'LegalEntityContextMissingError'); - }).pipe( - Effect.ensuring(cleanup.pipe(Effect.orDie)), - Effect.ensuring(databaseEffect(pool.end.bind(pool)).pipe(Effect.orDie)), - ); - }), + }).pipe(Effect.ensuring(cleanup.pipe(Effect.orDie))); + }).pipe(Effect.scoped, makeEffectTestCallback), ); diff --git a/app/packages/core-runtime/tests/integration/module-state-gate.test.ts b/app/packages/core-runtime/tests/integration/module-state-gate.test.ts index 9f9cce005..b3ea26cb5 100644 --- a/app/packages/core-runtime/tests/integration/module-state-gate.test.ts +++ b/app/packages/core-runtime/tests/integration/module-state-gate.test.ts @@ -226,20 +226,16 @@ void test('batches tenant-isolated states once, rejects malformed/unavailable re assert.doesNotMatch(malformed.reason, /corrupt|storage/u); } finally { await runEffectTestPromise( - database.executor - .delete(tenantModuleStates) - .where(eq(tenantModuleStates.tenantId, tenantOne)), - ); - await runEffectTestPromise( - database.executor - .delete(tenantModuleStates) - .where(eq(tenantModuleStates.tenantId, tenantTwo)), - ); - await runEffectTestPromise( - database.executor.delete(tenants).where(eq(tenants.tenantId, tenantOne)), - ); - await runEffectTestPromise( - database.executor.delete(tenants).where(eq(tenants.tenantId, tenantTwo)), + Effect.forEach( + [tenantModuleStates, tenants], + (table) => + Effect.forEach( + [tenantOne, tenantTwo], + (tenantId) => database.executor.delete(table).where(eq(table.tenantId, tenantId)), + { discard: true }, + ), + { discard: true }, + ), ); } }); diff --git a/app/packages/core-runtime/tests/integration/outbox-runtime.test.ts b/app/packages/core-runtime/tests/integration/outbox-runtime.test.ts index 4317de310..3918f0ff6 100644 --- a/app/packages/core-runtime/tests/integration/outbox-runtime.test.ts +++ b/app/packages/core-runtime/tests/integration/outbox-runtime.test.ts @@ -1,4 +1,5 @@ import { runEffectTestPromise } from '@app/core-runtime/testing/effect-runtime'; +import { purgeFixtureRows } from '../support/fixture-cleanup.ts'; // @effect-diagnostics asyncFunction:off -- Existing compatibility boundary; expires: 2026-12-31. import { and, asc, eq } from 'drizzle-orm'; import { DateTime, Effect, Option, Schema } from 'effect'; @@ -188,396 +189,396 @@ const cleanupTenant = async (database: CoreDatabaseExecutor, tenantId: string): database.delete(outboxDeliveries).where(eq(outboxDeliveries.outboxMessageId, messageId)), ); }); - await runEffectTestPromise( + await purgeFixtureRows([ database.delete(outboxMessages).where(eq(outboxMessages.tenantId, tenantId)), - ); - await runEffectTestPromise( database.delete(domainEvents).where(eq(domainEvents.tenantId, tenantId)), - ); - await runEffectTestPromise( database.delete(tenantModuleStates).where(eq(tenantModuleStates.tenantId, tenantId)), - ); - await runEffectTestPromise(database.delete(tenants).where(eq(tenants.tenantId, tenantId))); + database.delete(tenants).where(eq(tenants.tenantId, tenantId)), + ]); }; -void test('matches zero, one, or multiple exact workers once without historical backfill', async () => { +/** + * Every scenario owns one throwaway tenant and must drop it even when an assertion fails, so the + * tenant lifecycle stays here instead of being restated as a try/finally in each test. + */ +const withTenant = async ( + scenario: (database: CoreDatabaseExecutor, tenantId: string) => Promise, +): Promise => await withDatabase(async (database) => { const tenantId = await insertTenant(database); try { - await insertMessage(database, tenantId); - await insertMessage(database, tenantId, 'producer.unmatched'); - const repository = makeOutboxRepository(database); - const workers = [makeWorker('consumer.alpha'), makeWorker('consumer.beta')]; - - const firstMatch = await runEffectTestPromise( - repository.matchUnmatched(workers.map(subscriptionOf), dateAt('2026-08-03T10:00:00Z')), - ); - assert.equal(firstMatch.deliveriesCreated, 2); - assert.ok(firstMatch.messagesMatched >= 2); - const repeatMatch = await runEffectTestPromise( - repository.matchUnmatched(workers.map(subscriptionOf), dateAt('2026-08-03T10:01:00Z')), - ); - assert.equal(repeatMatch.deliveriesCreated, 0); - const lateWorkerMatch = await runEffectTestPromise( - repository.matchUnmatched( - [...workers, makeWorker('consumer.late')].map(subscriptionOf), - dateAt('2026-08-03T10:02:00Z'), - ), - ); - assert.equal(lateWorkerMatch.deliveriesCreated, 0); - const deliveries = await runEffectTestPromise( - database - .select() - .from(outboxDeliveries) - .innerJoin( - outboxMessages, - eq(outboxMessages.outboxMessageId, outboxDeliveries.outboxMessageId), - ) - .where(eq(outboxMessages.tenantId, tenantId)), - ); - assert.equal(deliveries.length, 2); - assert.deepEqual(deliveries.map((row) => row.outbox_deliveries.workerKey).toSorted(), [ - 'consumer.alpha', - 'consumer.beta', - ]); - const messages = await runEffectTestPromise( - database - .select({ matchedAt: outboxMessages.matchedAt }) - .from(outboxMessages) - .where(eq(outboxMessages.tenantId, tenantId)), - ); - assert.equal( - messages.every(({ matchedAt }) => matchedAt !== null), - true, - ); + await scenario(database, tenantId); } finally { await cleanupTenant(database, tenantId); } }); + +/** + * The claim scenarios all start from a registered worker whose pending messages are already + * matched, and they all claim against a clock advanced past the seeded rows. + */ +const matchedWorker = async ( + database: CoreDatabaseExecutor, + tenantId: string, + workerKey: string, + options: Parameters[1] & { readonly messages?: number } = {}, +): Promise<{ + readonly messages: readonly Awaited>[]; + readonly now: Date; + readonly registration: AnyOutboxWorkerRegistration; + readonly repository: OutboxRepositoryService; +}> => { + await runEffectTestPromise(activateConsumer(database, tenantId)); + const messages: Awaited>[] = []; + await forEachSequential( + Array.from({ length: options.messages ?? 1 }, (_, index) => index), + async () => { + messages.push(await insertMessage(database, tenantId)); + }, + ); + const registration = makeWorker(workerKey, options); + const repository = makeOutboxRepository(database); + const now = advanceDate(await runEffectTestPromise(DateTime.nowAsDate), 1000); + await runEffectTestPromise(repository.matchUnmatched([subscriptionOf(registration)], now)); + return { messages, now, registration, repository }; +}; + +void test('matches zero, one, or multiple exact workers once without historical backfill', async () => { + await withTenant(async (database, tenantId) => { + await insertMessage(database, tenantId); + await insertMessage(database, tenantId, 'producer.unmatched'); + const repository = makeOutboxRepository(database); + const workers = [makeWorker('consumer.alpha'), makeWorker('consumer.beta')]; + + const firstMatch = await runEffectTestPromise( + repository.matchUnmatched(workers.map(subscriptionOf), dateAt('2026-08-03T10:00:00Z')), + ); + assert.equal(firstMatch.deliveriesCreated, 2); + assert.ok(firstMatch.messagesMatched >= 2); + const repeatMatch = await runEffectTestPromise( + repository.matchUnmatched(workers.map(subscriptionOf), dateAt('2026-08-03T10:01:00Z')), + ); + assert.equal(repeatMatch.deliveriesCreated, 0); + const lateWorkerMatch = await runEffectTestPromise( + repository.matchUnmatched( + [...workers, makeWorker('consumer.late')].map(subscriptionOf), + dateAt('2026-08-03T10:02:00Z'), + ), + ); + assert.equal(lateWorkerMatch.deliveriesCreated, 0); + const deliveries = await runEffectTestPromise( + database + .select() + .from(outboxDeliveries) + .innerJoin( + outboxMessages, + eq(outboxMessages.outboxMessageId, outboxDeliveries.outboxMessageId), + ) + .where(eq(outboxMessages.tenantId, tenantId)), + ); + assert.equal(deliveries.length, 2); + assert.deepEqual(deliveries.map((row) => row.outbox_deliveries.workerKey).toSorted(), [ + 'consumer.alpha', + 'consumer.beta', + ]); + const messages = await runEffectTestPromise( + database + .select({ matchedAt: outboxMessages.matchedAt }) + .from(outboxMessages) + .where(eq(outboxMessages.tenantId, tenantId)), + ); + assert.equal( + messages.every(({ matchedAt }) => matchedAt !== null), + true, + ); + }); }); void test('matches the complete subscription catalog before owner-local processes claim work', async () => { - await withDatabase(async (database) => { - const tenantId = await insertTenant(database); - try { - await runEffectTestPromise(activateConsumer(database, tenantId)); - await runEffectTestPromise( - database.insert(tenantModuleStates).values({ - moduleKey: 'reporting', - state: 'active', - tenantId, - }), - ); - await insertMessage(database, tenantId); - const consumerWorker = makeWorker('consumer.local'); - const reportingWorker = makeWorker('reporting.local', { - consumerModuleKey: 'reporting', - }); - const repository = makeOutboxRepository(database); - const subscriptions = [consumerWorker, reportingWorker].map(subscriptionOf); + await withTenant(async (database, tenantId) => { + await runEffectTestPromise(activateConsumer(database, tenantId)); + await runEffectTestPromise( + database.insert(tenantModuleStates).values({ + moduleKey: 'reporting', + state: 'active', + tenantId, + }), + ); + await insertMessage(database, tenantId); + const consumerWorker = makeWorker('consumer.local'); + const reportingWorker = makeWorker('reporting.local', { + consumerModuleKey: 'reporting', + }); + const repository = makeOutboxRepository(database); + const subscriptions = [consumerWorker, reportingWorker].map(subscriptionOf); - const matched = await runEffectTestPromise( - repository.matchUnmatched(subscriptions, dateAt('2026-08-03T10:00:00Z')), - ); - assert.equal(matched.deliveriesCreated, 2); + const matched = await runEffectTestPromise( + repository.matchUnmatched(subscriptions, dateAt('2026-08-03T10:00:00Z')), + ); + assert.equal(matched.deliveriesCreated, 2); - const claimAt = await runEffectTestPromise(DateTime.nowAsDate); - const consumerClaim = Option.getOrNull( - await claimNext(repository, [consumerWorker], 'consumer-process', claimAt), - ); - const reportingClaim = Option.getOrNull( - await claimNext(repository, [reportingWorker], 'reporting-process', claimAt), - ); - assert.equal(consumerClaim?.workerKey, 'consumer.local'); - assert.equal(reportingClaim?.workerKey, 'reporting.local'); - } finally { - await cleanupTenant(database, tenantId); - } + const claimAt = await runEffectTestPromise(DateTime.nowAsDate); + const consumerClaim = Option.getOrNull( + await claimNext(repository, [consumerWorker], 'consumer-process', claimAt), + ); + const reportingClaim = Option.getOrNull( + await claimNext(repository, [reportingWorker], 'reporting-process', claimAt), + ); + assert.equal(consumerClaim?.workerKey, 'consumer.local'); + assert.equal(reportingClaim?.workerKey, 'reporting.local'); }); }); void test('gates claims on every non-active consumer state and permits one concurrent live claim', async () => { - await withDatabase(async (database) => { - const tenantId = await insertTenant(database); - try { - await insertMessage(database, tenantId); - const registration = makeWorker('consumer.module-gated'); - const repository = makeOutboxRepository(database); - await runEffectTestPromise( - repository.matchUnmatched([subscriptionOf(registration)], dateAt('2026-08-03T11:00:00Z')), - ); - const claimAt = advanceDate(await runEffectTestPromise(DateTime.nowAsDate), 1000); - assert.equal( - Option.getOrNull(await claimNext(repository, [registration], 'runtime-a', claimAt)), - null, - ); - await runEffectTestPromise(activateConsumer(database, tenantId, 'inactive')); - await forEachSequential( - ['inactive', 'read_only', 'suspended', 'quarantined', 'deprecated', 'archived'] as const, - async (state) => { - await runEffectTestPromise( - database - .update(tenantModuleStates) - .set({ state }) - .where( - and( - eq(tenantModuleStates.tenantId, tenantId), - eq(tenantModuleStates.moduleKey, 'consumer'), - ), + await withTenant(async (database, tenantId) => { + await insertMessage(database, tenantId); + const registration = makeWorker('consumer.module-gated'); + const repository = makeOutboxRepository(database); + await runEffectTestPromise( + repository.matchUnmatched([subscriptionOf(registration)], dateAt('2026-08-03T11:00:00Z')), + ); + const claimAt = advanceDate(await runEffectTestPromise(DateTime.nowAsDate), 1000); + assert.equal( + Option.getOrNull(await claimNext(repository, [registration], 'runtime-a', claimAt)), + null, + ); + await runEffectTestPromise(activateConsumer(database, tenantId, 'inactive')); + await forEachSequential( + ['inactive', 'read_only', 'suspended', 'quarantined', 'deprecated', 'archived'] as const, + async (state) => { + await runEffectTestPromise( + database + .update(tenantModuleStates) + .set({ state }) + .where( + and( + eq(tenantModuleStates.tenantId, tenantId), + eq(tenantModuleStates.moduleKey, 'consumer'), ), - ); - assert.equal( - Option.getOrNull( - await claimNext(repository, [registration], `runtime-${state}`, claimAt), - ), - null, - ); - }, - ); - await runEffectTestPromise( - database - .update(tenantModuleStates) - .set({ state: 'active' }) - .where( - and( - eq(tenantModuleStates.tenantId, tenantId), - eq(tenantModuleStates.moduleKey, 'consumer'), ), + ); + assert.equal( + Option.getOrNull( + await claimNext(repository, [registration], `runtime-${state}`, claimAt), ), - ); - const claimOptions = await Promise.all([ - claimNext(repository, [registration], 'runtime-a', claimAt), - claimNext(repository, [registration], 'runtime-b', claimAt), - ]); - const claims = claimOptions.map(Option.getOrNull); - assert.equal(claims.filter((candidate) => candidate !== null).length, 1); - const claimed = claims.find((candidate) => candidate !== null); - assert.ok(claimed); - const [attempt] = await runEffectTestPromise( - database - .select() - .from(outboxAttempts) - .where(eq(outboxAttempts.outboxDeliveryId, claimed.deliveryId)), - ); - assert.ok(attempt); - assert.equal(attempt.finishedAt, null); - } finally { - await cleanupTenant(database, tenantId); - } + null, + ); + }, + ); + await runEffectTestPromise( + database + .update(tenantModuleStates) + .set({ state: 'active' }) + .where( + and( + eq(tenantModuleStates.tenantId, tenantId), + eq(tenantModuleStates.moduleKey, 'consumer'), + ), + ), + ); + const claimOptions = await Promise.all([ + claimNext(repository, [registration], 'runtime-a', claimAt), + claimNext(repository, [registration], 'runtime-b', claimAt), + ]); + const claims = claimOptions.map(Option.getOrNull); + assert.equal(claims.filter((candidate) => candidate !== null).length, 1); + const claimed = claims.find((candidate) => candidate !== null); + assert.ok(claimed); + const [attempt] = await runEffectTestPromise( + database + .select() + .from(outboxAttempts) + .where(eq(outboxAttempts.outboxDeliveryId, claimed.deliveryId)), + ); + assert.ok(attempt); + assert.equal(attempt.finishedAt, null); }); }); void test('reclaims only expired leases, abandons the old attempt, and rejects stale finalization', async () => { - await withDatabase(async (database) => { - const tenantId = await insertTenant(database); - try { - await runEffectTestPromise(activateConsumer(database, tenantId)); - await insertMessage(database, tenantId); - const registration = makeWorker('consumer.lease-proof'); - const repository = makeOutboxRepository(database); - const started = advanceDate(await runEffectTestPromise(DateTime.nowAsDate), 1000); - await runEffectTestPromise( - repository.matchUnmatched([subscriptionOf(registration)], started), - ); - const first = Option.getOrNull( - await claimNext(repository, [registration], 'runtime-a', started), - ); - assert.ok(first); - assert.equal( - Option.getOrNull( - await claimNext(repository, [registration], 'runtime-b', advanceDate(started, 999)), - ), - null, - ); - const second = Option.getOrNull( - await claimNext(repository, [registration], 'runtime-b', advanceDate(started, 1001)), - ); - assert.ok(second); - assert.notEqual(second.claimId, first.claimId); - await assert.rejects( - runEffectTestPromise(repository.complete(first, advanceDate(started, 1002))), - Schema.is(OutboxClaimLostError), - ); - const attempts = await runEffectTestPromise( - database - .select() - .from(outboxAttempts) - .where(eq(outboxAttempts.outboxDeliveryId, first.deliveryId)) - .orderBy(asc(outboxAttempts.startedAt)), - ); - assert.equal(attempts.length, 2); - assert.equal(attempts[0]?.errorMessage, 'Outbox Worker lease expired before completion'); - assert.ok(attempts[0]?.finishedAt); - assert.equal(attempts[1]?.finishedAt, null); - } finally { - await cleanupTenant(database, tenantId); - } + await withTenant(async (database, tenantId) => { + const { + now: started, + registration, + repository, + } = await matchedWorker(database, tenantId, 'consumer.lease-proof'); + const first = Option.getOrNull( + await claimNext(repository, [registration], 'runtime-a', started), + ); + assert.ok(first); + assert.equal( + Option.getOrNull( + await claimNext(repository, [registration], 'runtime-b', advanceDate(started, 999)), + ), + null, + ); + const second = Option.getOrNull( + await claimNext(repository, [registration], 'runtime-b', advanceDate(started, 1001)), + ); + assert.ok(second); + assert.notEqual(second.claimId, first.claimId); + await assert.rejects( + runEffectTestPromise(repository.complete(first, advanceDate(started, 1002))), + Schema.is(OutboxClaimLostError), + ); + const attempts = await runEffectTestPromise( + database + .select() + .from(outboxAttempts) + .where(eq(outboxAttempts.outboxDeliveryId, first.deliveryId)) + .orderBy(asc(outboxAttempts.startedAt)), + ); + assert.equal(attempts.length, 2); + assert.equal(attempts[0]?.errorMessage, 'Outbox Worker lease expired before completion'); + assert.ok(attempts[0]?.finishedAt); + assert.equal(attempts[1]?.finishedAt, null); }); }); void test('finishes an abandoned final attempt before dead-lettering its expired delivery', async () => { - await withDatabase(async (database) => { - const tenantId = await insertTenant(database); - try { - await runEffectTestPromise(activateConsumer(database, tenantId)); - await insertMessage(database, tenantId); - const registration = makeWorker('consumer.final-lease', { maxAttempts: 1 }); - const repository = makeOutboxRepository(database); - const started = advanceDate(await runEffectTestPromise(DateTime.nowAsDate), 1000); - await runEffectTestPromise( - repository.matchUnmatched([subscriptionOf(registration)], started), - ); - const claim = Option.getOrNull( - await claimNext(repository, [registration], 'runtime-a', started), - ); - assert.ok(claim); + await withTenant(async (database, tenantId) => { + const { + now: started, + registration, + repository, + } = await matchedWorker(database, tenantId, 'consumer.final-lease', { maxAttempts: 1 }); + const claim = Option.getOrNull( + await claimNext(repository, [registration], 'runtime-a', started), + ); + assert.ok(claim); - assert.equal( - Option.getOrNull( - await claimNext(repository, [registration], 'runtime-b', advanceDate(started, 1001)), - ), - null, - ); - const [delivery] = await runEffectTestPromise( - database - .select() - .from(outboxDeliveries) - .where(eq(outboxDeliveries.outboxDeliveryId, claim.deliveryId)), - ); - const [attempt] = await runEffectTestPromise( - database - .select() - .from(outboxAttempts) - .where(eq(outboxAttempts.outboxDeliveryId, claim.deliveryId)), - ); - assert.equal(delivery?.status, 'dead'); - assert.equal(attempt?.errorMessage, 'Outbox Worker lease expired before completion'); - assert.ok(attempt?.finishedAt); - } finally { - await cleanupTenant(database, tenantId); - } + assert.equal( + Option.getOrNull( + await claimNext(repository, [registration], 'runtime-b', advanceDate(started, 1001)), + ), + null, + ); + const [delivery] = await runEffectTestPromise( + database + .select() + .from(outboxDeliveries) + .where(eq(outboxDeliveries.outboxDeliveryId, claim.deliveryId)), + ); + const [attempt] = await runEffectTestPromise( + database + .select() + .from(outboxAttempts) + .where(eq(outboxAttempts.outboxDeliveryId, claim.deliveryId)), + ); + assert.equal(delivery?.status, 'dead'); + assert.equal(attempt?.errorMessage, 'Outbox Worker lease expired before completion'); + assert.ok(attempt?.finishedAt); }); }); void test('finalizes success atomically and advances only through contiguous done deliveries', async () => { - await withDatabase(async (database) => { - const tenantId = await insertTenant(database); - try { - await runEffectTestPromise(activateConsumer(database, tenantId)); - const firstMessage = await insertMessage(database, tenantId); - const secondMessage = await insertMessage(database, tenantId); - const registration = makeWorker('consumer.checkpoint-proof'); - const repository = makeOutboxRepository(database); - const now = advanceDate(await runEffectTestPromise(DateTime.nowAsDate), 1000); - await runEffectTestPromise(repository.matchUnmatched([subscriptionOf(registration)], now)); - const first = Option.getOrNull(await claimNext(repository, [registration], 'runtime-a', now)); - const second = Option.getOrNull( - await claimNext(repository, [registration], 'runtime-b', now), - ); - assert.ok(first); - assert.ok(second); - await runEffectTestPromise(repository.complete(second, advanceDate(now, 1))); - assert.deepEqual( - await runEffectTestPromise( - database.select().from(workerCheckpoints).where(eq(workerCheckpoints.tenantId, tenantId)), - ), - [], - ); - await runEffectTestPromise(repository.complete(first, advanceDate(now, 2))); - const [checkpoint] = await runEffectTestPromise( + await withTenant(async (database, tenantId) => { + const { + messages: [firstMessage, secondMessage], + now, + registration, + repository, + } = await matchedWorker(database, tenantId, 'consumer.checkpoint-proof', { messages: 2 }); + assert.ok(firstMessage); + assert.ok(secondMessage); + const first = Option.getOrNull(await claimNext(repository, [registration], 'runtime-a', now)); + const second = Option.getOrNull(await claimNext(repository, [registration], 'runtime-b', now)); + assert.ok(first); + assert.ok(second); + await runEffectTestPromise(repository.complete(second, advanceDate(now, 1))); + assert.deepEqual( + await runEffectTestPromise( database.select().from(workerCheckpoints).where(eq(workerCheckpoints.tenantId, tenantId)), - ); - assert.ok(checkpoint); - assert.equal(checkpoint.consumerName, registration.descriptor.workerKey); - assert.equal(checkpoint.streamKey, 'producer:producer.message-created'); - assert.equal(checkpoint.lastTenantSequenceNo, secondMessage.tenantSequenceNo); - assert.ok(checkpoint.lastTenantSequenceNo > firstMessage.tenantSequenceNo); - const deliveries = await runEffectTestPromise( - database - .select() - .from(outboxDeliveries) - .innerJoin( - outboxMessages, - eq(outboxMessages.outboxMessageId, outboxDeliveries.outboxMessageId), - ) - .where(eq(outboxMessages.tenantId, tenantId)), - ); - assert.equal( - deliveries.every((row) => row.outbox_deliveries.status === 'done'), - true, - ); - assert.equal( - deliveries.every((row) => row.outbox_deliveries.claimedBy === null), - true, - ); - } finally { - await cleanupTenant(database, tenantId); - } + ), + [], + ); + await runEffectTestPromise(repository.complete(first, advanceDate(now, 2))); + const [checkpoint] = await runEffectTestPromise( + database.select().from(workerCheckpoints).where(eq(workerCheckpoints.tenantId, tenantId)), + ); + assert.ok(checkpoint); + assert.equal(checkpoint.consumerName, registration.descriptor.workerKey); + assert.equal(checkpoint.streamKey, 'producer:producer.message-created'); + assert.equal(checkpoint.lastTenantSequenceNo, secondMessage.tenantSequenceNo); + assert.ok(checkpoint.lastTenantSequenceNo > firstMessage.tenantSequenceNo); + const deliveries = await runEffectTestPromise( + database + .select() + .from(outboxDeliveries) + .innerJoin( + outboxMessages, + eq(outboxMessages.outboxMessageId, outboxDeliveries.outboxMessageId), + ) + .where(eq(outboxMessages.tenantId, tenantId)), + ); + assert.equal( + deliveries.every((row) => row.outbox_deliveries.status === 'done'), + true, + ); + assert.equal( + deliveries.every((row) => row.outbox_deliveries.claimedBy === null), + true, + ); }); }); void test('schedules bounded retry, dead-letters exhaustion, stores safe errors, and never checkpoints failure', async () => { - await withDatabase(async (database) => { - const tenantId = await insertTenant(database); - try { - await runEffectTestPromise(activateConsumer(database, tenantId)); - await insertMessage(database, tenantId); - const registration = makeWorker('consumer.retry-proof', { maxAttempts: 2 }); - const repository = makeOutboxRepository(database); - const now = advanceDate(await runEffectTestPromise(DateTime.nowAsDate), 1000); - await runEffectTestPromise(repository.matchUnmatched([subscriptionOf(registration)], now)); - const first = Option.getOrNull(await claimNext(repository, [registration], 'runtime-a', now)); - assert.ok(first); - assert.equal( - await runEffectTestPromise( - repository.fail(first, ' safe\nretry\tmessage ', advanceDate(now, 1)), - ), - 'pending', - ); - assert.equal( - Option.getOrNull( - await claimNext(repository, [registration], 'runtime-b', advanceDate(now, 999)), - ), - null, - ); - const second = Option.getOrNull( - await claimNext(repository, [registration], 'runtime-b', advanceDate(now, 1001)), - ); - assert.ok(second); - assert.equal( - await runEffectTestPromise( - repository.fail(second, 'terminal safe failure', advanceDate(now, 1002)), - ), - 'dead', - ); - const [delivery] = await runEffectTestPromise( - database - .select() - .from(outboxDeliveries) - .where(eq(outboxDeliveries.outboxDeliveryId, second.deliveryId)), - ); - assert.equal(delivery?.status, 'dead'); - assert.equal(delivery?.attemptsCount, 2); - const attempts = await runEffectTestPromise( - database - .select() - .from(outboxAttempts) - .where(eq(outboxAttempts.outboxDeliveryId, second.deliveryId)) - .orderBy(asc(outboxAttempts.startedAt)), - ); - assert.deepEqual( - attempts.map(({ errorMessage }) => errorMessage), - ['safe retry message', 'terminal safe failure'], - ); - assert.deepEqual( - await runEffectTestPromise( - database.select().from(workerCheckpoints).where(eq(workerCheckpoints.tenantId, tenantId)), - ), - [], - ); - } finally { - await cleanupTenant(database, tenantId); - } + await withTenant(async (database, tenantId) => { + const { now, registration, repository } = await matchedWorker( + database, + tenantId, + 'consumer.retry-proof', + { maxAttempts: 2 }, + ); + const first = Option.getOrNull(await claimNext(repository, [registration], 'runtime-a', now)); + assert.ok(first); + assert.equal( + await runEffectTestPromise( + repository.fail(first, ' safe\nretry\tmessage ', advanceDate(now, 1)), + ), + 'pending', + ); + assert.equal( + Option.getOrNull( + await claimNext(repository, [registration], 'runtime-b', advanceDate(now, 999)), + ), + null, + ); + const second = Option.getOrNull( + await claimNext(repository, [registration], 'runtime-b', advanceDate(now, 1001)), + ); + assert.ok(second); + assert.equal( + await runEffectTestPromise( + repository.fail(second, 'terminal safe failure', advanceDate(now, 1002)), + ), + 'dead', + ); + const [delivery] = await runEffectTestPromise( + database + .select() + .from(outboxDeliveries) + .where(eq(outboxDeliveries.outboxDeliveryId, second.deliveryId)), + ); + assert.equal(delivery?.status, 'dead'); + assert.equal(delivery?.attemptsCount, 2); + const attempts = await runEffectTestPromise( + database + .select() + .from(outboxAttempts) + .where(eq(outboxAttempts.outboxDeliveryId, second.deliveryId)) + .orderBy(asc(outboxAttempts.startedAt)), + ); + assert.deepEqual( + attempts.map(({ errorMessage }) => errorMessage), + ['safe retry message', 'terminal safe failure'], + ); + assert.deepEqual( + await runEffectTestPromise( + database.select().from(workerCheckpoints).where(eq(workerCheckpoints.tenantId, tenantId)), + ), + [], + ); }); }); diff --git a/app/packages/core-runtime/tests/integration/principal-management.test.ts b/app/packages/core-runtime/tests/integration/principal-management.test.ts index e945ded5d..f46953555 100644 --- a/app/packages/core-runtime/tests/integration/principal-management.test.ts +++ b/app/packages/core-runtime/tests/integration/principal-management.test.ts @@ -20,6 +20,7 @@ import { import { loadDatabaseConfig } from '../../src/db/config.ts'; import { coreRelations, principalAuthBindings, principals, tenants } from '../../src/db/schema.ts'; import { makeTestDatabaseFromPool } from '../support/database.ts'; +import { purgeFixtureRows } from '../support/fixture-cleanup.ts'; import { runEffectTestSync as runNativeSync } from '../support/effect-runtime.ts'; const nativeDatabaseScope = runNativeSync(NativeScope.make()); @@ -36,15 +37,13 @@ void test('persists managed key lifecycle without credential material and enforc makeTestDatabaseFromPool(pool, coreRelations).pipe(NativeScope.provide(nativeDatabaseScope)), ); const cleanup = async () => { - await runEffectTestPromise( + await purgeFixtureRows([ database .delete(principalAuthBindings) .where(eq(principalAuthBindings.providerSubjectId, providerKeyId)), - ); - await runEffectTestPromise( database.delete(principals).where(eq(principals.tenantId, tenantId)), - ); - await runEffectTestPromise(database.delete(tenants).where(eq(tenants.tenantId, tenantId))); + database.delete(tenants).where(eq(tenants.tenantId, tenantId)), + ]); }; try { diff --git a/app/packages/core-runtime/tests/integration/principal-resolver.test.ts b/app/packages/core-runtime/tests/integration/principal-resolver.test.ts index 8d5adeab2..8b4e9a278 100644 --- a/app/packages/core-runtime/tests/integration/principal-resolver.test.ts +++ b/app/packages/core-runtime/tests/integration/principal-resolver.test.ts @@ -1,25 +1,13 @@ -import { - makeEffectTestCallback as nativeTestCallback, - makeEffectTestCallback, -} from '@app/core-runtime/testing/effect-runtime'; +import { makeEffectTestCallback } from '@app/core-runtime/testing/effect-runtime'; import { and, eq } from 'drizzle-orm'; -import { DateTime, Effect, Exit as NativeExit, Scope as NativeScope } from 'effect'; +import { DateTime, Effect } from 'effect'; import assert from 'node:assert/strict'; -import test, { after as afterNativeDatabase } from 'node:test'; -import { Pool } from 'pg'; +import test from 'node:test'; import { makePrincipalResolver } from '../../src/auth/principal-resolver.ts'; import { loadDatabaseConfig } from '../../src/db/config.ts'; -import { coreRelations, principalAuthBindings, principals, tenants } from '../../src/db/schema.ts'; -import { makeTestDatabaseFromPool } from '../support/database.ts'; -import { runEffectTestSync as runNativeSync } from '../support/effect-runtime.ts'; - -const nativeDatabaseScope = runNativeSync(NativeScope.make()); -const databaseEffect = (operation: () => PromiseLike) => - Effect.promise(() => operation()); -afterNativeDatabase( - NativeScope.close(nativeDatabaseScope, NativeExit.void).pipe(nativeTestCallback), -); +import { principalAuthBindings, principals, tenants } from '../../src/db/schema.ts'; +import { makeCoreDatabase } from '../../src/db/client.ts'; const tenantOne = '10000000-0000-4000-8000-000000000001'; const tenantTwo = '10000000-0000-4000-8000-000000000002'; @@ -27,18 +15,11 @@ const principalOne = '20000000-0000-4000-8000-000000000001'; const principalTwo = '20000000-0000-4000-8000-000000000002'; const subject = 'better-auth-integration-subject'; -const effectTest = (name: string, effect: Effect.Effect): void => { - test(name, makeEffectTestCallback(effect)); -}; - -effectTest( +test( 'lists and selects multiple tenant-scoped principals and fails closed after access changes', Effect.gen(function* principalResolverIntegration() { const configuration = yield* loadDatabaseConfig(); - const pool = new Pool({ connectionString: configuration.connectionString }); - const database = yield* makeTestDatabaseFromPool(pool, coreRelations).pipe( - NativeScope.provide(nativeDatabaseScope), - ); + const { executor: database } = yield* makeCoreDatabase(configuration); const resolver = makePrincipalResolver({ executor: database }); const cleanup = Effect.gen(function* cleanPrincipalResolverFixtures() { yield* database @@ -160,9 +141,6 @@ effectTest( resolver.resolveBetterAuthUserForTenant(subject, tenantOne), ); assert.equal(inactiveTenant._tag, 'TenantInactiveError'); - }).pipe( - Effect.ensuring(cleanup.pipe(Effect.orDie)), - Effect.ensuring(databaseEffect(pool.end.bind(pool)).pipe(Effect.orDie)), - ); - }), + }).pipe(Effect.ensuring(cleanup.pipe(Effect.orDie))); + }).pipe(Effect.scoped, makeEffectTestCallback), ); diff --git a/app/packages/core-runtime/tests/integration/tenant-module-state.test.ts b/app/packages/core-runtime/tests/integration/tenant-module-state.test.ts index 9b133d3b9..6a7238977 100644 --- a/app/packages/core-runtime/tests/integration/tenant-module-state.test.ts +++ b/app/packages/core-runtime/tests/integration/tenant-module-state.test.ts @@ -1,3 +1,4 @@ +import { makeInstalledCatalogFixture as catalogFrom } from '../support/installed-catalog.ts'; import { makeModuleContractFixture } from '../../src/testing/module-contract.ts'; import { SqlError, UnknownError } from 'effect/unstable/sql/SqlError'; import { runEffectTestPromise } from '@app/core-runtime/testing/effect-runtime'; @@ -59,41 +60,12 @@ const installedContract = (moduleId: string): OntosModuleDeploymentContract => ], }); -const noInstalledContracts: readonly OntosModuleDeploymentContract[] = Object.freeze([]); - +// State-transition tests deliberately accept arbitrary module IDs without discovery. const installedCatalog: InstalledModuleCatalog = Object.freeze({ - contracts: noInstalledContracts, - deploymentAppIds: Object.freeze([]), - deploymentStatuses: Object.freeze([]), - getByDeploymentAppId: (appId: string) => - noInstalledContracts.find(({ deployment }) => deployment.appId === appId), - getByModuleId: (moduleId: string) => installedContract(moduleId), - moduleIds: Object.freeze([]), - outboxSubscriptions: Object.freeze([]), + ...catalogFrom(), + getByModuleId: installedContract, }); -const catalogFrom = ( - ...contracts: readonly OntosModuleDeploymentContract[] -): InstalledModuleCatalog => { - const byModuleId = new Map(contracts.map((item) => [item.manifest.module.id, item])); - return Object.freeze({ - contracts: Object.freeze([...contracts]), - deploymentAppIds: Object.freeze(contracts.map(({ deployment }) => deployment.appId)), - deploymentStatuses: Object.freeze( - contracts.map((contract) => ({ - appId: contract.deployment.appId, - moduleId: contract.manifest.module.id, - status: 'available' as const, - })), - ), - getByDeploymentAppId: (appId: string) => - contracts.find(({ deployment }) => deployment.appId === appId), - getByModuleId: (moduleId: string) => byModuleId.get(moduleId), - moduleIds: Object.freeze(contracts.map(({ manifest }) => manifest.module.id)), - outboxSubscriptions: Object.freeze([]), - }); -}; - const withDatabase = ( operation: ( database: DatabaseService, @@ -121,24 +93,19 @@ const effectTest = (name: string, effect: Effect.Effect Effect.gen(function* cleanTenantModuleStateFixtures() { - yield* database.executor - .delete(dataAccessEvents) - .where(inArray(dataAccessEvents.tenantId, tenantIds)); - yield* database.executor.delete(auditEvents).where(inArray(auditEvents.tenantId, tenantIds)); - yield* database.executor - .delete(tenantModuleStateChanges) - .where(inArray(tenantModuleStateChanges.tenantId, tenantIds)); - yield* database.executor - .delete(tenantModuleStates) - .where(inArray(tenantModuleStates.tenantId, tenantIds)); - yield* database.executor - .delete(actionInvocations) - .where(inArray(actionInvocations.tenantId, tenantIds)); - yield* database.executor - .delete(principalAuthBindings) - .where(inArray(principalAuthBindings.tenantId, tenantIds)); - yield* database.executor.delete(principals).where(inArray(principals.tenantId, tenantIds)); - yield* database.executor.delete(tenants).where(inArray(tenants.tenantId, tenantIds)); + // Keep dependent evidence ahead of its referenced identity rows. + for (const table of [ + dataAccessEvents, + auditEvents, + tenantModuleStateChanges, + tenantModuleStates, + actionInvocations, + principalAuthBindings, + principals, + tenants, + ]) { + yield* database.executor.delete(table).where(inArray(table.tenantId, tenantIds)); + } }), ); diff --git a/app/packages/core-runtime/tests/support/fixture-cleanup.ts b/app/packages/core-runtime/tests/support/fixture-cleanup.ts new file mode 100644 index 000000000..793f4b6b9 --- /dev/null +++ b/app/packages/core-runtime/tests/support/fixture-cleanup.ts @@ -0,0 +1,10 @@ +import { Effect } from 'effect'; +import { runEffectTestPromise } from './effect-runtime.ts'; + +/** + * Runs fixture deletions in call order so every child row drops before its parent, failing on + * the first deletion that rejects. Callers build the delete Effects inline, which keeps the + * owned table order explicit at the call site instead of behind a generic cascade. + */ +export const purgeFixtureRows = (deletions: readonly Effect.Effect[]): Promise => + runEffectTestPromise(Effect.all(deletions, { discard: true })); diff --git a/app/packages/core-runtime/tests/support/installed-catalog.ts b/app/packages/core-runtime/tests/support/installed-catalog.ts new file mode 100644 index 000000000..1d636d587 --- /dev/null +++ b/app/packages/core-runtime/tests/support/installed-catalog.ts @@ -0,0 +1,23 @@ +import type { InstalledModuleCatalog, OntosModuleDeploymentContract } from '../../src/index.ts'; + +export const makeInstalledCatalogFixture = ( + ...contracts: readonly OntosModuleDeploymentContract[] +): InstalledModuleCatalog => { + const byModuleId = new Map(contracts.map((item) => [item.manifest.module.id, item])); + return Object.freeze({ + contracts: Object.freeze([...contracts]), + deploymentAppIds: Object.freeze(contracts.map(({ deployment }) => deployment.appId)), + deploymentStatuses: Object.freeze( + contracts.map((contract) => ({ + appId: contract.deployment.appId, + moduleId: contract.manifest.module.id, + status: 'available' as const, + })), + ), + getByDeploymentAppId: (appId: string) => + contracts.find(({ deployment }) => deployment.appId === appId), + getByModuleId: (moduleId: string) => byModuleId.get(moduleId), + moduleIds: Object.freeze(contracts.map(({ manifest }) => manifest.module.id)), + outboxSubscriptions: Object.freeze([]), + }); +}; diff --git a/app/packages/core-runtime/tests/unit/action-testing-harness.test.ts b/app/packages/core-runtime/tests/unit/action-testing-harness.test.ts index b388d83bd..a97b7e886 100644 --- a/app/packages/core-runtime/tests/unit/action-testing-harness.test.ts +++ b/app/packages/core-runtime/tests/unit/action-testing-harness.test.ts @@ -4,6 +4,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { Effect, Schema } from 'effect'; import { defineAction } from '../../src/actions/definition.ts'; +import { defineGlobalPolicy, denyPolicy } from '../../src/actions/policy.ts'; import { ACTION_RUNTIME_STAGES } from '../../src/actions/runtime.ts'; import { defineTenantModuleEntrypoint } from '../../src/modules/module-entrypoint.ts'; import { bindActionTestServices, makeActionTestHarness } from '../../src/testing/actions.ts'; @@ -94,6 +95,8 @@ test('defaults authorization closed and never starts a transaction for a denial' assert.equal(snapshot.invocations.length, 1); assert.equal(snapshot.invocations[0]?.status, 'rejected'); assert.equal(snapshot.permissionDenials.length, 1); + assert.equal(snapshot.policyDenials.length, 0); + assert.equal(snapshot.invocations[0]?.completedAt?.getTime(), 0); assert.equal(snapshot.transactionCount, 0); assert.equal(snapshot.stages.includes('handler_executed'), false); }); @@ -175,3 +178,38 @@ test('rejects missing idempotency before creating an invocation', async () => { assert.equal(failure._tag, 'ActionIdempotencyKeyRequired'); assert.equal(harness.snapshot().invocations.length, 0); }); + +test('persists policy denials separately from permission denials before handler execution', async () => + await runEffectTestPromise( + Effect.gen(function* policyDenialSnapshot() { + const registration = defineAction( + { + ...lifecycleAction.descriptor, + policies: [ + defineGlobalPolicy({ + evaluate: () => Effect.fail(denyPolicy('counter_locked', 'Counter is locked')), + policyKey: 'global.counter-locked.v1', + }), + ], + }, + () => Effect.die('A denied policy must not execute the handler'), + ); + const harness = makeActionTestHarness({ + actionPermission: 'allowed', + tenantPermission: 'allowed', + }); + yield* harness.runtime.runAction({ ...request, registration }).pipe(Effect.flip); + const snapshot = harness.snapshot(); + assert.equal(snapshot.policyDenials.length, 1); + assert.equal(snapshot.permissionDenials.length, 0); + assert.equal(snapshot.invocations[0]?.status, 'rejected'); + assert.equal(snapshot.invocations[0]?.completedAt?.getTime(), 0); + assert.equal( + snapshot.policyDenials[0]?.actionInvocationId, + snapshot.invocations[0]?.actionInvocationId, + ); + assert.equal(snapshot.transactionCount, 0); + assert.equal(snapshot.committed.length, 0); + assert.equal(snapshot.stages.includes('handler_executed'), false); + }), + )); diff --git a/app/packages/core-runtime/tests/unit/context-access.test.ts b/app/packages/core-runtime/tests/unit/context-access.test.ts index 13d4bb107..183c6114f 100644 --- a/app/packages/core-runtime/tests/unit/context-access.test.ts +++ b/app/packages/core-runtime/tests/unit/context-access.test.ts @@ -113,21 +113,26 @@ effectTest( }), ); +const makeAllowedPermissionRecorder = () => { + const observed: string[] = []; + const service = makeContextAccess( + makeClient((request) => + Effect.sync(() => { + observed.push(...request.items.map(({ permission }) => permission)); + return responseFor( + request, + request.items.map(() => v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION), + ); + }), + ), + ); + return { observed, service }; +}; + effectTest( 'forwards every closed tenant permission key without widening it', Effect.gen(function* forwardsTenantPermissionKeys() { - const observed: string[] = []; - const service = makeContextAccess( - makeClient((request) => - Effect.sync(() => { - observed.push(...request.items.map(({ permission }) => permission)); - return responseFor( - request, - request.items.map(() => v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION), - ); - }), - ), - ); + const { observed, service } = makeAllowedPermissionRecorder(); yield* Effect.all( TENANT_PERMISSION_KEYS.map((permission) => @@ -147,18 +152,7 @@ effectTest( effectTest( 'forwards every closed Legal Entity permission key without widening it', Effect.gen(function* forwardsLegalEntityPermissionKeys() { - const observed: string[] = []; - const service = makeContextAccess( - makeClient((request) => - Effect.sync(() => { - observed.push(...request.items.map(({ permission }) => permission)); - return responseFor( - request, - request.items.map(() => v1.CheckPermissionResponse_Permissionship.HAS_PERMISSION), - ); - }), - ), - ); + const { observed, service } = makeAllowedPermissionRecorder(); yield* Effect.all( LEGAL_ENTITY_PERMISSION_KEYS.map((permission) => diff --git a/app/packages/core-runtime/tests/unit/module-catalog.test.ts b/app/packages/core-runtime/tests/unit/module-catalog.test.ts index 22f22bd4f..a35dae6a7 100644 --- a/app/packages/core-runtime/tests/unit/module-catalog.test.ts +++ b/app/packages/core-runtime/tests/unit/module-catalog.test.ts @@ -226,58 +226,47 @@ void test('resolves healthy, incompatible, and unreachable deployments independe ]); }); -void test('excludes every contradictory claimant while preserving unrelated deployments', () => { - const catalog = resolveInstalledModuleCatalog([ - { - contract: contract('documents-center', 'shared.module'), - expectedAppId: 'documents-center', - outcome: 'fetched', - }, - { - contract: contract('property-registry', 'shared.module'), - expectedAppId: 'property-registry', - outcome: 'fetched', - }, - { - contract: contract('reporting-center', 'reporting.center'), - expectedAppId: 'reporting-center', - outcome: 'fetched', - }, - ]); - - assert.deepEqual(catalog.moduleIds, ['reporting.center']); - assert.deepEqual(catalog.deploymentStatuses, [ - { appId: 'documents-center', reason: 'incompatible', status: 'unavailable' }, - { appId: 'property-registry', reason: 'incompatible', status: 'unavailable' }, - { appId: 'reporting-center', moduleId: 'reporting.center', status: 'available' }, - ]); -}); - -void test('rejects duplicate deployment identities from tolerant candidate promotion', () => { - const catalog = resolveInstalledModuleCatalog([ - { - contract: contract('property-registry', 'property.registry'), - expectedAppId: 'property-registry', - outcome: 'fetched', - }, - { - contract: contract('property-registry', 'property.duplicate'), - expectedAppId: 'property-registry', - outcome: 'fetched', - }, - { - contract: contract('documents-center', 'documents.center'), - expectedAppId: 'documents-center', - outcome: 'fetched', - }, - ]); - - assert.deepEqual(catalog.moduleIds, ['documents.center']); - assert.deepEqual(catalog.deploymentStatuses, [ - { appId: 'documents-center', moduleId: 'documents.center', status: 'available' }, - { appId: 'property-registry', reason: 'incompatible', status: 'unavailable' }, - ]); -}); +for (const scenario of [ + { + identities: [ + ['documents-center', 'shared.module'], + ['property-registry', 'shared.module'], + ['reporting-center', 'reporting.center'], + ], + moduleIds: ['reporting.center'], + name: 'excludes every contradictory claimant while preserving unrelated deployments', + statuses: [ + { appId: 'documents-center', reason: 'incompatible', status: 'unavailable' }, + { appId: 'property-registry', reason: 'incompatible', status: 'unavailable' }, + { appId: 'reporting-center', moduleId: 'reporting.center', status: 'available' }, + ], + }, + { + identities: [ + ['property-registry', 'property.registry'], + ['property-registry', 'property.duplicate'], + ['documents-center', 'documents.center'], + ], + moduleIds: ['documents.center'], + name: 'rejects duplicate deployment identities from tolerant candidate promotion', + statuses: [ + { appId: 'documents-center', moduleId: 'documents.center', status: 'available' }, + { appId: 'property-registry', reason: 'incompatible', status: 'unavailable' }, + ], + }, +] as const) { + void test(scenario.name, () => { + const catalog = resolveInstalledModuleCatalog( + scenario.identities.map(([appId, moduleId]) => ({ + contract: contract(appId, moduleId), + expectedAppId: appId, + outcome: 'fetched', + })), + ); + assert.deepEqual(catalog.moduleIds, scenario.moduleIds); + assert.deepEqual(catalog.deploymentStatuses, scenario.statuses); + }); +} void test('keeps authoritative revocation ahead of a stale fetched candidate', () => { const catalog = resolveInstalledModuleCatalog([ diff --git a/app/packages/core-runtime/tests/unit/read-runtime.test.ts b/app/packages/core-runtime/tests/unit/read-runtime.test.ts index 17c24a33f..e4fca1acb 100644 --- a/app/packages/core-runtime/tests/unit/read-runtime.test.ts +++ b/app/packages/core-runtime/tests/unit/read-runtime.test.ts @@ -716,124 +716,101 @@ test('rejects generic tenant access as an alternative permission target', async assert.equal(handlerCalls, 0); }); -test('never treats missing Legal Entity scope as an allowed alternative', async () => { - let handlerCalls = 0; - const composed = defineRead( - { - ...registration().descriptor, - permissionTarget: 'tenant', - }, - () => { - handlerCalls += 1; - return Effect.succeed({ evidence: { resultCount: 0 }, result: [] }); - }, - () => Effect.succeed({}), - () => ({ - kind: 'any_of', - targets: [ - { kind: 'tenant', permission: 'manage_party_identity' }, - { kind: 'module', moduleId: 'party.registry' }, - ], - }), - ); - const failure = await runEffectTestPromise( - Effect.flip( - makeHarness({ tenantPermissionDecision: 'denied' }).runtime.runRead({ - input: {}, - principal: scope, - registration: composed, - transport: { correlationId: scope.correlationId }, +for (const scenario of [ + { + expectedFailure: 'ReadPermissionUnavailable', + name: 'never treats missing Legal Entity scope as an allowed alternative', + permission: 'manage_party_identity', + resultTargets: null, + tenantPermissionDecision: 'denied', + }, + { + expectedFailure: 'ReadHandlerExecutionError', + name: 'rejects alternative targets whenever result authorization cannot preserve them', + permission: 'read_party_identity', + resultTargets: () => [], + tenantPermissionDecision: 'allowed', + }, +] as const) { + test(scenario.name, async () => { + let handlerCalls = 0; + const alternativeRead = defineRead( + { ...registration().descriptor, permissionTarget: 'tenant' }, + () => { + handlerCalls += 1; + return Effect.succeed({ evidence: { resultCount: 0 }, result: [] }); + }, + () => Effect.succeed({}), + () => ({ + kind: 'any_of', + targets: [ + { kind: 'tenant', permission: scenario.permission }, + { kind: 'module', moduleId: 'party.registry' }, + ], }), - ), - ); - assert.equal(failure._tag, 'ReadPermissionUnavailable'); - assert.equal(handlerCalls, 0); -}); + scenario.resultTargets ?? undefined, + ); + const failure = await runEffectTestPromise( + Effect.flip( + makeHarness({ + tenantPermissionDecision: scenario.tenantPermissionDecision, + }).runtime.runRead({ + input: {}, + principal: scope, + registration: alternativeRead, + transport: { correlationId: scope.correlationId }, + }), + ), + ); + assert.equal(failure._tag, scenario.expectedFailure); + assert.equal(handlerCalls, 0); + }); +} -test('rejects alternative targets whenever result authorization cannot preserve them', async () => { - let handlerCalls = 0; - const search = defineRead( - { - ...registration().descriptor, - permissionTarget: 'tenant', - }, - () => { - handlerCalls += 1; - return Effect.succeed({ evidence: { resultCount: 0 }, result: [] }); - }, - () => Effect.succeed({}), - () => ({ - kind: 'any_of', - targets: [ - { kind: 'tenant', permission: 'read_party_identity' }, - { kind: 'module', moduleId: 'party.registry' }, - ], +for (const scenario of [ + { + expectedEvidence: 0, + expectedFailure: 'ReadEvidenceValidationError', + name: 'rejects handler-controlled hashes in metadata-only evidence', + outcome: Effect.succeed({ + evidence: { queryHash: 'raw query text', resultCount: 1 }, + result: [], }), - () => [], - ); - const failure = await runEffectTestPromise( - Effect.flip( - makeHarness({ tenantPermissionDecision: 'allowed' }).runtime.runRead({ - input: {}, - principal: scope, - registration: search, - transport: { correlationId: scope.correlationId }, + }, + { + expectedEvidence: 1, + expectedFailure: 'ReadPermissionDenied', + name: 'persists late definite denial after rolling back the owner transaction', + outcome: Effect.fail( + new ReadPermissionDenied({ + code: 'read_permission_denied', + reason: 'A late provider target check denied this read', }), ), - ); - assert.equal(failure._tag, 'ReadHandlerExecutionError'); - assert.equal(handlerCalls, 0); -}); - -test('rejects handler-controlled hashes in metadata-only evidence', async () => { - const harness = makeHarness(); - const unboundedEvidence = defineRead( - registration().descriptor, - () => Effect.succeed({ evidence: { queryHash: 'raw query text', resultCount: 1 }, result: [] }), - () => Effect.succeed({}), - () => ({ kind: 'module', moduleId: 'core.shell' }), - ); - const error = await runEffectTestPromise( - Effect.flip( - harness.runtime.runRead({ - input: {}, - principal: scope, - registration: unboundedEvidence, - transport: { correlationId: scope.correlationId }, - }), - ), - ); - assert.equal(error._tag, 'ReadEvidenceValidationError'); - assert.equal(harness.evidence(), 0); -}); - -void test('persists late definite denial after rolling back the owner transaction', async () => { - const harness = makeHarness(); - const lateDenial = defineRead( - registration().descriptor, - () => - Effect.fail( - new ReadPermissionDenied({ - code: 'read_permission_denied', - reason: 'A late provider target check denied this read', + }, +]) { + test(scenario.name, async () => { + const harness = makeHarness(); + const failingRead = defineRead( + registration().descriptor, + () => scenario.outcome, + () => Effect.succeed({}), + () => ({ kind: 'module', moduleId: 'core.shell' }), + ); + const error = await runEffectTestPromise( + Effect.flip( + harness.runtime.runRead({ + input: {}, + principal: scope, + registration: failingRead, + transport: { correlationId: scope.correlationId }, }), ), - () => Effect.succeed({}), - () => ({ kind: 'module', moduleId: 'core.shell' }), - ); - const error = await runEffectTestPromise( - Effect.flip( - harness.runtime.runRead({ - input: {}, - principal: scope, - registration: lateDenial, - transport: { correlationId: scope.correlationId }, - }), - ), - ); - assert.equal(error._tag, 'ReadPermissionDenied'); - assert.equal(harness.evidence(), 1); -}); + ); + assert.equal(error._tag, scenario.expectedFailure); + assert.equal(harness.evidence(), scenario.expectedEvidence); + }); +} void test('does not release generated search candidates denied by result-level authorization', async () => { const legalEntityId = '00000000-0000-4000-8000-000000000004'; diff --git a/app/packages/core-runtime/tests/unit/tenant-module-state.test.ts b/app/packages/core-runtime/tests/unit/tenant-module-state.test.ts index 663435e21..9cc4ba77c 100644 --- a/app/packages/core-runtime/tests/unit/tenant-module-state.test.ts +++ b/app/packages/core-runtime/tests/unit/tenant-module-state.test.ts @@ -1,3 +1,4 @@ +import { makeInstalledCatalogFixture as catalog } from '../support/installed-catalog.ts'; import { makeModuleContractFixture } from '../../src/testing/module-contract.ts'; import { runEffectTestPromise } from '@app/core-runtime/testing/effect-runtime'; import assert from 'node:assert/strict'; @@ -5,7 +6,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { Effect, Schema } from 'effect'; import { changeTenantModuleStateAction } from '../../src/modules/actions/change-tenant-module-state.action.ts'; -import type { InstalledModuleCatalog, OntosModuleDeploymentContract } from '../../src/index.ts'; +import type { OntosModuleDeploymentContract } from '../../src/index.ts'; import { TenantModuleStateConcurrentChangeError, TenantModuleStatePersistenceUnavailableError, @@ -38,28 +39,6 @@ const contract = ( supportedStates, }); -const catalog = ( - ...contracts: readonly OntosModuleDeploymentContract[] -): InstalledModuleCatalog => { - const byModule = new Map(contracts.map((item) => [item.manifest.module.id, item])); - return Object.freeze({ - contracts: Object.freeze([...contracts]), - deploymentAppIds: Object.freeze(contracts.map(({ deployment }) => deployment.appId)), - deploymentStatuses: Object.freeze( - contracts.map((moduleContract) => ({ - appId: moduleContract.deployment.appId, - moduleId: moduleContract.manifest.module.id, - status: 'available' as const, - })), - ), - getByDeploymentAppId: (appId: string) => - contracts.find(({ deployment }) => deployment.appId === appId), - getByModuleId: (moduleId: string) => byModule.get(moduleId), - moduleIds: Object.freeze(contracts.map(({ manifest }) => manifest.module.id)), - outboxSubscriptions: Object.freeze([]), - }); -}; - void test('uses one canonical tenant module state schema', async () => { const decodedStates = await Promise.all( TENANT_MODULE_STATES.map( diff --git a/app/packages/shared-contracts/src/gateway-context.ts b/app/packages/shared-contracts/src/gateway-context.ts index 7383aa176..86da38575 100644 --- a/app/packages/shared-contracts/src/gateway-context.ts +++ b/app/packages/shared-contracts/src/gateway-context.ts @@ -8,7 +8,7 @@ import { Schema, makeEffectHttpApiClient, } from '@modern-js/plugin-bff/effect-client'; -import type { HttpApiClient, HttpClientError } from '@modern-js/plugin-bff/effect-client'; +import type { HttpClientError } from '@modern-js/plugin-bff/effect-client'; import { TrustedPrincipalContextSchema } from '@app/core-runtime/actions/principal-context'; import type { TrustedPrincipalContext } from '@app/core-runtime/actions/principal-context'; import { Context } from 'effect'; @@ -198,13 +198,6 @@ export const gatewayContextAuthorizationEntrypoints = [ }, ] as const; -type GatewayContextApiGroups = - typeof GatewayContextApi extends HttpApi.HttpApi ? Groups : never; - -type GatewayContextClient = HttpApiClient.Client< - Extract ->; - export interface GatewayContextClientOptions { readonly baseUrl?: string | URL; readonly cookie?: string; @@ -239,23 +232,17 @@ const gatewayContextClient = makeEffectHttpApiClient(GatewayContextApi, { ), }); -const invokeGatewayContextClient = ( - options: GatewayContextClientOptions, - operation: (client: GatewayContextClient) => Effect.Effect, -): Effect.Effect => - gatewayContextClient.pipe( - Effect.flatMap(operation), - Effect.provideService(GatewayContextRequestOptions, options), - ); - export const issueGatewayContext = ( payload: GatewayContextRequest, options: GatewayContextClientOptions = {}, ): GatewayContextClientEffect => Schema.decodeUnknownEffect(GatewayContextRequestSchema)(payload).pipe( Effect.flatMap((decodedPayload) => - invokeGatewayContextClient(options, (client) => - client.gatewayContext.issueGatewayContext({ payload: decodedPayload }), + gatewayContextClient.pipe( + Effect.flatMap((client) => + client.gatewayContext.issueGatewayContext({ payload: decodedPayload }), + ), ), ), + Effect.provideService(GatewayContextRequestOptions, options), ); diff --git a/app/packages/shared-contracts/tests/unit/client-runtime.test.ts b/app/packages/shared-contracts/tests/unit/client-runtime.test.ts index 6dec2750f..cea5c9ad8 100644 --- a/app/packages/shared-contracts/tests/unit/client-runtime.test.ts +++ b/app/packages/shared-contracts/tests/unit/client-runtime.test.ts @@ -247,34 +247,23 @@ test('keeps declared backend failures in the typed Effect error channel', async assert.deepEqual(outcome.failure, problem); }); -test('keeps transport failures in the typed Effect error channel', async () => { - const outcome = await Effect.runPromise( - makeEffectBffClient({ - api: RepresentativeApi, - defaultApiPrefix: 'https://owner.example/representative-api', - }).pipe( - Effect.flatMap((client) => client.representative.read({})), - Effect.result, - Effect.provideService(FetchHttpClient.Fetch, controlledTransportFailureFetch), - ), - ); - - assert.ok(Result.isFailure(outcome)); - assert.equal(outcome.failure._tag, 'HttpClientError'); -}); - -test('keeps response decoding failures in the typed Effect error channel', async () => { - const outcome = await Effect.runPromise( - makeEffectBffClient({ - api: RepresentativeApi, - defaultApiPrefix: 'https://owner.example/representative-api', - }).pipe( - Effect.flatMap((client) => client.representative.read({})), - Effect.result, - Effect.provideService(FetchHttpClient.Fetch, invalidResponseFetch), - ), - ); +for (const [failureKind, transport, expectedTag] of [ + ['transport', controlledTransportFailureFetch, 'HttpClientError'], + ['response decoding', invalidResponseFetch, 'SchemaError'], +] as const) { + test(`keeps ${failureKind} failures in the typed Effect error channel`, async () => { + const outcome = await Effect.runPromise( + makeEffectBffClient({ + api: RepresentativeApi, + defaultApiPrefix: 'https://owner.example/representative-api', + }).pipe( + Effect.flatMap((client) => client.representative.read({})), + Effect.result, + Effect.provideService(FetchHttpClient.Fetch, transport), + ), + ); - assert.ok(Result.isFailure(outcome)); - assert.equal(outcome.failure._tag, 'SchemaError'); -}); + assert.ok(Result.isFailure(outcome)); + assert.equal(outcome.failure._tag, expectedTag); + }); +} diff --git a/app/quality-audit/knip-model.mts b/app/quality-audit/knip-model.mts index da4a49b12..947c10875 100644 --- a/app/quality-audit/knip-model.mts +++ b/app/quality-audit/knip-model.mts @@ -46,6 +46,7 @@ const PackageSchema = Schema.Struct({ ), }), ), + 'zephyr:dependencies': Schema.optional(Schema.Record(Schema.String, Schema.String)), }); class KnipModelError extends Schema.TaggedError()('KnipModelError', { @@ -251,6 +252,60 @@ const sourceFiles = Effect.fn('QualityAudit.knipModelSourceFiles')(function* rea return files; }); +/** + * `zephyr:dependencies` maps a Module Federation remote alias to a versioned package + * reference such as `@app/party-registry@workspace:*`; the deployment platform resolves + * the package by that name, so the manifest entry is a real consumer of the dependency. + */ +const zephyrPackageName = (reference: string): string | undefined => { + const separator = reference.lastIndexOf('@'); + const name = separator > 0 ? reference.slice(0, separator) : reference; + return name.length === 0 ? undefined : name; +}; + +/** + * A package manifest names consumers no import graph can see: declared export leaves, the Modern + * module contract, and the Zephyr composition remotes the deployment platform resolves by package + * name. Each one is evidence that the referenced entry point or dependency is genuinely used. + */ +const manifestEvidence = ( + manifest: typeof PackageSchema.Type, + manifestFile: string, + workspace: string, +): readonly KnipModelEvidence[] => { + const facts: KnipModelEvidence[] = []; + for (const target of [ + ...exportLeaves(manifest.exports), + manifest.modernjs?.ontosModule?.manifest, + manifest.modernjs?.ontosModule?.registration, + ]) { + if (target !== undefined) { + facts.push({ + kind: 'entry', + line: 1, + reason: 'Declared package export or Modern module contract', + source: manifestFile, + target, + workspace, + }); + } + } + for (const [alias, reference] of Object.entries(manifest['zephyr:dependencies'] ?? {})) { + const target = zephyrPackageName(reference); + if (target !== undefined) { + facts.push({ + kind: 'dependency', + line: 1, + reason: `Zephyr composition dependency declared for the ${alias} remote`, + source: manifestFile, + target, + workspace, + }); + } + } + return facts; +}; + const evidenceAt = ( facts: SourceFacts, workspace: string, @@ -486,21 +541,6 @@ const validatorEvidence = (facts: SourceFacts): KnipModelEvidence[] => { return; } const [argument] = node.arguments; - if (node.callee.name === 'requiredShellWorkerCompositionPath') { - const target = staticString(argument, facts.variables); - if (target !== undefined) { - evidence.push( - evidenceAt( - facts, - '.', - 'file', - `${target}/src/routes/vertical-components.worker.tsx`, - node.start, - 'Workspace validator resolves the required shell worker composition', - ), - ); - } - } if (node.callee.name !== 'readText' || !isAppBuildTemplate(argument)) { return; } @@ -1123,22 +1163,8 @@ const workspaceModel = Effect.fn('QualityAudit.knipWorkspaceModel')(function* bu } evidence.push(fact); }; - const manifestTargets = [ - ...exportLeaves(manifest.exports), - manifest.modernjs?.ontosModule?.manifest, - manifest.modernjs?.ontosModule?.registration, - ]; - for (const target of manifestTargets) { - if (target !== undefined) { - add({ - kind: 'entry', - line: 1, - reason: 'Declared package export or Modern module contract', - source: manifestFile, - target, - workspace, - }); - } + for (const fact of manifestEvidence(manifest, manifestFile, workspace)) { + add(fact); } const ownedFiles = files.filter( (file) => diff --git a/app/scripts/assert-mf-types.mts b/app/scripts/assert-mf-types.mts index 030cce687..6880e6d0c 100644 --- a/app/scripts/assert-mf-types.mts +++ b/app/scripts/assert-mf-types.mts @@ -1,20 +1,14 @@ #!/usr/bin/env node import { NodeServices } from '@effect/platform-node'; -import { Effect, Schema } from 'effect'; +import { Effect } from 'effect'; import { runUltramodernScript, ultramodernExitCode } from './shared/ultramodern-command.mts'; - -class MfTypesAssertionError extends Schema.TaggedError()( - 'MfTypesAssertionError', - { reason: Schema.String }, -) {} - -const failure = (reason: string): MfTypesAssertionError => new MfTypesAssertionError({ reason }); +import { ultramodernCommandFailure } from './ultramodern-command-failure.mts'; const exit = await Effect.runPromiseExit( runUltramodernScript({ command: 'mf-types', directoryFailure: 'Unable to resolve the MF types wrapper directory', - failure, + failure: ultramodernCommandFailure, moduleUrl: import.meta.url, nodeExecutable: process.execPath, }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), diff --git a/app/scripts/boundary-source-structure.mts b/app/scripts/boundary-source-structure.mts new file mode 100644 index 000000000..d58604c53 --- /dev/null +++ b/app/scripts/boundary-source-structure.mts @@ -0,0 +1,110 @@ +const delimiterOpenings = new Map([ + ['(', '('], + [')', '('], + ['[', '['], + [']', '['], + ['{', '{'], + ['}', '{'], + ['<', '<'], + ['>', '<'], +]); + +/** Delimiter traversal over source whose comments and literals are already masked. + * Angle brackets are opt-in: declarations/parameters need generics, expressions + * must retain comparison operators. Arrow `=>` never closes a generic argument. + */ +export class DelimiterDepth { + private readonly depths = new Map(); + + update(character: string | undefined, previous?: string, angles = false): void { + if (character === undefined) { + return; + } + if (!angles && (character === '<' || character === '>')) { + return; + } + if (character === '>' && previous === '=') { + return; + } + const opening = delimiterOpenings.get(character); + if (opening === undefined) { + return; + } + const delta = character === opening ? 1 : -1; + this.depths.set(opening, (this.depths.get(opening) ?? 0) + delta); + } + + hasUnmatchedClose(): boolean { + return [...this.depths.values()].some((depth) => depth < 0); + } + + isTopLevel(): boolean { + return [...this.depths.values()].every((depth) => depth === 0); + } +} + +export const topLevelSeparators = ( + structure: string, + separators: string, + start = 0, + end = structure.length, + angles = false, +): readonly number[] => { + const depth = new DelimiterDepth(); + const positions: number[] = []; + for (let index = start; index < end; index += 1) { + depth.update(structure[index], structure[index - 1], angles); + if (depth.isTopLevel() && separators.includes(structure.charAt(index))) { + positions.push(index); + } + } + return positions; +}; + +export const matchingDelimiter = ( + structure: string, + start: number, + opening: string, + closing: string, +): number | undefined => { + let depth = 0; + for (let index = start; index < structure.length; index += 1) { + if (structure[index] === opening) { + depth += 1; + } + if (structure[index] === closing) { + depth -= 1; + if (depth === 0) { + return index; + } + } + } + return undefined; +}; + +export const separatedSource = ( + source: string, + separators: readonly number[], + start = 0, + end = source.length, +): readonly string[] => { + const entries: string[] = []; + let entryStart = start; + for (const index of [...separators, end]) { + entries.push(source.slice(entryStart, index).trim()); + entryStart = index + 1; + } + return entries; +}; + +export const toPascalCase = (value: string): string => + value + .split('-') + .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) + .join(''); + +/** Boundary bindings may arrive in PascalCase as well as canonical lowercase slugs. */ +export const toCamelCase = (value: string): string => { + const pascal = toPascalCase(value); + return `${pascal.slice(0, 1).toLowerCase()}${pascal.slice(1)}`; +}; diff --git a/app/scripts/check-module-entrypoint-boundaries.mts b/app/scripts/check-module-entrypoint-boundaries.mts index 07908b034..be54782af 100644 --- a/app/scripts/check-module-entrypoint-boundaries.mts +++ b/app/scripts/check-module-entrypoint-boundaries.mts @@ -650,7 +650,6 @@ const validateGovernedSource = (file: string, source: string) => }); interface GeneratedProviderLocation { - readonly isClient: boolean; readonly kind: 'report' | 'search'; readonly name: string; readonly vertical: string; @@ -667,45 +666,40 @@ const generatedProviderLocation = ( ) { return undefined; } - const clientMatch = - /^(?verticals\/[^/]+)\/src\/api\/(?[^/]+)-(?search|report)-client\.ts$/u.exec( - file, - )?.groups; - const contractMatch = - /^(?verticals\/[^/]+)\/shared\/apis\/(?[^/]+)-(?search|report)\.ts$/u.exec( - file, - )?.groups; - const providerMatch = - /^(?verticals\/[^/]+)\/src\/(?search|reports)\/(?[^/]+)\.provider\.ts$/u.exec( - file, - )?.groups; - const serverMatch = - /^(?verticals\/[^/]+)\/api\/(?[^/]+)-(?search|report)-server\.ts$/u.exec( - file, - )?.groups; - const provider = clientMatch ?? contractMatch ?? providerMatch ?? serverMatch; - if (provider?.name === undefined || provider.vertical === undefined) { - return undefined; + const locations = [ + /^(?verticals\/[^/]+)\/src\/api\/(?[^/]+)-(?search|report)-client\.ts$/u, + /^(?verticals\/[^/]+)\/shared\/apis\/(?[^/]+)-(?search|report)\.ts$/u, + /^(?verticals\/[^/]+)\/src\/(?search|reports)\/(?[^/]+)\.provider\.ts$/u, + /^(?verticals\/[^/]+)\/api\/(?[^/]+)-(?search|report)-server\.ts$/u, + ]; + for (const pattern of locations) { + const provider = pattern.exec(file)?.groups; + if (provider?.name !== undefined && provider.vertical !== undefined) { + return { + kind: provider.kind === 'search' ? 'search' : 'report', + name: provider.name, + vertical: provider.vertical, + }; + } } - return { - isClient: clientMatch !== undefined, - kind: provider.kind === 'report' || provider.directory === 'reports' ? 'report' : 'search', - name: provider.name, - vertical: provider.vertical, - }; + return undefined; }; +const sourceOrEmpty = (sourceMap: ReadonlyMap, file: string): string => + sourceMap.get(file) ?? ''; + +const providerModuleId = (manifest: string): string => + /@ontos-module-id (?[a-z0-9]+(?:\.[a-z0-9]+)*)/u.exec(manifest)?.groups?.moduleId ?? ''; + const validateGeneratedProviderClient = ( sourceMap: ReadonlyMap, file: string, deploymentAppId: string, - discoveredProvider?: Omit, + discoveredProvider?: GeneratedProviderLocation, ) => Effect.gen(function* validateGeneratedProviderClientEffect() { const provider = - discoveredProvider === undefined - ? generatedProviderLocation(file, sourceMap.get(file) ?? '') - : { ...discoveredProvider, isClient: false }; + discoveredProvider ?? generatedProviderLocation(file, sourceOrEmpty(sourceMap, file)); if (provider === undefined) { return; } @@ -726,19 +720,17 @@ const validateGeneratedProviderClient = ( }; const generatedHeader = `// @generated by OntOS Codesmith Governed Contribution v1\n// @ontos-contribution-kind ${kindDetails.contributionKind}\n`; const contractPath = `${vertical}/shared/apis/${name}-${kind}.ts`; - const contractSource = sourceMap.get(contractPath) ?? ''; + const contractSource = sourceOrEmpty(sourceMap, contractPath); const clientPath = `${vertical}/src/api/${name}-${kind}-client.ts`; - const clientSource = sourceMap.get(clientPath) ?? ''; + const clientSource = sourceOrEmpty(sourceMap, clientPath); const providerPath = `${vertical}/src/${kindDetails.directory}/${name}.provider.ts`; - const providerSource = sourceMap.get(providerPath) ?? ''; + const providerSource = sourceOrEmpty(sourceMap, providerPath); const serverPath = `${vertical}/api/${name}-${kind}-server.ts`; - const serverSource = sourceMap.get(serverPath) ?? ''; - const manifest = sourceMap.get(`${vertical}/vertical.manifest.ts`) ?? ''; - const registration = sourceMap.get(`${vertical}/vertical.registration.ts`) ?? ''; - const gateway = sourceMap.get(`${vertical}/src/api/action-gateway.ts`) ?? ''; - const moduleId = - /@ontos-module-id (?[a-z0-9]+(?:\.[a-z0-9]+)*)/u.exec(manifest)?.groups?.moduleId ?? - ''; + const serverSource = sourceOrEmpty(sourceMap, serverPath); + const manifest = sourceOrEmpty(sourceMap, `${vertical}/vertical.manifest.ts`); + const registration = sourceOrEmpty(sourceMap, `${vertical}/vertical.registration.ts`); + const gateway = sourceOrEmpty(sourceMap, `${vertical}/src/api/action-gateway.ts`); + const moduleId = providerModuleId(manifest); const type = toPascalCase(name); const camel = `${type.slice(0, 1).toLowerCase()}${type.slice(1)}`; const ownerApiValue = `${type}${kindDetails.apiSuffix}Api`; @@ -770,7 +762,7 @@ const validateGeneratedProviderClient = ( ].every(Boolean); if (!hasCompleteProviderSeam) { yield* fail( - provider.isClient ? clientPath : file, + file, 'generated search and report clients require the shared client runtime, owner-local contract, operation gateway, authorization, and correlation metadata', ); } @@ -789,11 +781,9 @@ const validatePublishedProviderIdentities = ( ) { return; } - const manifest = state.sourceMap.get(`${verticalPath}/vertical.manifest.ts`) ?? ''; - const registration = state.sourceMap.get(`${verticalPath}/vertical.registration.ts`) ?? ''; - const moduleId = - /@ontos-module-id (?[a-z0-9]+(?:\.[a-z0-9]+)*)/u.exec(manifest)?.groups?.moduleId ?? - ''; + const manifest = sourceOrEmpty(state.sourceMap, `${verticalPath}/vertical.manifest.ts`); + const registration = sourceOrEmpty(state.sourceMap, `${verticalPath}/vertical.registration.ts`); + const moduleId = providerModuleId(manifest); const deploymentAppId = state.owners.get(verticalPath) ?? file.split('/')[1] ?? ''; if (!hasExactGeneratedProviderIdentityTopology(manifest, registration, moduleId)) { yield* fail( @@ -1046,7 +1036,7 @@ const validateManifestKeys = ( sourceKeys: ReadonlySet, ) => Effect.gen(function* validateManifestKeysEffect() { - const manifestSource = state.sourceMap.get(normalizedFile) ?? ''; + const manifestSource = sourceOrEmpty(state.sourceMap, normalizedFile); const manifestKeys = readStringProperties(manifestSource, 'entrypointKey'); const missing = [...sourceKeys].filter((entrypointKey) => !manifestKeys.has(entrypointKey)); const stale = [...manifestKeys].filter((entrypointKey) => !sourceKeys.has(entrypointKey)); @@ -1167,10 +1157,12 @@ const hasMountedIssuerPath = ( const validateGatewayContract = (state: BoundaryCheckState) => Effect.gen(function* validateGatewayContractEffect() { - const gatewayContract = - state.sourceMap.get('packages/shared-contracts/src/gateway-context.ts') ?? ''; - const shellApiContract = state.sourceMap.get('apps/shell-super-app/shared/api.ts') ?? ''; - const shellApiRuntime = state.sourceMap.get('apps/shell-super-app/api/index.ts') ?? ''; + const gatewayContract = sourceOrEmpty( + state.sourceMap, + 'packages/shared-contracts/src/gateway-context.ts', + ); + const shellApiContract = sourceOrEmpty(state.sourceMap, 'apps/shell-super-app/shared/api.ts'); + const shellApiRuntime = sourceOrEmpty(state.sourceMap, 'apps/shell-super-app/api/index.ts'); yield* validateIssuerCredentials(); for (const issuer of gatewayContextAuthorizationEntrypoints) { if ( diff --git a/app/scripts/check-ultramodern-api-boundaries.mts b/app/scripts/check-ultramodern-api-boundaries.mts index 16546b5e1..90c8bcd99 100644 --- a/app/scripts/check-ultramodern-api-boundaries.mts +++ b/app/scripts/check-ultramodern-api-boundaries.mts @@ -373,27 +373,8 @@ const checkApiBoundaries = Effect.gen(function* checkApiBoundariesEffect() { assert(yield* exists(shellClient), `${shellClient} must aggregate vertical API clients.`); } - /* oxlint-disable complexity -- The owner API surface gate intentionally keeps all fail-closed assertions together. expires: 2026-12-31. */ - const assertApiSurface = (appPath: string) => - Effect.gen(function* assertApiSurfaceEffect() { - const apiEntry = `${appPath}/api/index.ts`; - const backendEffectExpose = `${appPath}/api/effect-api.ts`; - const sharedApi = `${appPath}/shared/api.ts`; - const srcApiDirectory = `${appPath}/src/api`; - const modernConfig = `${appPath}/modern.config.ts`; - const packageJsonPath = `${appPath}/package.json`; - - assert(yield* exists(apiEntry), `${apiEntry} is required.`); - assert(yield* exists(sharedApi), `${sharedApi} is required.`); - assert(yield* exists(srcApiDirectory), `${srcApiDirectory} is required.`); - - if (yield* exists(srcApiDirectory)) { - const clientFiles = (yield* listFiles(srcApiDirectory)).filter((file) => - file.endsWith('-client.ts'), - ); - assert(clientFiles.length > 0, `${srcApiDirectory} must contain a generated API client.`); - } - + const assertApiRuntime = (apiEntry: string) => + Effect.gen(function* assertApiRuntimeEffect() { if (yield* exists(apiEntry)) { const entry = yield* readText(apiEntry); const usesRpcRuntime = usesStrictRpcRuntimeTopology(entry, topologyResolverFor(apiEntry)); @@ -419,6 +400,96 @@ const checkApiBoundaries = Effect.gen(function* checkApiBoundariesEffect() { ); } } + }); + + const assertVerticalBaseline = (appPath: string, sharedApi: string): void => { + const apiStem = verticalApiStem(appPath); + const vertical = topologyVertical(appPath); + const basePath = vertical?.api?.basePath; + const apiPrefix = vertical?.api?.bff?.prefix; + if (vertical === undefined) { + fail(`${sharedApi}: topology must declare this MicroVertical owner.`); + } else if (basePath === undefined || basePath.length === 0) { + fail(`${sharedApi}: topology must declare api.basePath.`); + } else if (apiPrefix === undefined || apiPrefix.length === 0) { + fail(`${sharedApi}: topology must declare api.bff.prefix.`); + } else { + const baselineViolation = microVerticalApiBaselineViolation( + apiStem, + path.join(workspaceRoot, sharedApi), + { + additionalPaths: apiStem === 'checkout' ? { checkoutCartPath: `${basePath}/cart` } : {}, + apiPrefix, + basePath, + effectClientPackage: '@modern-js/plugin-bff/effect-client', + ownerId: vertical.id, + readinessPath: `${basePath}/readiness`, + sharedContractsPackage: '@app/shared-contracts', + }, + ); + assert( + baselineViolation === undefined, + `${sharedApi}: ${baselineViolation ?? 'invalid MicroVertical API baseline'}.`, + ); + } + }; + + const assertApiContract = (appPath: string) => + Effect.gen(function* assertApiContractEffect() { + const sharedApi = `${appPath}/shared/api.ts`; + if (yield* exists(sharedApi)) { + const contract = yield* readText(sharedApi); + assertContains( + sharedApi, + contract, + /\bHttpApi\.make\b/u, + 'must declare the HttpApi contract.', + ); + assertContains( + sharedApi, + contract, + /\bHttpApiGroup\.make\b/u, + 'must declare HttpApi groups.', + ); + assertContains( + sharedApi, + contract, + /\bHttpApiEndpoint\./u, + 'must declare endpoints through HttpApiEndpoint.', + ); + assertContains( + sharedApi, + contract, + /\bSchema\./u, + 'must use Schema for request, response and error shapes.', + ); + if (appPath.startsWith('verticals/')) { + assertVerticalBaseline(appPath, sharedApi); + } + } + }); + + const assertApiSurface = (appPath: string) => + Effect.gen(function* assertApiSurfaceEffect() { + const apiEntry = `${appPath}/api/index.ts`; + const backendEffectExpose = `${appPath}/api/effect-api.ts`; + const sharedApi = `${appPath}/shared/api.ts`; + const srcApiDirectory = `${appPath}/src/api`; + const modernConfig = `${appPath}/modern.config.ts`; + const packageJsonPath = `${appPath}/package.json`; + + assert(yield* exists(apiEntry), `${apiEntry} is required.`); + assert(yield* exists(sharedApi), `${sharedApi} is required.`); + assert(yield* exists(srcApiDirectory), `${srcApiDirectory} is required.`); + + if (yield* exists(srcApiDirectory)) { + const clientFiles = (yield* listFiles(srcApiDirectory)).filter((file) => + file.endsWith('-client.ts'), + ); + assert(clientFiles.length > 0, `${srcApiDirectory} must contain a generated API client.`); + } + + yield* assertApiRuntime(apiEntry); if (yield* exists(backendEffectExpose)) { const backendExpose = yield* readText(backendEffectExpose); assertContains( @@ -457,66 +528,7 @@ const checkApiBoundaries = Effect.gen(function* checkApiBoundariesEffect() { ); } - if (yield* exists(sharedApi)) { - const contract = yield* readText(sharedApi); - assertContains( - sharedApi, - contract, - /\bHttpApi\.make\b/u, - 'must declare the HttpApi contract.', - ); - assertContains( - sharedApi, - contract, - /\bHttpApiGroup\.make\b/u, - 'must declare HttpApi groups.', - ); - assertContains( - sharedApi, - contract, - /\bHttpApiEndpoint\./u, - 'must declare endpoints through HttpApiEndpoint.', - ); - assertContains( - sharedApi, - contract, - /\bSchema\./u, - 'must use Schema for request, response and error shapes.', - ); - if (appPath.startsWith('verticals/')) { - const apiStem = verticalApiStem(appPath); - const vertical = topologyVertical(appPath); - const basePath = vertical?.api?.basePath; - const apiPrefix = vertical?.api?.bff?.prefix; - if (vertical === undefined) { - fail(`${sharedApi}: topology must declare this MicroVertical owner.`); - } else if (basePath === undefined || basePath.length === 0) { - fail(`${sharedApi}: topology must declare api.basePath.`); - } else if (apiPrefix === undefined || apiPrefix.length === 0) { - fail(`${sharedApi}: topology must declare api.bff.prefix.`); - } else { - const baselineViolation = microVerticalApiBaselineViolation( - apiStem, - path.join(workspaceRoot, sharedApi), - { - additionalPaths: - apiStem === 'checkout' ? { checkoutCartPath: `${basePath}/cart` } : {}, - apiPrefix, - basePath, - effectClientPackage: '@modern-js/plugin-bff/effect-client', - ownerId: vertical.id, - readinessPath: `${basePath}/readiness`, - sharedContractsPackage: '@app/shared-contracts', - }, - ); - assert( - baselineViolation === undefined, - `${sharedApi}: ${baselineViolation ?? 'invalid MicroVertical API baseline'}.`, - ); - } - } - } - + yield* assertApiContract(appPath); if (yield* exists(modernConfig)) { const config = yield* readText(modernConfig); assertContains( @@ -568,7 +580,6 @@ const checkApiBoundaries = Effect.gen(function* checkApiBoundariesEffect() { }); yield* validateApiPackage; }); - /* oxlint-enable complexity */ const inspectApiSurfaces = Effect.gen(function* inspectApiSurfacesEffect() { for (const appPath of appDirectories) { diff --git a/app/scripts/generated-governed-http-boundary.mts b/app/scripts/generated-governed-http-boundary.mts index 5322a4caf..d1209653f 100644 --- a/app/scripts/generated-governed-http-boundary.mts +++ b/app/scripts/generated-governed-http-boundary.mts @@ -1,3 +1,9 @@ +import { + matchingDelimiter, + separatedSource, + topLevelSeparators, +} from './boundary-source-structure.mts'; +import { toCamelCase, toPascalCase, isCodePosition, maskNonCode } from './scaffolding/shared.mts'; import path from 'node:path'; import { hasGeneratedGovernedClientContract, @@ -7,7 +13,6 @@ import { hasGeneratedOperationPrincipalContract, } from './generated-module-api-boundary.mts'; import { Schema } from 'effect'; -import { isCodePosition, maskNonCode } from './scaffolding/shared.mts'; const GOVERNED_READ_HTTP_MODULE = '@app/core-runtime/http/governed-read'; const MANIFEST_API_SLOT_START = '// '; @@ -39,135 +44,53 @@ interface GovernedReadContribution { const escapeRegExp = (value: string): string => value.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`); -const toPascalCase = (value: string): string => - value - .split('-') - .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) - .join(''); - -const toCamelCase = (value: string): string => { - const pascal = toPascalCase(value); - return `${pascal.slice(0, 1).toLowerCase()}${pascal.slice(1)}`; -}; - const matches = (source: string | undefined, expression: RegExp): boolean => source !== undefined && expression.test(source); const hasExactlyOne = (source: string | undefined, expression: RegExp): boolean => source !== undefined && [...source.matchAll(expression)].length === 1; -const QuoteSchema = Schema.Literals(["'", '"', '`']); -type Quote = typeof QuoteSchema.Type; - const matchingDelimiterEnd = ( source: string, start: number, opening: string, closing: string, -): number | undefined => { - let depth = 0; - let quote: Quote | null = null; - let escaped = false; - for (let index = start; index < source.length; index += 1) { - const character = source[index]; - if (quote !== null) { - if (escaped) { - escaped = false; - } else if (character === '\\') { - escaped = true; - } else if (character === quote) { - quote = null; - } - continue; - } - if (character === "'" || character === '"' || character === '`') { - quote = character; - } else if (character === opening) { - depth += 1; - } else if (character === closing) { - depth -= 1; - if (depth === 0) { - return index; - } - } - } - return undefined; -}; +): number | undefined => matchingDelimiter(maskNonCode(source), start, opening, closing); const maskComments = (source: string): string => maskNonCode(source, true); -/* eslint-disable complexity -- These lexical helpers deliberately track all JavaScript delimiter kinds. */ const callArgument = ( - source: string, + source: string | undefined, declaration: RegExp, argumentIndex = 0, ): string | undefined => { + if (source === undefined) { + return undefined; + } const code = maskComments(source); const match = declaration.exec(code); - if (match?.index === undefined) { + if (match === null) { return undefined; } const callStart = code.indexOf('(', match.index); - const callEnd = callStart === -1 ? null : matchingDelimiterEnd(code, callStart, '(', ')'); - if (callStart === -1 || callEnd === null || callEnd === undefined) { + if (callStart === -1) { return undefined; } - const argumentRanges: (readonly [number, number])[] = []; - let argumentStart = callStart + 1; - let roundDepth = 0; - let squareDepth = 0; - let curlyDepth = 0; - let quote: Quote | null = null; - let escaped = false; - for (let index = callStart + 1; index <= callEnd; index += 1) { - const character = code[index]; - if (quote !== null) { - if (escaped) { - escaped = false; - } else if (character === '\\') { - escaped = true; - } else if (character === quote) { - quote = null; - } - continue; - } - if (character === "'" || character === '"' || character === '`') { - quote = character; - } else if (character === '(') { - roundDepth += 1; - } else if (character === ')') { - if (roundDepth > 0) { - roundDepth -= 1; - } - } else if (character === '[') { - squareDepth += 1; - } else if (character === ']') { - squareDepth -= 1; - } else if (character === '{') { - curlyDepth += 1; - } else if (character === '}') { - curlyDepth -= 1; - } - if ( - (character === ',' || index === callEnd) && - roundDepth === 0 && - squareDepth === 0 && - curlyDepth === 0 - ) { - argumentRanges.push([argumentStart, index]); - argumentStart = index + 1; - } - } - const range = argumentRanges[argumentIndex]; - if (range === undefined) { + const structure = maskNonCode(source); + const callEnd = matchingDelimiter(structure, callStart, '(', ')'); + if (callEnd === undefined) { return undefined; } - return code.slice(range[0], range[1]).trim(); + return separatedSource( + code, + topLevelSeparators(structure, ',', callStart + 1, callEnd), + callStart + 1, + callEnd, + )[argumentIndex]; }; -/* eslint-enable complexity */ const objectArgument = ( - source: string, + source: string | undefined, declaration: RegExp, argumentIndex = 0, ): string | undefined => { @@ -186,39 +109,8 @@ const topLevelObjectEntries = (source: string): readonly string[] | undefined => if (!source.startsWith('{') || matchingDelimiterEnd(source, 0, '{', '}') !== source.length - 1) { return undefined; } - const code = maskNonCode(source); - const entries: string[] = []; - let start = 1; - let roundDepth = 0; - let squareDepth = 0; - let curlyDepth = 0; - for (let index = 1; index < code.length - 1; index += 1) { - const character = code[index]; - if (character === '(') { - roundDepth += 1; - } else if (character === ')') { - roundDepth -= 1; - } else if (character === '[') { - squareDepth += 1; - } else if (character === ']') { - squareDepth -= 1; - } else if (character === '{') { - curlyDepth += 1; - } else if (character === '}') { - curlyDepth -= 1; - } else if (character === ',' && roundDepth === 0 && squareDepth === 0 && curlyDepth === 0) { - const entry = source.slice(start, index).trim(); - if (entry !== '') { - entries.push(entry); - } - start = index + 1; - } - } - const finalEntry = source.slice(start, -1).trim(); - if (finalEntry !== '') { - entries.push(finalEntry); - } - return entries; + const separators = topLevelSeparators(maskNonCode(source), ',', 1, source.length - 1); + return separatedSource(source, separators, 1, source.length - 1).filter((entry) => entry !== ''); }; const objectProperty = (source: string, property: string): string | undefined => { @@ -237,6 +129,96 @@ const objectPropertyValue = (source: string, property: string): string | undefin return shorthand.length === 1 ? property : undefined; }; +const isWholeCallExpression = (source: string, declaration: RegExp): boolean => { + const match = declaration.exec(source); + if (match?.index === undefined) { + return false; + } + const opening = source.indexOf('(', match.index); + const closing = matchingDelimiterEnd(source, opening, '(', ')'); + return opening !== -1 && closing === source.length - 1; +}; + +interface SourceDepthAnalysis { + readonly code: string; + readonly prefixDepths: Int32Array; +} + +// Source strings are immutable cache keys. Bound retained analyses because scripts may validate +// many disposable owners in one process; repeated contributions reuse the same owner sources. +const sourceDepthAnalyses = new Map(); +const MAX_SOURCE_DEPTH_ANALYSES = 32; +const sourceDepthAnalysis = (source: string): SourceDepthAnalysis => { + const cached = sourceDepthAnalyses.get(source); + if (cached !== undefined) { + return cached; + } + const code = maskNonCode(source); + const prefixDepths = new Int32Array(code.length + 1); + let depth = 0; + for (let index = 0; index < code.length; index += 1) { + const character = code.charAt(index); + if ('{(['.includes(character)) { + depth += 1; + } else if ('})]'.includes(character)) { + depth -= 1; + } + prefixDepths[index + 1] = depth; + } + if (sourceDepthAnalyses.size >= MAX_SOURCE_DEPTH_ANALYSES) { + const oldest = sourceDepthAnalyses.keys().next().value; + if (oldest !== undefined) { + sourceDepthAnalyses.delete(oldest); + } + } + const analysis = { code, prefixDepths }; + sourceDepthAnalyses.set(source, analysis); + return analysis; +}; + +const codeDepthBeforePosition = (source: string, target: number): number => + sourceDepthAnalysis(source).prefixDepths[target] ?? 0; + +const codeDepthAtPosition = (source: string, target: number): number | undefined => + isCodePosition(source, target) ? codeDepthBeforePosition(source, target) : undefined; + +const isTopLevelCodePosition = (source: string, target: number): boolean => + codeDepthAtPosition(source, target) === 0; + +interface SourceRange { + readonly end: number; + readonly start: number; + readonly value: string; +} + +const assignedExpressionRange = (source: string, declaration: RegExp): SourceRange | undefined => { + const code = maskComments(source); + const structure = sourceDepthAnalysis(source).code; + const flags = declaration.flags.includes('g') ? declaration.flags : `${declaration.flags}g`; + const declarations = [...structure.matchAll(new RegExp(declaration.source, flags))].filter( + (candidate) => candidate.index !== undefined && isTopLevelCodePosition(source, candidate.index), + ); + if (declarations.length !== 1) { + return undefined; + } + const [match] = declarations; + if (match?.index === undefined) { + return undefined; + } + const start = match.index + match[0].length; + const [index] = topLevelSeparators(structure, ';', start); + if (index === undefined) { + return undefined; + } + const value = code.slice(start, index); + const valueStart = start + value.length - value.trimStart().length; + const valueEnd = start + value.trimEnd().length; + return { end: valueEnd, start: valueStart, value: code.slice(valueStart, valueEnd) }; +}; + +const assignedExpression = (source: string, declaration: RegExp): string | undefined => + assignedExpressionRange(source, declaration)?.value; + const isEffectFnCallback = (source: string): boolean => { if (!source.startsWith('Effect.fn(')) { return false; @@ -269,7 +251,6 @@ const isExecutableCallback = (candidate: string | undefined, ownerSource?: strin if (ownerSource === undefined || !/^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(candidate)) { return false; } - // eslint-disable-next-line no-use-before-define -- Identifier callbacks are resolved after the shared top-level declaration parser initializes. const initializer = assignedExpression( ownerSource, new RegExp(`(?:export\\s+)?const ${escapeRegExp(candidate)}\\s*=\\s*`, 'u'), @@ -291,7 +272,6 @@ const callbackReturnedExpression = (handlerSource: string): string | undefined = } const bodyStructure = maskNonCode(body); const returns = [...bodyStructure.matchAll(/\breturn\s+/gu)].filter( - // eslint-disable-next-line no-use-before-define -- Callback results reuse the shared lexical-depth scanner declared below. (match) => match.index !== undefined && codeDepthBeforePosition(body, match.index) === 1, ); const finalReturn = returns.at(-1); @@ -308,8 +288,7 @@ const isReadHandlerCallback = (candidate: string | undefined, ownerSource: strin return false; } const resolved = /^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(candidate) - ? // eslint-disable-next-line no-use-before-define -- Identifier callbacks are resolved after the shared top-level declaration parser initializes. - assignedExpression( + ? assignedExpression( ownerSource, new RegExp(`(?:export\\s+)?const ${escapeRegExp(candidate)}\\s*=\\s*`, 'u'), ) @@ -326,112 +305,6 @@ const isReadHandlerCallback = (candidate: string | undefined, ownerSource: strin ); }; -const isWholeCallExpression = (source: string, declaration: RegExp): boolean => { - const match = declaration.exec(source); - if (match?.index === undefined) { - return false; - } - const opening = source.indexOf('(', match.index); - const closing = matchingDelimiterEnd(source, opening, '(', ')'); - return opening !== -1 && closing === source.length - 1; -}; - -interface SourceDepthAnalysis { - readonly code: string; - readonly prefixDepths: Int32Array; -} - -// Source strings are immutable cache keys. Bound retained analyses because scripts may validate -// many disposable owners in one process; repeated contributions reuse the same owner sources. -const sourceDepthAnalyses = new Map(); -const MAX_SOURCE_DEPTH_ANALYSES = 32; -const sourceDepthAnalysis = (source: string): SourceDepthAnalysis => { - const cached = sourceDepthAnalyses.get(source); - if (cached !== undefined) { - return cached; - } - const code = maskNonCode(source); - const prefixDepths = new Int32Array(code.length + 1); - let depth = 0; - for (let index = 0; index < code.length; index += 1) { - const character = code[index]; - if (character === '{' || character === '(' || character === '[') { - depth += 1; - } else if (character === '}' || character === ')' || character === ']') { - depth -= 1; - } - prefixDepths[index + 1] = depth; - } - if (sourceDepthAnalyses.size >= MAX_SOURCE_DEPTH_ANALYSES) { - const oldest = sourceDepthAnalyses.keys().next().value; - if (oldest !== undefined) { - sourceDepthAnalyses.delete(oldest); - } - } - const analysis = { code, prefixDepths }; - sourceDepthAnalyses.set(source, analysis); - return analysis; -}; - -const codeDepthBeforePosition = (source: string, target: number): number => - sourceDepthAnalysis(source).prefixDepths[target] ?? 0; - -const codeDepthAtPosition = (source: string, target: number): number | undefined => - isCodePosition(source, target) ? codeDepthBeforePosition(source, target) : undefined; - -const isTopLevelCodePosition = (source: string, target: number): boolean => - codeDepthAtPosition(source, target) === 0; - -interface SourceRange { - readonly end: number; - readonly start: number; - readonly value: string; -} - -const assignedExpressionRange = (source: string, declaration: RegExp): SourceRange | undefined => { - const code = maskComments(source); - const structure = sourceDepthAnalysis(source).code; - const flags = declaration.flags.includes('g') ? declaration.flags : `${declaration.flags}g`; - const declarations = [...structure.matchAll(new RegExp(declaration.source, flags))].filter( - (candidate) => candidate.index !== undefined && isTopLevelCodePosition(source, candidate.index), - ); - if (declarations.length !== 1) { - return undefined; - } - const [match] = declarations; - if (match?.index === undefined) { - return undefined; - } - const start = match.index + match[0].length; - let roundDepth = 0; - let squareDepth = 0; - let curlyDepth = 0; - for (let index = start; index < code.length; index += 1) { - const character = structure[index]; - if (character === '(') { - roundDepth += 1; - } else if (character === ')') { - roundDepth -= 1; - } else if (character === '[') { - squareDepth += 1; - } else if (character === ']') { - squareDepth -= 1; - } else if (character === '{') { - curlyDepth += 1; - } else if (character === '}') { - curlyDepth -= 1; - } else if (character === ';' && roundDepth === 0 && squareDepth === 0 && curlyDepth === 0) { - const valueStart = start + (/^\s*/u.exec(code.slice(start, index))?.[0].length ?? 0); - const valueEnd = index - (/\s*$/u.exec(code.slice(start, index))?.[0].length ?? 0); - return { end: valueEnd, start: valueStart, value: code.slice(valueStart, valueEnd) }; - } - } - return undefined; -}; - -const assignedExpression = (source: string, declaration: RegExp): string | undefined => - assignedExpressionRange(source, declaration)?.value; - interface GeneratedSlotRange { readonly bodyEnd: number; readonly bodyStart: number; @@ -697,7 +570,53 @@ const slotIsMountedByAssembler = ( ); }; -// eslint-disable-next-line complexity -- Mount validation traces the generated slot through its aggregate, runtime, and exported BFF root. +const definesExpectedRuntime = ( + definition: string | undefined, + expectedApi: string, + runtimeName: string, +): boolean => + definition !== undefined && + !definition.includes('...') && + objectPropertyValue(definition, 'api') === expectedApi && + hasExactlyOne( + definition, + runtimeName === 'layer' + ? /(?:\{|,)\s*layer(?:\s*:\s*layer)?\s*(?:,|\})/gu + : new RegExp(`(?:\\{|,)\\s*layer\\s*:\\s*${escapeRegExp(runtimeName)}\\s*(?:,|\\})`, 'gu'), + ); + +const legacyRuntimeMount = ( + source: string, + code: string, + runtimeSource: string, + closing: number, + layerName: string, + expectedApi: string, +): boolean => { + const runtimeDeclaration = + /const\s+(?[A-Za-z][A-Za-z0-9]*)\s*=\s*HttpApiBuilder\.layer\(/u.exec(runtimeSource); + const runtimeName = runtimeDeclaration?.groups?.name; + if (runtimeDeclaration === null || runtimeName === undefined) { + return false; + } + const runtimeStart = closing + 1 + runtimeDeclaration.index; + const runtimeEnd = code.indexOf('satisfies EffectRuntimeLayer', runtimeStart); + if (runtimeEnd <= runtimeStart) { + return false; + } + const runtimeSlice = code.slice(runtimeStart, runtimeEnd); + const definition = effectBffDefinition(source); + return ( + callArgument(runtimeSlice, /HttpApiBuilder\.layer\(/u) === expectedApi && + matches( + runtimeSlice, + new RegExp(`(?:GovernedReadLayer|Layer)\\.provide\\(${escapeRegExp(layerName)}\\)`, 'u'), + ) && + hasExactlyOne(code, /\bdefineEffectBff\(/gu) && + definesExpectedRuntime(definition, expectedApi, runtimeName) + ); +}; + const slotIsInsideMountedLayer = ( source: string, startMarker: string, @@ -718,15 +637,12 @@ const slotIsInsideMountedLayer = ( ]; let declaration: RegExpExecArray | undefined; for (const candidate of declarations) { - if ( - candidate.index !== undefined && - codeDepthAtPosition(source, candidate.index) === slot.depth - 1 - ) { + if (codeDepthAtPosition(source, candidate.index) === slot.depth - 1) { declaration = candidate; } } const layerName = declaration?.groups?.name; - if (declaration?.index === undefined || layerName === undefined) { + if (declaration === undefined || layerName === undefined) { return false; } const layerStart = declaration.index; @@ -739,52 +655,47 @@ const slotIsInsideMountedLayer = ( if (slotIsMountedByAssembler(source, runtimeSource, layerName, expectedApi)) { return true; } - const runtimeDeclaration = - /const\s+(?[A-Za-z][A-Za-z0-9]*)\s*=\s*HttpApiBuilder\.layer\(/u.exec(runtimeSource); - const runtimeName = runtimeDeclaration?.groups?.name; - const runtimeStart = - runtimeDeclaration?.index === undefined ? -1 : closing + 1 + runtimeDeclaration.index; - const runtimeEnd = - runtimeStart === -1 ? -1 : code.indexOf('satisfies EffectRuntimeLayer', runtimeStart); - const runtimeSlice = - runtimeStart === -1 || runtimeEnd === -1 ? '' : code.slice(runtimeStart, runtimeEnd); - const definition = effectBffDefinition(source); - return ( - runtimeName !== undefined && - runtimeStart !== -1 && - runtimeEnd > runtimeStart && - callArgument(runtimeSlice, /HttpApiBuilder\.layer\(/u) === expectedApi && - matches( - runtimeSlice, - new RegExp(`(?:GovernedReadLayer|Layer)\\.provide\\(${escapeRegExp(layerName)}\\)`, 'u'), - ) && - hasExactlyOne(code, /\bdefineEffectBff\(/gu) && - definition !== undefined && - !definition.includes('...') && - objectPropertyValue(definition, 'api') === expectedApi && - hasExactlyOne( - definition, - runtimeName === 'layer' - ? /(?:\{|,)\s*layer(?:\s*:\s*layer)?\s*(?:,|\})/gu - : new RegExp(`(?:\\{|,)\\s*layer\\s*:\\s*${escapeRegExp(runtimeName)}\\s*(?:,|\\})`, 'gu'), - ) - ); + return legacyRuntimeMount(source, code, runtimeSource, closing, layerName, expectedApi); }; -const governedSharedApiRoot = (source: string): SourceRange | undefined => { - const governed = assignedExpressionRange(source, /export const governedHttpApi\s*=\s*/u); - if (governed === undefined) { - return undefined; - } - let apiRoot: SourceRange | undefined; - if (governed.value.startsWith(HTTP_API_MAKE)) { - apiRoot = governed; - } else if (/^[A-Za-z][A-Za-z0-9]*$/u.test(governed.value)) { - apiRoot = assignedExpressionRange( - source, +const effectiveApiRootRange = ( + source: string, + governed: SourceRange, + apiRoot: SourceRange, +): SourceRange => { + const governedDeclaration = maskNonCode(source, true).indexOf('export const governedHttpApi'); + const aliasedStatementEnd = + governed.value.startsWith(HTTP_API_MAKE) || governedDeclaration === -1 + ? -1 + : source.lastIndexOf(';', governedDeclaration); + return aliasedStatementEnd > apiRoot.start + ? { + end: aliasedStatementEnd, + start: apiRoot.start, + value: source.slice(apiRoot.start, aliasedStatementEnd).trimEnd(), + } + : apiRoot; +}; + +const resolveGovernedApiRoot = (source: string, governed: SourceRange): SourceRange | undefined => { + let apiRoot: SourceRange | undefined; + if (governed.value.startsWith(HTTP_API_MAKE)) { + apiRoot = governed; + } else if (/^[A-Za-z][A-Za-z0-9]*$/u.test(governed.value)) { + apiRoot = assignedExpressionRange( + source, new RegExp(`export const ${escapeRegExp(governed.value)}\\s*=\\s*`, 'u'), ); } + return apiRoot; +}; + +const governedSharedApiRoot = (source: string): SourceRange | undefined => { + const governed = assignedExpressionRange(source, /export const governedHttpApi\s*=\s*/u); + if (governed === undefined) { + return undefined; + } + const apiRoot = resolveGovernedApiRoot(source, governed); const slot = generatedSlotRange( source, '// ', @@ -798,19 +709,7 @@ const governedSharedApiRoot = (source: string): SourceRange | undefined => { ) { return undefined; } - const governedDeclaration = maskNonCode(source, true).indexOf('export const governedHttpApi'); - const aliasedStatementEnd = - governed.value.startsWith(HTTP_API_MAKE) || governedDeclaration === -1 - ? -1 - : source.lastIndexOf(';', governedDeclaration); - const effectiveRoot = - aliasedStatementEnd > apiRoot.start - ? { - end: aliasedStatementEnd, - start: apiRoot.start, - value: source.slice(apiRoot.start, aliasedStatementEnd).trimEnd(), - } - : apiRoot; + const effectiveRoot = effectiveApiRootRange(source, governed, apiRoot); if (slot.markerEnd >= effectiveRoot.end) { return undefined; } @@ -824,6 +723,11 @@ const governedSharedApiRoot = (source: string): SourceRange | undefined => { : undefined; }; +const governedApiBinding = (source: string): string | undefined => { + const value = assignedExpression(source, /export const governedHttpApi\s*=\s*/u); + return value?.startsWith(HTTP_API_MAKE) === true ? 'governedHttpApi' : value; +}; + const hasGovernedSharedApiRoot = (source: string): boolean => governedSharedApiRoot(source) !== undefined; @@ -862,6 +766,9 @@ const hasInjectedGovernedReadRuntime = (source: string): boolean => { ); }; +const wholeCall = (expression: string | undefined, callee: RegExp): boolean => + expression !== undefined && isWholeCallExpression(expression, callee); + const hasGovernedHandlerRoot = (source: string): boolean => { if (hasInjectedGovernedReadRuntime(source)) { return true; @@ -876,8 +783,7 @@ const hasGovernedHandlerRoot = (source: string): boolean => { if (runtime === 'readRuntimeLive') { const ownerRuntime = assignedExpression(source, /const readRuntimeLive\s*=\s*/u); if ( - ownerRuntime === undefined || - !isWholeCallExpression(ownerRuntime, /^ReadRuntimeLive\.pipe\(/u) || + !wholeCall(ownerRuntime, /^ReadRuntimeLive\.pipe\(/u) || callArgument(ownerRuntime, /^ReadRuntimeLive\.pipe\(/u) !== 'Layer.provide(readRuntimeDependenciesLive)' ) { @@ -893,24 +799,7 @@ const hasGovernedHandlerRoot = (source: string): boolean => { return effectBffDefinition(source) !== undefined; }; -// eslint-disable-next-line complexity -- Composition validation binds every generated slot to the exported Effect BFF root. -export const hasValidGovernedHttpCompositionRoot = ( - sharedApi: string, - handlerRoot: string, -): boolean => { - const sharedRoot = governedSharedApiRoot(sharedApi); - const governed = assignedExpression(sharedApi, /export const governedHttpApi\s*=\s*/u); - const expectedApi = governed?.startsWith(HTTP_API_MAKE) === true ? 'governedHttpApi' : governed; - const sharedImports = generatedSlotRange( - sharedApi, - '// ', - '// ', - ); - const handlerImports = generatedSlotRange( - handlerRoot, - '// ', - '// ', - ); +const hasGovernedSupportSlots = (handlerRoot: string): boolean => { const supportImportStart = '// '; const supportLayerStart = '// '; const supportImports = handlerRoot.includes(supportImportStart) @@ -931,6 +820,32 @@ export const hasValidGovernedHttpCompositionRoot = ( handlerRoot, /export const governedReadApiHandlersLive\s*=\s*/u, ); + return ( + (supportImports === null || supportImports?.depth === 0) && + (supportLayers === null || + (generatedHandlers !== undefined && + supportLayers !== undefined && + supportLayers.markerStart > generatedHandlers.start && + supportLayers.markerEnd < generatedHandlers.end)) + ); +}; + +export const hasValidGovernedHttpCompositionRoot = ( + sharedApi: string, + handlerRoot: string, +): boolean => { + const sharedRoot = governedSharedApiRoot(sharedApi); + const expectedApi = governedApiBinding(sharedApi); + const sharedImports = generatedSlotRange( + sharedApi, + '// ', + '// ', + ); + const handlerImports = generatedSlotRange( + handlerRoot, + '// ', + '// ', + ); const mounted = expectedApi !== undefined && slotIsInsideMountedLayer( @@ -941,16 +856,10 @@ export const hasValidGovernedHttpCompositionRoot = ( ); return ( sharedRoot !== undefined && - expectedApi !== undefined && hasGovernedHandlerRoot(handlerRoot) && sharedImports?.depth === 0 && handlerImports?.depth === 0 && - (supportImports === null || supportImports?.depth === 0) && - (supportLayers === null || - (generatedHandlers !== undefined && - supportLayers !== undefined && - supportLayers.markerStart > generatedHandlers.start && - supportLayers.markerEnd < generatedHandlers.end)) && + hasGovernedSupportSlots(handlerRoot) && mounted ); }; @@ -985,47 +894,131 @@ const generatedHeader = (kind: GovernedReadKind): string => ? MODULE_API_HEADER : `${GOVERNED_CONTRIBUTION_HEADER}// @ontos-contribution-kind ${kind}\n`; -const contributionRole = (kind: GovernedReadKind): string => { - if (kind === MODULE_API_KIND) { - return 'api'; - } - return kind === REPORT_KIND ? 'report' : 'search'; +const contributionProfiles = { + 'module-api': { + accessKinds: new Set(["'detail'", "'list'"]), + directory: 'api', + readSuffix: '.read', + registrationCategory: 'api', + registrationSection: 'apis', + role: 'api', + schemaSuffix: '', + typeSuffix: '', + }, + report: { + accessKinds: new Set(["'report'"]), + directory: 'reports', + readSuffix: '.provider', + registrationCategory: 'reports', + registrationSection: 'reports', + role: 'report', + schemaSuffix: 'Provider', + typeSuffix: 'Report', + }, + 'search-provider': { + accessKinds: new Set(["'search'"]), + directory: 'search', + readSuffix: '.provider', + registrationCategory: 'search', + registrationSection: 'search', + role: 'search', + schemaSuffix: 'Provider', + typeSuffix: 'Search', + }, +} as const; + +const contributionServerStem = (contribution: GovernedReadContribution): string => + contribution.kind === MODULE_API_KIND ? `${contribution.name}-read` : contribution.contractStem; + +const contributionRole = (kind: GovernedReadKind): string => contributionProfiles[kind].role; +const contributionGroup = (kind: GovernedReadKind, name: string): string => + `${toCamelCase(name)}${contributionProfiles[kind].typeSuffix}`; +const contributionApiValue = (kind: GovernedReadKind, name: string): string => + `${toPascalCase(name)}${contributionProfiles[kind].typeSuffix}Api`; +const contributionReadDirectory = (kind: GovernedReadKind): string => + contributionProfiles[kind].directory; +const contributionSchemaStem = (kind: GovernedReadKind, name: string): string => + `${toPascalCase(name)}${contributionProfiles[kind].schemaSuffix}`; +const contributionEndpoint = (contribution: GovernedReadContribution, moduleId: string): string => + contribution.kind === MODULE_API_KIND + ? `/reads/${contribution.name}` + : `/${moduleId}/${contributionProfiles[contribution.kind].directory}/${contribution.name}`; + +const hasObjectProperties = (source: string, expected: Readonly>): boolean => + Object.entries(expected).every(([property, value]) => objectProperty(source, property) === value); + +const hasContractImports = (source: string, expected: Readonly>): boolean => + Object.entries(expected).every(([name, specifier]) => + hasExactValueImport(source, name, specifier), + ); + +const isWholeObjectCall = ( + expression: string | undefined, + value: string | undefined, + callee: RegExp, +): boolean => + expression !== undefined && + value !== undefined && + !value.includes('...') && + isWholeCallExpression(expression, callee); + +const hasReadDescriptorPolicy = ( + read: string, + allowedAccessKinds: ReadonlySet, +): boolean => { + const policies = objectProperty(read, 'policies'); + return ( + allowedAccessKinds.has(objectProperty(read, 'accessKind') ?? '') && + matches(objectProperty(read, 'legalEntityScope'), /^'(?:required|optional|forbidden)'$/u) && + matches( + objectProperty(read, 'permissionTarget'), + /^'(?:legal_entity|module|resource|tenant)'$/u, + ) && + policies !== undefined && + policies.startsWith('[') && + matchingDelimiterEnd(policies, 0, '[', ']') === policies.length - 1 + ); }; -const contributionTypeSuffix = (kind: GovernedReadKind): string => { - if (kind === MODULE_API_KIND) { - return ''; +const hasReadCallbacks = ( + source: string, + readExpression: string, + kind: GovernedReadKind, +): boolean => { + const handler = callArgument(readExpression, /^defineRead\(/u, 1); + const callbacks = [2, 3]; + if (kind === SEARCH_PROVIDER_KIND) { + callbacks.push(4); } - return kind === REPORT_KIND ? 'Report' : 'Search'; + return ( + isReadHandlerCallback(handler, source) && + callbacks.every((index) => + isExecutableCallback(callArgument(readExpression, /^defineRead\(/u, index), source), + ) + ); }; -const contributionGroup = (kind: GovernedReadKind, name: string): string => - `${toCamelCase(name)}${contributionTypeSuffix(kind)}`; - -const contributionApiValue = (kind: GovernedReadKind, name: string): string => - `${toPascalCase(name)}${contributionTypeSuffix(kind)}Api`; - -const contributionReadDirectory = (kind: GovernedReadKind): string => { - if (kind === MODULE_API_KIND) { - return 'api'; - } - return kind === REPORT_KIND ? 'reports' : 'search'; +const hasReadEntrypoint = ( + entrypoint: string, + identity: Readonly>, +): boolean => { + const access = objectProperty(entrypoint, 'access'); + return ( + (access === "'read'" || access === "'historical_read'") && + hasObjectProperties(entrypoint, identity) + ); }; -// eslint-disable-next-line complexity -- Read validation binds the complete generated descriptor and owner identity. const hasReadContract = ( source: string, contribution: GovernedReadContribution, moduleId: string, ): boolean => { const camel = toCamelCase(contribution.name); - const type = toPascalCase(contribution.name); + const schemaStem = contributionSchemaStem(contribution.kind, contribution.name); const escapedCamel = escapeRegExp(camel); const role = contributionRole(contribution.kind); - const allowedAccessKinds = - contribution.kind === MODULE_API_KIND - ? new Set(["'detail'", "'list'"]) - : new Set([contribution.kind === REPORT_KIND ? "'report'" : "'search'"]); + const allowedAccessKinds = contributionProfiles[contribution.kind].accessKinds; const contributionKey = `${moduleId}.${role}.${contribution.name}`; const entrypointExpression = assignedExpression( source, @@ -1035,53 +1028,34 @@ const hasReadContract = ( source, new RegExp(`export const ${escapedCamel}Read\\s*=\\s*`, 'u'), ); - const entrypoint = - entrypointExpression === undefined - ? undefined - : objectArgument(entrypointExpression, /^defineTenantModuleEntrypoint\(/u); - const read = - readExpression === undefined ? undefined : objectArgument(readExpression, /^defineRead\(/u); - const inputSchema = `${type}${contribution.kind === MODULE_API_KIND ? '' : 'Provider'}RequestSchema`; - const resultSchema = `${type}${contribution.kind === MODULE_API_KIND ? '' : 'Provider'}ResponseSchema`; - const handler = callArgument(readExpression ?? '', /^defineRead\(/u, 1); - const services = callArgument(readExpression ?? '', /^defineRead\(/u, 2); - const permissionTarget = callArgument(readExpression ?? '', /^defineRead\(/u, 3); - const resultTargets = callArgument(readExpression ?? '', /^defineRead\(/u, 4); - const entrypointAccess = objectProperty(entrypoint ?? '', 'access'); - const policies = objectProperty(read ?? '', 'policies'); + const entrypoint = objectArgument(entrypointExpression, /^defineTenantModuleEntrypoint\(/u); + const read = objectArgument(readExpression, /^defineRead\(/u); + const inputSchema = `${schemaStem}RequestSchema`; + const resultSchema = `${schemaStem}ResponseSchema`; + if (entrypoint === undefined || read === undefined || readExpression === undefined) { + return false; + } return ( - entrypoint !== undefined && - read !== undefined && - entrypointExpression !== undefined && - readExpression !== undefined && - isWholeCallExpression(entrypointExpression, /^defineTenantModuleEntrypoint\(/u) && - isWholeCallExpression(readExpression, /^defineRead\(/u) && - !entrypoint.includes('...') && - !read.includes('...') && - hasExactValueImport(source, 'defineRead', '@app/core-runtime') && - hasExactValueImport(source, 'defineTenantModuleEntrypoint', '@app/core-runtime') && - (entrypointAccess === "'read'" || entrypointAccess === "'historical_read'") && - objectProperty(entrypoint, 'entrypointKey') === `'${contributionKey}'` && - objectProperty(entrypoint, 'moduleKey') === `'${moduleId}'` && - objectProperty(entrypoint, 'role') === `'${role}'` && - allowedAccessKinds.has(objectProperty(read, 'accessKind') ?? '') && - objectProperty(read, 'entrypoint') === `${camel}Entrypoint` && - objectProperty(read, 'inputSchema') === inputSchema && - objectProperty(read, 'resultSchema') === resultSchema && - matches(objectProperty(read, 'legalEntityScope'), /^'(?:required|optional|forbidden)'$/u) && - objectProperty(read, 'owningModuleKey') === `'${moduleId}'` && - matches( - objectProperty(read, 'permissionTarget'), - /^'(?:legal_entity|module|resource|tenant)'$/u, - ) && - policies !== undefined && - policies.startsWith('[') && - matchingDelimiterEnd(policies, 0, '[', ']') === policies.length - 1 && - objectProperty(read, 'readKey') === `'${contributionKey}'` && - isReadHandlerCallback(handler, source) && - isExecutableCallback(services, source) && - isExecutableCallback(permissionTarget, source) && - (contribution.kind !== SEARCH_PROVIDER_KIND || isExecutableCallback(resultTargets, source)) + isWholeObjectCall(entrypointExpression, entrypoint, /^defineTenantModuleEntrypoint\(/u) && + isWholeObjectCall(readExpression, read, /^defineRead\(/u) && + hasContractImports(source, { + defineRead: '@app/core-runtime', + defineTenantModuleEntrypoint: '@app/core-runtime', + }) && + hasReadEntrypoint(entrypoint, { + entrypointKey: `'${contributionKey}'`, + moduleKey: `'${moduleId}'`, + role: `'${role}'`, + }) && + hasObjectProperties(read, { + entrypoint: `${camel}Entrypoint`, + inputSchema, + owningModuleKey: `'${moduleId}'`, + readKey: `'${contributionKey}'`, + resultSchema, + }) && + hasReadDescriptorPolicy(read, allowedAccessKinds) && + hasReadCallbacks(source, readExpression, contribution.kind) ); }; @@ -1138,10 +1112,7 @@ const hasProblemSet = (source: string, schemaStem: string, contractImport: strin 'u', ).exec(entry ?? ''); const factory = prefix?.groups?.factory; - const problem = - factory === undefined - ? undefined - : objectArgument(factory, new RegExp(`^${schemaStem}${suffix}\\.make\\(`, 'u')); + const problem = objectArgument(factory, new RegExp(`^${schemaStem}${suffix}\\.make\\(`, 'u')); return ( factory !== undefined && problem !== undefined && @@ -1190,7 +1161,49 @@ const hasOnlyThinServerStatements = ( return remaining.trim() === ''; }; -// eslint-disable-next-line complexity -- Server validation binds the exported group, execute handler, helper options, and every trusted import. +const optionsFromHandlerCallback = (callbackSource: string | undefined): string | undefined => { + const prefix = /^\(\s*handlers\s*\)\s*=>\s*/u.exec(callbackSource ?? ''); + if (callbackSource === undefined || prefix === null) { + return undefined; + } + const registration = callbackSource.slice(prefix[0].length).trim(); + if ( + !wholeCall(registration, /^handlers\.handle\(/u) || + callArgument(registration, /^handlers\.handle\(/u) !== "'execute'" || + !hasExactlyOne(callbackSource, /handlers\.handle\(/gu) + ) { + return undefined; + } + const handler = callArgument(registration, /^handlers\.handle\(/u, 1); + return wholeCall(handler, /^makeGovernedReadHttpHandler\(/u) + ? objectArgument(handler, /^makeGovernedReadHttpHandler\(/u) + : undefined; +}; + +const serverHandlerOptions = ( + layerExpression: string | undefined, + expectedGroup: string, +): string | undefined => { + if ( + !wholeCall(layerExpression, /^HttpApiBuilder\.group\(/u) || + callArgument(layerExpression, /^HttpApiBuilder\.group\(/u) !== 'governedHttpApi' || + callArgument(layerExpression, /^HttpApiBuilder\.group\(/u, 1) !== `'${expectedGroup}'` + ) { + return undefined; + } + const callback = callArgument(layerExpression, /^HttpApiBuilder\.group\(/u, 2); + return optionsFromHandlerCallback(callback); +}; + +const hasServerOptions = (options: string, readName: string): boolean => + !options.includes('...') && + topLevelObjectEntries(options)?.length === 3 && + hasObjectProperties(options, { + authenticatePrincipal: 'authenticateOperationPrincipal', + registration: readName, + }) && + objectPropertyValue(options, 'problems') === 'problems'; + const hasServerContract = ( source: string, escapedCamel: string, @@ -1204,34 +1217,10 @@ const hasServerContract = ( code, new RegExp(`export const ${escapedCamel}ReadApiLive\\s*=\\s*`, 'u'), ); - const groupApi = - layerExpression === undefined - ? undefined - : callArgument(layerExpression, /^HttpApiBuilder\.group\(/u); - const groupName = - layerExpression === undefined - ? undefined - : callArgument(layerExpression, /^HttpApiBuilder\.group\(/u, 1); - const groupCallback = - layerExpression === undefined - ? undefined - : callArgument(layerExpression, /^HttpApiBuilder\.group\(/u, 2); - const callbackPrefix = /^\(\s*handlers\s*\)\s*=>\s*/u.exec(groupCallback ?? ''); - const handlerRegistration = - groupCallback === undefined || callbackPrefix === null - ? undefined - : groupCallback.slice(callbackPrefix[0].length).trim(); - const operation = - handlerRegistration === undefined - ? undefined - : callArgument(handlerRegistration, /^handlers\.handle\(/u); - const handler = - handlerRegistration === undefined - ? undefined - : callArgument(handlerRegistration, /^handlers\.handle\(/u, 1); - const options = - handler === undefined ? undefined : objectArgument(handler, /^makeGovernedReadHttpHandler\(/u); + const options = serverHandlerOptions(layerExpression, escapedGroup); return ( + options !== undefined && + hasServerOptions(options, `${escapedCamel}Read`) && hasOnlyThinServerStatements( source, `${escapedCamel}ReadApiLive`, @@ -1244,30 +1233,17 @@ const hasServerContract = ( contractImport, ]), ) && - groupApi === 'governedHttpApi' && - groupName === `'${escapedGroup}'` && - layerExpression !== undefined && - isWholeCallExpression(layerExpression, /^HttpApiBuilder\.group\(/u) && - handlerRegistration !== undefined && - isWholeCallExpression(handlerRegistration, /^handlers\.handle\(/u) && - operation === "'execute'" && - handler !== undefined && - isWholeCallExpression(handler, /^makeGovernedReadHttpHandler\(/u) && - options !== undefined && - !options.includes('...') && - hasExactValueImport(source, 'makeGovernedReadHttpHandler', GOVERNED_READ_HTTP_MODULE) && - hasExactValueImport(source, 'authenticateOperationPrincipal', './auth/action-principal.ts') && - hasExactValueImport(source, 'governedHttpApi', '../shared/api.ts') && - hasExactValueImport(source, 'HttpApiBuilder', '@modern-js/plugin-bff/effect-edge') && - hasExactValueImport(source, `${escapedCamel}Read`, readImport) && + hasContractImports(source, { + [`${escapedCamel}Read`]: readImport, + authenticateOperationPrincipal: './auth/action-principal.ts', + governedHttpApi: '../shared/api.ts', + HttpApiBuilder: '@modern-js/plugin-bff/effect-edge', + makeGovernedReadHttpHandler: GOVERNED_READ_HTTP_MODULE, + }) && hasProblemSet(source, schemaStem, contractImport) && - hasExactlyOne(code, /HttpApiBuilder\.group\(/gu) && - hasExactlyOne(code, /makeGovernedReadHttpHandler\(/gu) && - hasExactlyOne(groupCallback, /handlers\.handle\(/gu) && - topLevelObjectEntries(options)?.length === 3 && - objectProperty(options, 'authenticatePrincipal') === 'authenticateOperationPrincipal' && - objectPropertyValue(options, 'problems') === 'problems' && - objectProperty(options, 'registration') === `${escapedCamel}Read` + [/HttpApiBuilder\.group\(/gu, /makeGovernedReadHttpHandler\(/gu].every((pattern) => + hasExactlyOne(code, pattern), + ) ); }; @@ -1294,7 +1270,33 @@ const hasProblemSchemaContract = ( ); }; -// eslint-disable-next-line complexity -- Contract validation follows the complete exported API/group/endpoint chain and every governed schema binding. +const addedContractMember = ( + expression: string | undefined, + factory: string, + name: string, +): string | undefined => { + if (expression === undefined || !expression.startsWith(`${factory}.make(`)) { + return undefined; + } + const make = new RegExp(`^${factory}\\.make\\(`, 'u'); + if (callArgument(expression, make) !== `'${name}'`) { + return undefined; + } + const close = matchingDelimiterEnd(expression, expression.indexOf('('), '(', ')'); + if (close === undefined) { + return undefined; + } + const addition = expression.slice(close + 1).trim(); + return addition.startsWith('.add(') && isWholeCallExpression(addition, /^\.add\(/u) + ? callArgument(addition, /^\.add\(/u) + : undefined; +}; + +const isExecuteEndpoint = (expression: string | undefined, endpointPath: string): boolean => + wholeCall(expression, /^HttpApiEndpoint\.post\(/u) && + callArgument(expression, /^HttpApiEndpoint\.post\(/u) === "'execute'" && + callArgument(expression, /^HttpApiEndpoint\.post\(/u, 1) === `'${endpointPath}'`; + const hasHttpContract = ( source: string, apiValue: string, @@ -1307,52 +1309,9 @@ const hasHttpContract = ( source, new RegExp(`export const ${escapedApiValue}\\s*=\\s*`, 'u'), ); - const apiMakeOpening = expression?.indexOf('(') ?? -1; - const apiMakeClosing = - expression === undefined || apiMakeOpening === -1 - ? undefined - : matchingDelimiterEnd(expression, apiMakeOpening, '(', ')'); - const apiAddition = - expression === undefined || apiMakeClosing === undefined - ? undefined - : expression.slice(apiMakeClosing + 1).trim(); - if ( - expression === undefined || - !expression.startsWith(HTTP_API_MAKE) || - callArgument(expression, /^HttpApi\.make\(/u) !== `'${apiValue}'` || - apiAddition === undefined || - !apiAddition.startsWith('.add(') || - !isWholeCallExpression(apiAddition, /^\.add\(/u) - ) { - return false; - } - const groupExpression = callArgument(apiAddition, /^\.add\(/u); - const groupMakeOpening = groupExpression?.indexOf('(') ?? -1; - const groupMakeClosing = - groupExpression === undefined || groupMakeOpening === -1 - ? undefined - : matchingDelimiterEnd(groupExpression, groupMakeOpening, '(', ')'); - const groupAddition = - groupExpression === undefined || groupMakeClosing === undefined - ? undefined - : groupExpression.slice(groupMakeClosing + 1).trim(); - if ( - groupExpression === undefined || - !groupExpression.startsWith('HttpApiGroup.make(') || - callArgument(groupExpression, /^HttpApiGroup\.make\(/u) !== `'${group}'` || - groupAddition === undefined || - !groupAddition.startsWith('.add(') || - !isWholeCallExpression(groupAddition, /^\.add\(/u) - ) { - return false; - } - const endpointExpression = callArgument(groupAddition, /^\.add\(/u); - if ( - endpointExpression === undefined || - !isWholeCallExpression(endpointExpression, /^HttpApiEndpoint\.post\(/u) || - callArgument(endpointExpression, /^HttpApiEndpoint\.post\(/u) !== "'execute'" || - callArgument(endpointExpression, /^HttpApiEndpoint\.post\(/u, 1) !== `'${endpointPath}'` - ) { + const groupExpression = addedContractMember(expression, 'HttpApi', apiValue); + const endpointExpression = addedContractMember(groupExpression, 'HttpApiGroup', group); + if (!isExecuteEndpoint(endpointExpression, endpointPath)) { return false; } const options = objectArgument(endpointExpression, /^HttpApiEndpoint\.post\(/u, 2); @@ -1372,9 +1331,11 @@ const hasHttpContract = ( return ( options !== undefined && !options.includes('...') && - hasExactValueImport(source, 'HttpApi', HTTP_API_CONTRACT_MODULE) && - hasExactValueImport(source, 'HttpApiEndpoint', HTTP_API_CONTRACT_MODULE) && - hasExactValueImport(source, 'HttpApiGroup', HTTP_API_CONTRACT_MODULE) && + hasContractImports(source, { + HttpApi: HTTP_API_CONTRACT_MODULE, + HttpApiEndpoint: HTTP_API_CONTRACT_MODULE, + HttpApiGroup: HTTP_API_CONTRACT_MODULE, + }) && problems.every((problem, index) => hasProblemSchemaContract(source, problem, problemStatuses[index] ?? -1, index === 6), ) && @@ -1412,16 +1373,14 @@ const hasManifestContract = ( 'gu', ), ) && - apiEntries?.filter( - (entry) => - entry === - `'${contribution.contractStem}': ${contributionApiValue(contribution.kind, contribution.name)}`, - ).length === 1 && - allOwnerManifestEntries?.filter( - (entry) => - entry === - `'${contribution.contractStem}': ${contributionApiValue(contribution.kind, contribution.name)}`, - ).length === 1 + [apiEntries, allOwnerManifestEntries].every( + (entries) => + entries?.filter( + (entry) => + entry === + `'${contribution.contractStem}': ${contributionApiValue(contribution.kind, contribution.name)}`, + ).length === 1, + ) ); } const role = contributionRole(contribution.kind); @@ -1462,33 +1421,13 @@ const hasManifestContract = ( return published?.length === 1 && allPublished?.length === 1 && shell?.length === 1; }; -// eslint-disable-next-line complexity -- Publication validation binds API, manifest, registration, and mounted handler category identities. -const hasPublishedContract = ( - sharedApi: string, - manifest: string, +const hasPublishedRegistration = ( registration: string, - handlerRoot: string, contribution: GovernedReadContribution, - moduleId: string, ): boolean => { - const governed = assignedExpression(sharedApi, /export const governedHttpApi\s*=\s*/u); - const expectedApi = governed?.startsWith(HTTP_API_MAKE) === true ? 'governedHttpApi' : governed; - const apiValue = contributionApiValue(contribution.kind, contribution.name); - const camel = toCamelCase(contribution.name); - const serverStem = - contribution.kind === MODULE_API_KIND ? `${contribution.name}-read` : contribution.contractStem; - const escapedContractStem = escapeRegExp(contribution.contractStem); - const escapedApiValue = escapeRegExp(apiValue); - const escapedCamel = escapeRegExp(camel); const escapedName = escapeRegExp(contribution.name); - const escapedServerStem = escapeRegExp(serverStem); - let registrationSection = 'search'; - if (contribution.kind === MODULE_API_KIND) { - registrationSection = 'apis'; - } else if (contribution.kind === REPORT_KIND) { - registrationSection = 'reports'; - } - const registrationCategory = contribution.kind === MODULE_API_KIND ? 'api' : registrationSection; + const escapedContractStem = escapeRegExp(contribution.contractStem); + const { registrationCategory, registrationSection } = contributionProfiles[contribution.kind]; const registrationEntries = generatedSlotEntries( registration, `// `, @@ -1499,17 +1438,81 @@ const hasPublishedContract = ( ['// ', '// '], ['// ', '// '], ]); - const handlerLayers = generatedSlotEntries( - handlerRoot, - GOVERNED_HANDLER_LAYER_SLOT_START, - GOVERNED_HANDLER_LAYER_SLOT_END, - ); const registrationPattern = new RegExp( `^(?:'${escapedName}'|${escapedName})\\s*:\\s*\\(\\s*\\)\\s*=>\\s*import\\(\\s*'./src/api/${escapedContractStem}-client\\.ts'\\s*\\)$`, 'u', ); const canonicalRegistrationPattern = /^(?:'(?[a-z][a-z0-9]*(?:-[a-z0-9]+)*)'|(?[a-z][a-z0-9]*(?:-[a-z0-9]+)*))\s*:\s*\(\s*\)\s*=>\s*import\(\s*'\.\/src\/api\/(?[a-z][a-z0-9]*(?:-[a-z0-9]+)*)-client\.ts'\s*\)$/u; + return ( + registrationEntries?.filter((entry) => registrationPattern.test(entry)).length === 1 && + allRegistrationEntries?.every((entry) => { + const match = canonicalRegistrationPattern.exec(entry); + const name = match?.groups?.quoted ?? match?.groups?.bare; + const file = match?.groups?.file; + return ( + name !== undefined && + file !== undefined && + (file === name || file === `${name}-report` || file === `${name}-search`) + ); + }) === true && + allRegistrationEntries?.filter((entry) => objectEntryKey(entry) === contribution.name) + .length === 1 && + slotIsInsideObjectProperty( + registration, + registrationCategory, + `// `, + `// `, + ) + ); +}; + +const publishesSharedApiContribution = ( + sharedApi: string, + escapedApiValue: string, + escapedContractStem: string, +): boolean => + hasExactlyOne( + maskComments(sharedApi), + new RegExp(`\\.addHttpApi\\(${escapedApiValue}\\)`, 'gu'), + ) && + slotHasExactlyOneCodeMatch( + sharedApi, + '// ', + '// ', + new RegExp( + `import \\{ ${escapedApiValue} \\} from './apis/${escapedContractStem}\\.ts';`, + 'gu', + ), + ) && + slotHasExactlyOneCodeMatch( + sharedApi, + '// ', + '// ', + new RegExp(`\\.addHttpApi\\(${escapedApiValue}\\)`, 'gu'), + ); + +const hasPublishedContract = ( + sharedApi: string, + manifest: string, + registration: string, + handlerRoot: string, + contribution: GovernedReadContribution, + moduleId: string, +): boolean => { + const expectedApi = governedApiBinding(sharedApi); + const apiValue = contributionApiValue(contribution.kind, contribution.name); + const camel = toCamelCase(contribution.name); + const serverStem = contributionServerStem(contribution); + const escapedContractStem = escapeRegExp(contribution.contractStem); + const escapedApiValue = escapeRegExp(apiValue); + const escapedCamel = escapeRegExp(camel); + const escapedServerStem = escapeRegExp(serverStem); + const handlerLayers = generatedSlotEntries( + handlerRoot, + GOVERNED_HANDLER_LAYER_SLOT_START, + GOVERNED_HANDLER_LAYER_SLOT_END, + ); const expectedLayer = `${camel}ReadApiLive`; const isExpectedHandlerLayer = (entry: string): boolean => { if (!isWholeCallExpression(entry, new RegExp(`^${escapedCamel}ReadApiLive\\.pipe\\(`, 'u'))) { @@ -1551,45 +1554,9 @@ const hasPublishedContract = ( expectedApi !== undefined && hasGovernedSharedApiRoot(sharedApi) && hasGovernedHandlerRoot(handlerRoot) && - hasExactlyOne( - maskComments(sharedApi), - new RegExp(`\\.addHttpApi\\(${escapedApiValue}\\)`, 'gu'), - ) && - slotHasExactlyOneCodeMatch( - sharedApi, - '// ', - '// ', - new RegExp( - `import \\{ ${escapedApiValue} \\} from './apis/${escapedContractStem}\\.ts';`, - 'gu', - ), - ) && - slotHasExactlyOneCodeMatch( - sharedApi, - '// ', - '// ', - new RegExp(`\\.addHttpApi\\(${escapedApiValue}\\)`, 'gu'), - ) && + publishesSharedApiContribution(sharedApi, escapedApiValue, escapedContractStem) && hasManifestContract(manifest, contribution, moduleId, escapedContractStem, escapedApiValue) && - registrationEntries?.filter((entry) => registrationPattern.test(entry)).length === 1 && - allRegistrationEntries?.every((entry) => { - const match = canonicalRegistrationPattern.exec(entry); - const name = match?.groups?.quoted ?? match?.groups?.bare; - const file = match?.groups?.file; - return ( - name !== undefined && - file !== undefined && - (file === name || file === `${name}-report` || file === `${name}-search`) - ); - }) === true && - allRegistrationEntries?.filter((entry) => objectEntryKey(entry) === contribution.name) - .length === 1 && - slotIsInsideObjectProperty( - registration, - registrationCategory, - `// `, - `// `, - ) && + hasPublishedRegistration(registration, contribution) && slotHasExactlyOneCodeMatch( handlerRoot, '// ', @@ -1614,7 +1581,78 @@ const hasPublishedContract = ( * File presence alone is intentionally insufficient: each contract must remain * owner-published, privately registered, authenticated, gated, and contract-derived. */ -// eslint-disable-next-line complexity -- Complete-seam recognition intentionally fails closed across every generated owner boundary. +const actionBoundaryHeader = '// @generated by OntOS Codesmith MicroVertical Action Boundary v1\n'; +const hasActionBoundaryAuthentication = (source: string | undefined): boolean => { + if (source === undefined || !source.startsWith(actionBoundaryHeader)) { + return false; + } + const expression = assignedExpression( + source, + /export const authenticateOperationPrincipal\s*=\s*/u, + ); + return ( + expression !== undefined && + isWholeCallExpression(expression, /^makeMicroverticalHttpPrincipalAuthentication\(/u) && + hasExactValueImport( + source, + 'makeMicroverticalHttpPrincipalAuthentication', + '@app/core-runtime/http/principal-authentication', + ) && + hasGeneratedOperationPrincipalContract(source) + ); +}; +const hasActionBoundaryGateway = (source: string | undefined, appId: string): boolean => + source !== undefined && + source.startsWith(actionBoundaryHeader) && + assignedExpression(source, /export const operationGateway\s*=\s*/u) === + 'makeOperationGateway()' && + hasGeneratedOperationGatewayContract(source, appId); + +type GeneratedContribution = readonly [string, string, GovernedReadContribution | undefined]; +const hasMatchingModuleApiSlots = ( + manifest: string, + registration: string, + contributions: readonly GeneratedContribution[], +): boolean => { + const moduleApiCount = contributions.filter((entry) => entry[2]?.kind === MODULE_API_KIND).length; + const manifestApis = generatedSlotEntries( + manifest, + MANIFEST_API_SLOT_START, + MANIFEST_API_SLOT_END, + ); + const registrationApis = generatedSlotEntries( + registration, + '// ', + '// ', + ); + return manifestApis?.length === moduleApiCount && registrationApis?.length === moduleApiCount; +}; + +const generatedReadContributions = ( + sources: ReadonlyMap, + verticalPath: string, +): readonly GeneratedContribution[] => { + const contractPrefix = `${verticalPath}/shared/apis/`; + const generatedContracts = [...sources.entries()].filter( + ([candidate, source]) => + candidate.startsWith(contractPrefix) && + candidate.endsWith('.ts') && + !candidate.slice(contractPrefix.length).includes('/') && + (source.startsWith(MODULE_API_HEADER) || source.startsWith(GOVERNED_CONTRIBUTION_HEADER)), + ); + return generatedContracts.map( + ([candidate, source]) => + [ + candidate, + source, + governedReadContribution(path.posix.basename(candidate, '.ts'), source), + ] as const, + ); +}; + +const governedOwnerModuleId = (manifest: string | undefined): string | undefined => + /^\/\/ @ontos-module-id (?[^\s]+)$/mu.exec(manifest ?? '')?.groups?.moduleId; + export const hasCompleteGeneratedModuleApiSeam = ( sources: ReadonlyMap, sharedApiFile: string, @@ -1627,75 +1665,29 @@ export const hasCompleteGeneratedModuleApiSeam = ( const handlerRoot = sources.get(`${verticalPath}/api/index.ts`); const principal = sources.get(`${verticalPath}/api/auth/action-principal.ts`); const gateway = sources.get(`${verticalPath}/src/api/action-gateway.ts`); - const moduleId = /^\/\/ @ontos-module-id (?[^\s]+)$/mu.exec(manifest ?? '')?.groups - ?.moduleId; - const principalAuthentication = - principal === undefined - ? undefined - : assignedExpression(principal, /export const authenticateOperationPrincipal\s*=\s*/u); - const operationGateway = - gateway === undefined - ? undefined - : assignedExpression(gateway, /export const operationGateway\s*=\s*/u); + const moduleId = governedOwnerModuleId(manifest); if ( sharedApi === undefined || manifest === undefined || registration === undefined || handlerRoot === undefined || - principal === undefined || - gateway === undefined || - moduleId === undefined || - !principal.startsWith('// @generated by OntOS Codesmith MicroVertical Action Boundary v1\n') || - principalAuthentication === undefined || - !isWholeCallExpression( - principalAuthentication, - /^makeMicroverticalHttpPrincipalAuthentication\(/u, - ) || - !hasExactValueImport( - principal, - 'makeMicroverticalHttpPrincipalAuthentication', - '@app/core-runtime/http/principal-authentication', - ) || - !gateway.startsWith('// @generated by OntOS Codesmith MicroVertical Action Boundary v1\n') || - operationGateway !== 'makeOperationGateway()' || - !hasGeneratedOperationGatewayContract(gateway, deploymentAppId) || - !hasGeneratedOperationPrincipalContract(principal) + moduleId === undefined + ) { + return false; + } + if ( + !hasActionBoundaryAuthentication(principal) || + !hasActionBoundaryGateway(gateway, deploymentAppId) ) { return false; } - const contractPrefix = `${verticalPath}/shared/apis/`; - const generatedContracts = [...sources.entries()].filter( - ([candidate, source]) => - candidate.startsWith(contractPrefix) && - candidate.endsWith('.ts') && - !candidate.slice(contractPrefix.length).includes('/') && - (source.startsWith(MODULE_API_HEADER) || source.startsWith(GOVERNED_CONTRIBUTION_HEADER)), - ); - const contributions = generatedContracts.map( - ([candidate, source]) => - [ - candidate, - source, - governedReadContribution(path.posix.basename(candidate, '.ts'), source), - ] as const, - ); - if (contributions.length === 0 || contributions.some((entry) => entry[2] === undefined)) { + const contributions = generatedReadContributions(sources, verticalPath); + if (contributions.length === 0) { return false; } - const moduleApiCount = contributions.filter((entry) => entry[2]?.kind === MODULE_API_KIND).length; - const manifestApis = generatedSlotEntries( - manifest, - MANIFEST_API_SLOT_START, - MANIFEST_API_SLOT_END, - ); - const registrationApis = generatedSlotEntries( - registration, - '// ', - '// ', - ); - if (manifestApis?.length !== moduleApiCount || registrationApis?.length !== moduleApiCount) { + if (!hasMatchingModuleApiSlots(manifest, registration, contributions)) { return false; } @@ -1707,11 +1699,8 @@ export const hasCompleteGeneratedModuleApiSeam = ( const group = contributionGroup(contribution.kind, contribution.name); const apiValue = contributionApiValue(contribution.kind, contribution.name); const readDirectory = contributionReadDirectory(contribution.kind); - const readSuffix = contribution.kind === MODULE_API_KIND ? '.read' : '.provider'; - const serverStem = - contribution.kind === MODULE_API_KIND - ? `${contribution.name}-read` - : contribution.contractStem; + const { readSuffix } = contributionProfiles[contribution.kind]; + const serverStem = contributionServerStem(contribution); const readSource = sources.get( `${verticalPath}/src/${readDirectory}/${contribution.name}${readSuffix}.ts`, ); @@ -1724,22 +1713,17 @@ export const hasCompleteGeneratedModuleApiSeam = ( readSource === undefined || clientSource === undefined || serverSource === undefined || - !contractSource.startsWith(header) || - !readSource.startsWith(header) || - !clientSource.startsWith(header) || - !serverSource.startsWith(header) + ![contractSource, readSource, clientSource, serverSource].every((source) => + source.startsWith(header), + ) ) { return false; } const escapedCamel = escapeRegExp(camel); const escapedGroup = escapeRegExp(group); - const type = toPascalCase(contribution.name); - const schemaStem = `${type}${contribution.kind === MODULE_API_KIND ? '' : 'Provider'}`; - const endpointPath = - contribution.kind === MODULE_API_KIND - ? `/reads/${contribution.name}` - : `/${moduleId}/${contribution.kind === REPORT_KIND ? 'reports' : 'search'}/${contribution.name}`; + const schemaStem = contributionSchemaStem(contribution.kind, contribution.name); + const endpointPath = contributionEndpoint(contribution, moduleId); const checks = { client: hasClientContract(clientSource, contribution, group, apiValue, deploymentAppId), contract: @@ -1771,6 +1755,12 @@ export const hasCompleteGeneratedModuleApiSeam = ( }); }; +const readDirectoryKinds = new Map([ + ['api', MODULE_API_KIND], + ['reports', REPORT_KIND], + ['search', SEARCH_PROVIDER_KIND], +]); + export const hasGeneratedGovernedServerContract = ( source: string, exportedName: string, @@ -1780,9 +1770,7 @@ export const hasGeneratedGovernedServerContract = ( /from '(?\.\.\/src\/(?api|search|reports)\/(?[a-z0-9-]+)\.(?:read|provider)\.ts)'/u.exec( source, ); - const readPath = readImport?.groups?.path; - const name = readImport?.groups?.name; - const directory = readImport?.groups?.directory; + const { directory, name, path: readPath } = readImport?.groups ?? {}; if ( readPath === undefined || name === undefined || @@ -1791,15 +1779,12 @@ export const hasGeneratedGovernedServerContract = ( ) { return false; } - let kind: GovernedReadKind = SEARCH_PROVIDER_KIND; - if (directory === 'api') { - kind = MODULE_API_KIND; - } else if (directory === 'reports') { - kind = REPORT_KIND; + const kind = readDirectoryKinds.get(directory); + if (kind === undefined) { + return false; } - const stem = - kind === MODULE_API_KIND ? name : `${name}-${kind === REPORT_KIND ? 'report' : 'search'}`; - const schemaStem = `${toPascalCase(name)}${kind === MODULE_API_KIND ? '' : 'Provider'}`; + const stem = kind === MODULE_API_KIND ? name : `${name}-${contributionRole(kind)}`; + const schemaStem = contributionSchemaStem(kind, name); return ( source.startsWith(generatedHeader(kind)) && hasServerContract( diff --git a/app/scripts/generated-module-api-boundary.mts b/app/scripts/generated-module-api-boundary.mts index 6cdff6a5a..d70046cda 100644 --- a/app/scripts/generated-module-api-boundary.mts +++ b/app/scripts/generated-module-api-boundary.mts @@ -1,3 +1,4 @@ +import { DelimiterDepth, toCamelCase } from './boundary-source-structure.mts'; import { LanguageVariant, SyntaxKind, createScanner } from '@typescript/native/unstable/ast'; const REGISTRATION_API_SLOT = [ @@ -29,22 +30,35 @@ const MANIFEST_SHELL_SEARCH_SLOT = [ '// ', ] as const; -export const toPascalCase = (value: string): string => - value - .split('-') - .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) - .join(''); - -const toCamelCase = (value: string): string => { - const pascal = toPascalCase(value); - return `${pascal.slice(0, 1).toLowerCase()}${pascal.slice(1)}`; -}; +export { toPascalCase } from './boundary-source-structure.mts'; export interface GovernedClientToken { readonly kind: SyntaxKind; readonly value: string; } +/** Out-of-range lookahead is a nonmatching token, never an invented identifier. */ +const tokenKind = (tokens: readonly GovernedClientToken[], index: number): SyntaxKind | undefined => + tokens[index]?.kind; +const tokenValue = (tokens: readonly GovernedClientToken[], index: number): string | undefined => + tokens[index]?.value; + +const tokenDelimiter = new Map([ + [SyntaxKind.OpenBraceToken, '{'], + [SyntaxKind.CloseBraceToken, '}'], + [SyntaxKind.OpenBracketToken, '['], + [SyntaxKind.CloseBracketToken, ']'], + [SyntaxKind.OpenParenToken, '('], + [SyntaxKind.CloseParenToken, ')'], +]); + +const tokenBraceDelta = (kind: SyntaxKind | undefined): number => { + if (kind === SyntaxKind.OpenBraceToken) { + return 1; + } + return kind === SyntaxKind.CloseBraceToken ? -1 : 0; +}; + type ExpectedToken = readonly [kind: SyntaxKind, value?: string]; const REGULAR_EXPRESSION_PRECEDERS = new Set([ @@ -57,6 +71,32 @@ const REGULAR_EXPRESSION_PRECEDERS = new Set([ SyntaxKind.ReturnKeyword, ]); +const scanTemplateDelimiter = ( + scanner: ReturnType, + scannedKind: SyntaxKind, + templateExpressionBraceDepths: number[], +): SyntaxKind => { + let kind = scannedKind; + const templateDepthIndex = templateExpressionBraceDepths.length - 1; + if (kind === SyntaxKind.TemplateHead) { + templateExpressionBraceDepths.push(0); + } else if (kind === SyntaxKind.OpenBraceToken && templateDepthIndex >= 0) { + templateExpressionBraceDepths[templateDepthIndex] = + (templateExpressionBraceDepths[templateDepthIndex] ?? 0) + 1; + } else if (kind === SyntaxKind.CloseBraceToken && templateDepthIndex >= 0) { + const braceDepth = templateExpressionBraceDepths[templateDepthIndex] ?? 0; + if (braceDepth === 0) { + kind = scanner.reScanTemplateToken(false); + if (kind === SyntaxKind.TemplateTail) { + templateExpressionBraceDepths.pop(); + } + } else { + templateExpressionBraceDepths[templateDepthIndex] = braceDepth - 1; + } + } + return kind; +}; + export const tokenizeGovernedClient = (source: string): readonly GovernedClientToken[] => { const scanner = createScanner(true, LanguageVariant.Standard, source); const tokens: GovernedClientToken[] = []; @@ -64,7 +104,6 @@ export const tokenizeGovernedClient = (source: string): readonly GovernedClientT let scannedKind = scanner.scan(); while (scannedKind !== SyntaxKind.EndOfFile) { let kind: SyntaxKind = scannedKind; - const templateDepthIndex = templateExpressionBraceDepths.length - 1; if ( kind === SyntaxKind.SlashToken && (tokens.length === 0 || @@ -72,22 +111,7 @@ export const tokenizeGovernedClient = (source: string): readonly GovernedClientT ) { kind = scanner.reScanSlashToken(); } - if (kind === SyntaxKind.TemplateHead) { - templateExpressionBraceDepths.push(0); - } else if (kind === SyntaxKind.OpenBraceToken && templateDepthIndex >= 0) { - templateExpressionBraceDepths[templateDepthIndex] = - (templateExpressionBraceDepths[templateDepthIndex] ?? 0) + 1; - } else if (kind === SyntaxKind.CloseBraceToken && templateDepthIndex >= 0) { - const braceDepth = templateExpressionBraceDepths[templateDepthIndex] ?? 0; - if (braceDepth === 0) { - kind = scanner.reScanTemplateToken(false); - if (kind === SyntaxKind.TemplateTail) { - templateExpressionBraceDepths.pop(); - } - } else { - templateExpressionBraceDepths[templateDepthIndex] = braceDepth - 1; - } - } + kind = scanTemplateDelimiter(scanner, kind, templateExpressionBraceDepths); tokens.push({ kind, value: scanner.getTokenValue() }); scannedKind = scanner.scan(); } @@ -103,6 +127,13 @@ const matchesSequence = ( expected: readonly ExpectedToken[], ): boolean => expected.every((token, offset) => matchesToken(tokens[start + offset], token)); +const isOptionalTrailingComma = ( + tokens: readonly GovernedClientToken[], + next: number, + close: number, +): boolean => + next === close || (tokenKind(tokens, next) === SyntaxKind.CommaToken && next + 1 === close); + const findSequence = ( tokens: readonly GovernedClientToken[], expected: readonly ExpectedToken[], @@ -129,11 +160,7 @@ const findSequenceAtBraceDepth = ( if (braceDepth === expectedDepth && matchesSequence(tokens, index, expected)) { return index; } - if (tokens[index]?.kind === SyntaxKind.OpenBraceToken) { - braceDepth += 1; - } else if (tokens[index]?.kind === SyntaxKind.CloseBraceToken) { - braceDepth -= 1; - } + braceDepth += tokenBraceDelta(tokenKind(tokens, index)); } return undefined; }; @@ -149,11 +176,7 @@ const sequenceOccurrencesAtBraceDepth = ( if (braceDepth === expectedDepth && matchesSequence(tokens, index, expected)) { count += 1; } - if (tokens[index]?.kind === SyntaxKind.OpenBraceToken) { - braceDepth += 1; - } else if (tokens[index]?.kind === SyntaxKind.CloseBraceToken) { - braceDepth -= 1; - } + braceDepth += tokenBraceDelta(tokenKind(tokens, index)); } return count; }; @@ -181,67 +204,25 @@ const findTopLevelSequence = ( expected: readonly ExpectedToken[], start: number, end: number, -): number | undefined => { - let braceDepth = 0; - for (let index = start; index < end; index += 1) { - if (braceDepth === 0 && matchesSequence(tokens, index, expected)) { - return index; - } - const kind = tokens[index]?.kind; - if (kind === SyntaxKind.OpenBraceToken) { - braceDepth += 1; - } else if (kind === SyntaxKind.CloseBraceToken) { - braceDepth -= 1; - } - } - return undefined; -}; +): number | undefined => findSequenceAtBraceDepth(tokens, expected, start, end, 0); const hasTopLevelSequence = ( tokens: readonly GovernedClientToken[], expected: readonly ExpectedToken[], ): boolean => findTopLevelSequence(tokens, expected, 0, tokens.length) !== undefined; -export const hasTopLevelExportedConst = (source: string, name: string): boolean => - hasTopLevelSequence(tokenizeGovernedClient(source), [ - [SyntaxKind.ExportKeyword], - [SyntaxKind.ConstKeyword], - [SyntaxKind.Identifier, name], - [SyntaxKind.EqualsToken], - ]); - const findRootExpressionSequence = ( tokens: readonly GovernedClientToken[], expected: readonly ExpectedToken[], start: number, end: number, ): number | undefined => { - let braceDepth = 0; - let bracketDepth = 0; - let parenthesisDepth = 0; + const depth = new DelimiterDepth(); for (let index = start; index < end; index += 1) { - if ( - braceDepth === 0 && - bracketDepth === 0 && - parenthesisDepth === 0 && - matchesSequence(tokens, index, expected) - ) { + if (depth.isTopLevel() && matchesSequence(tokens, index, expected)) { return index; } - const kind = tokens[index]?.kind; - if (kind === SyntaxKind.OpenBraceToken) { - braceDepth += 1; - } else if (kind === SyntaxKind.CloseBraceToken) { - braceDepth -= 1; - } else if (kind === SyntaxKind.OpenBracketToken) { - bracketDepth += 1; - } else if (kind === SyntaxKind.CloseBracketToken) { - bracketDepth -= 1; - } else if (kind === SyntaxKind.OpenParenToken) { - parenthesisDepth += 1; - } else if (kind === SyntaxKind.CloseParenToken) { - parenthesisDepth -= 1; - } + depth.update(tokenDelimiter.get(tokenKind(tokens, index) ?? SyntaxKind.Unknown)); } return undefined; }; @@ -285,12 +266,26 @@ const findClosingParenthesis = ( return undefined; }; -const findObjectPropertyValue = ( +const isNamedObjectProperty = ( + tokens: readonly GovernedClientToken[], + index: number, + property: string, +): boolean => { + const token = tokens[index]; + return ( + (token?.kind === SyntaxKind.Identifier || token?.kind === SyntaxKind.StringLiteral) && + token.value === property && + tokenKind(tokens, index + 1) === SyntaxKind.ColonToken + ); +}; + +const objectPropertyValuePositions = ( tokens: readonly GovernedClientToken[], openBraceIndex: number, closeBraceIndex: number, property: string, -): number | undefined => { +): readonly number[] => { + const positions: number[] = []; let depth = 0; for (let index = openBraceIndex; index < closeBraceIndex; index += 1) { const token = tokens[index]; @@ -298,43 +293,27 @@ const findObjectPropertyValue = ( depth += 1; } else if (token?.kind === SyntaxKind.CloseBraceToken) { depth -= 1; - } else if ( - depth === 1 && - (token?.kind === SyntaxKind.Identifier || token?.kind === SyntaxKind.StringLiteral) && - token.value === property && - tokens[index + 1]?.kind === SyntaxKind.ColonToken - ) { - return index + 2; + } else if (depth === 1 && isNamedObjectProperty(tokens, index, property)) { + positions.push(index + 2); } } - return undefined; + return positions; }; +const findObjectPropertyValue = ( + tokens: readonly GovernedClientToken[], + openBraceIndex: number, + closeBraceIndex: number, + property: string, +): number | undefined => + objectPropertyValuePositions(tokens, openBraceIndex, closeBraceIndex, property)[0]; + const directObjectPropertyOccurrences = ( tokens: readonly GovernedClientToken[], openBraceIndex: number, closeBraceIndex: number, property: string, -): number => { - let count = 0; - let depth = 0; - for (let index = openBraceIndex; index < closeBraceIndex; index += 1) { - const token = tokens[index]; - if (token?.kind === SyntaxKind.OpenBraceToken) { - depth += 1; - } else if (token?.kind === SyntaxKind.CloseBraceToken) { - depth -= 1; - } else if ( - depth === 1 && - (token?.kind === SyntaxKind.Identifier || token?.kind === SyntaxKind.StringLiteral) && - token.value === property && - tokens[index + 1]?.kind === SyntaxKind.ColonToken - ) { - count += 1; - } - } - return count; -}; +): number => objectPropertyValuePositions(tokens, openBraceIndex, closeBraceIndex, property).length; const hasExactObjectPropertyValue = ( tokens: readonly GovernedClientToken[], @@ -344,7 +323,7 @@ const hasExactObjectPropertyValue = ( if (valueStart === undefined || !matchesSequence(tokens, valueStart, expected)) { return false; } - const follower = tokens[valueStart + expected.length]?.kind; + const follower = tokenKind(tokens, valueStart + expected.length); return follower === SyntaxKind.CommaToken || follower === SyntaxKind.CloseBraceToken; }; @@ -356,22 +335,17 @@ const directObjectPropertyNames = ( const properties: string[] = []; let depth = 0; for (let index = openBraceIndex; index < closeBraceIndex; index += 1) { - const token = tokens[index]; - if (token?.kind === SyntaxKind.OpenBraceToken) { - depth += 1; - } else if (token?.kind === SyntaxKind.CloseBraceToken) { - depth -= 1; - } else if ( - depth === 1 && - (token?.kind === SyntaxKind.DotDotDotToken || token?.kind === SyntaxKind.OpenBracketToken) - ) { + const kind = tokenKind(tokens, index); + depth += tokenBraceDelta(kind); + if (depth !== 1) { + continue; + } + if (kind === SyntaxKind.DotDotDotToken || kind === SyntaxKind.OpenBracketToken) { return undefined; - } else if ( - depth === 1 && - (token?.kind === SyntaxKind.Identifier || token?.kind === SyntaxKind.StringLiteral) && - tokens[index + 1]?.kind === SyntaxKind.ColonToken - ) { - properties.push(token.value); + } + const value = tokenValue(tokens, index); + if (value !== undefined && isNamedObjectProperty(tokens, index, value)) { + properties.push(value); } } return properties; @@ -630,7 +604,7 @@ const exportedConsts = (tokens: readonly GovernedClientToken[]): readonly Export [SyntaxKind.EqualsToken], ]) ) { - const name = tokens[index + 2]?.value; + const name = tokenValue(tokens, index + 2); if (name !== undefined) { const nextExport = findSequence( tokens, @@ -645,59 +619,67 @@ const exportedConsts = (tokens: readonly GovernedClientToken[]): readonly Export return declarations; }; +const clientHelperAt = ( + tokens: readonly GovernedClientToken[], + index: number, + end: number, +): GovernedClientHelper | undefined => { + if ( + !matchesSequence(tokens, index, [ + [SyntaxKind.ConstKeyword], + [SyntaxKind.Identifier], + [SyntaxKind.EqualsToken], + [SyntaxKind.OpenParenToken], + ]) + ) { + return undefined; + } + const name = tokenValue(tokens, index + 1); + const parametersClose = findClosingParenthesis(tokens, index + 3, end); + if (name === undefined || parametersClose === undefined) { + return undefined; + } + const bodyOpen = parametersClose + 2; + const bodyClose = findClosingBrace(tokens, bodyOpen); + if (bodyClose === undefined || bodyClose + 1 >= end) { + return undefined; + } + return matchesSequence(tokens, parametersClose + 1, [ + [SyntaxKind.EqualsGreaterThanToken], + [SyntaxKind.OpenBraceToken], + ]) && tokenKind(tokens, bodyClose + 1) === SyntaxKind.SemicolonToken + ? { + declarationStart: index, + end: bodyClose + 2, + name, + parametersEnd: parametersClose, + parametersStart: index + 4, + start: bodyOpen + 1, + } + : undefined; +}; + const findClientHelper = ( tokens: readonly GovernedClientToken[], authorizedExportStart: number, ): GovernedClientHelper | undefined => { for (let index = 0; index < authorizedExportStart; index += 1) { + const helper = clientHelperAt(tokens, index, authorizedExportStart); if ( - matchesSequence(tokens, index, [ - [SyntaxKind.ConstKeyword], - [SyntaxKind.Identifier], - [SyntaxKind.EqualsToken], - [SyntaxKind.OpenParenToken], - ]) - ) { - const name = tokens[index + 1]?.value; - const parametersClose = findClosingParenthesis(tokens, index + 3, authorizedExportStart); - const bodyOpen = parametersClose === undefined ? undefined : parametersClose + 2; - const bodyClose = bodyOpen === undefined ? undefined : findClosingBrace(tokens, bodyOpen); - const helper = - name !== undefined && - parametersClose !== undefined && - bodyOpen !== undefined && - bodyClose !== undefined && - matchesSequence(tokens, parametersClose + 1, [ - [SyntaxKind.EqualsGreaterThanToken], + helper !== undefined && + findTopLevelSequence( + tokens, + [ + [SyntaxKind.ConstKeyword], + [SyntaxKind.Identifier, 'clientConfig'], + [SyntaxKind.EqualsToken], [SyntaxKind.OpenBraceToken], - ]) && - bodyClose + 1 < authorizedExportStart && - tokens[bodyClose + 1]?.kind === SyntaxKind.SemicolonToken - ? { - declarationStart: index, - end: bodyClose + 2, - name, - parametersEnd: parametersClose, - parametersStart: index + 4, - start: bodyOpen + 1, - } - : undefined; - if ( - helper !== undefined && - findTopLevelSequence( - tokens, - [ - [SyntaxKind.ConstKeyword], - [SyntaxKind.Identifier, 'clientConfig'], - [SyntaxKind.EqualsToken], - [SyntaxKind.OpenBraceToken], - ], - helper.start, - helper.end, - ) !== undefined - ) { - return helper; - } + ], + helper.start, + helper.end, + ) !== undefined + ) { + return helper; } } return undefined; @@ -726,16 +708,38 @@ const hasOnlyAllowedModuleStatements = ( `${operationStem}ClientOptions`, ]); let optionsInterfaceSeen = false; - // eslint-disable-next-line complexity -- This recursive recognizer is the closed top-level client module grammar. + const consumeOptionsInterface = (index: number): number | undefined => { + const name = tokenValue(tokens, index + 2); + if (name === undefined || optionsInterfaceSeen || !allowedOptionsInterfaces.has(name)) { + return undefined; + } + optionsInterfaceSeen = true; + const open = findSequence(tokens, [[SyntaxKind.OpenBraceToken]], index + 2); + if (open === undefined) { + return undefined; + } + const close = findClosingBrace(tokens, open); + if (close === undefined) { + return undefined; + } + return tokenKind(tokens, close + 1) === SyntaxKind.SemicolonToken ? close + 2 : close + 1; + }; + const isAllowedOperation = (index: number): boolean => + matchesSequence(tokens, index, [ + [SyntaxKind.ExportKeyword], + [SyntaxKind.ConstKeyword], + [SyntaxKind.Identifier], + [SyntaxKind.EqualsToken], + ]) && allowedOperations.has(tokenValue(tokens, index + 2) ?? ''); const acceptsFrom = (index: number): boolean => { if (index === tokens.length) { return true; } - if (tokens[index]?.kind === SyntaxKind.ImportKeyword) { - const end = findStatementSemicolon(tokens, index); - return end !== undefined && acceptsFrom(end + 1); - } - if (tokens[index]?.kind === SyntaxKind.TypeKeyword) { + if ( + [SyntaxKind.ImportKeyword, SyntaxKind.TypeKeyword].includes( + tokenKind(tokens, index) ?? SyntaxKind.Unknown, + ) + ) { const end = findStatementSemicolon(tokens, index); return end !== undefined && acceptsFrom(end + 1); } @@ -745,34 +749,13 @@ const hasOnlyAllowedModuleStatements = ( [SyntaxKind.Identifier], ]); if (isExportedInterface) { - const name = tokens[index + 2]?.value; - if (name === undefined || optionsInterfaceSeen || !allowedOptionsInterfaces.has(name)) { - return false; - } - optionsInterfaceSeen = true; - const openBrace = findSequence(tokens, [[SyntaxKind.OpenBraceToken]], index + 2); - const closeBrace = openBrace === undefined ? undefined : findClosingBrace(tokens, openBrace); - if (closeBrace === undefined) { - return false; - } - const next = - tokens[closeBrace + 1]?.kind === SyntaxKind.SemicolonToken - ? closeBrace + 2 - : closeBrace + 1; - return acceptsFrom(next); + const next = consumeOptionsInterface(index); + return next !== undefined && acceptsFrom(next); } if (index === helper.declarationStart) { return acceptsFrom(helper.end); } - if ( - matchesSequence(tokens, index, [ - [SyntaxKind.ExportKeyword], - [SyntaxKind.ConstKeyword], - [SyntaxKind.Identifier], - [SyntaxKind.EqualsToken], - ]) && - allowedOperations.has(tokens[index + 2]?.value ?? '') - ) { + if (isAllowedOperation(index)) { const end = findStatementSemicolon(tokens, index); return end !== undefined && acceptsFrom(end + 1); } @@ -824,6 +807,35 @@ const hasExactTransportHeaders = ( ); }; +const hasClientConfigValues = ( + tokens: readonly GovernedClientToken[], + configOpen: number, + configClose: number, + ownerApiValue: string, + defaultApiPrefix: string, +): boolean => { + const apiValue = findObjectPropertyValue(tokens, configOpen, configClose, 'api'); + const prefixValue = findObjectPropertyValue(tokens, configOpen, configClose, 'defaultApiPrefix'); + const headersValue = findObjectPropertyValue(tokens, configOpen, configClose, 'transportHeaders'); + if ( + headersValue === undefined || + !hasExactObjectPropertyValue(tokens, apiValue, [[SyntaxKind.Identifier, ownerApiValue]]) || + !hasExactObjectPropertyValue(tokens, prefixValue, [ + [SyntaxKind.StringLiteral, defaultApiPrefix], + ]) || + tokenKind(tokens, headersValue) !== SyntaxKind.OpenBraceToken + ) { + return false; + } + const headersClose = findClosingBrace(tokens, headersValue); + return ( + headersClose !== undefined && + (tokenKind(tokens, headersClose + 1) === SyntaxKind.CommaToken || + tokenKind(tokens, headersClose + 1) === SyntaxKind.CloseBraceToken) && + hasExactTransportHeaders(tokens, headersValue, configClose) + ); +}; + const hasClientConfig = ( tokens: readonly GovernedClientToken[], helper: GovernedClientHelper, @@ -850,7 +862,7 @@ const hasClientConfig = ( closeBrace === undefined || closeBrace >= helper.end || configDeclaration !== helper.start || - tokens[closeBrace + 1]?.kind !== SyntaxKind.SemicolonToken || + tokenKind(tokens, closeBrace + 1) !== SyntaxKind.SemicolonToken || !hasExactProperties( directObjectPropertyNames(tokens, openBrace, closeBrace), new Set(['api', 'defaultApiPrefix', 'transportHeaders']), @@ -858,28 +870,7 @@ const hasClientConfig = ( ) { return false; } - const apiValue = findObjectPropertyValue(tokens, openBrace, closeBrace, 'api'); - const prefixValue = findObjectPropertyValue(tokens, openBrace, closeBrace, 'defaultApiPrefix'); - const headersValue = findObjectPropertyValue(tokens, openBrace, closeBrace, 'transportHeaders'); - if ( - apiValue === undefined || - prefixValue === undefined || - headersValue === undefined || - !hasExactObjectPropertyValue(tokens, apiValue, [[SyntaxKind.Identifier, ownerApiValue]]) || - !hasExactObjectPropertyValue(tokens, prefixValue, [ - [SyntaxKind.StringLiteral, defaultApiPrefix], - ]) || - tokens[headersValue]?.kind !== SyntaxKind.OpenBraceToken - ) { - return false; - } - const headersClose = findClosingBrace(tokens, headersValue); - return ( - headersClose !== undefined && - (tokens[headersClose + 1]?.kind === SyntaxKind.CommaToken || - tokens[headersClose + 1]?.kind === SyntaxKind.CloseBraceToken) && - hasExactTransportHeaders(tokens, headersValue, closeBrace) - ); + return hasClientConfigValues(tokens, openBrace, closeBrace, ownerApiValue, defaultApiPrefix); }; const factoryConsumesClientConfig = ( @@ -970,7 +961,7 @@ const parametersBindIdentifier = ( ({ kind, value }, offset) => kind === SyntaxKind.Identifier && names.has(value) && - bindingFollowers.has(tokens[start + offset + 1]?.kind ?? SyntaxKind.Unknown), + bindingFollowers.has(tokenKind(tokens, start + offset + 1) ?? SyntaxKind.Unknown), ); }; @@ -993,16 +984,12 @@ const hasTopLevelDeclaration = ( if ( braceDepth === 0 && declarationKinds.has(token?.kind ?? SyntaxKind.Unknown) && - tokens[index + 1]?.kind === SyntaxKind.Identifier && - names.has(tokens[index + 1]?.value ?? '') + tokenKind(tokens, index + 1) === SyntaxKind.Identifier && + names.has(tokenValue(tokens, index + 1) ?? '') ) { return true; } - if (token?.kind === SyntaxKind.OpenBraceToken) { - braceDepth += 1; - } else if (token?.kind === SyntaxKind.CloseBraceToken) { - braceDepth -= 1; - } + braceDepth += tokenBraceDelta(token?.kind); } return false; }; @@ -1155,6 +1142,115 @@ const generatedOperationDeclarations = ( : undefined; }; +const matchingSequenceEnd = ( + tokens: readonly GovernedClientToken[], + start: number, + alternatives: readonly (readonly ExpectedToken[])[], +): number | undefined => { + const match = alternatives.find((sequence) => matchesSequence(tokens, start, sequence)); + return match === undefined ? undefined : start + match.length; +}; + +const hasInvocationClosure = ( + tokens: readonly GovernedClientToken[], + start: number | undefined, +): boolean => + start !== undefined && + [false, true].some((trailingComma) => + matchesSequence(tokens, start, [ + ...(trailingComma ? [[SyntaxKind.CommaToken] as const] : []), + [SyntaxKind.CloseParenToken], + [SyntaxKind.CommaToken], + [SyntaxKind.CloseParenToken], + [SyntaxKind.SemicolonToken], + ]), + ); + +const generatedInvocationPayloads = ( + kind: GovernedClientExpectation['invocationKind'], +): readonly (readonly ExpectedToken[])[] => + kind === MODULE_API_INVOCATION_KIND + ? ([false, true] as const).map( + (hasTrailingComma) => + [ + [SyntaxKind.OpenBraceToken], + [SyntaxKind.Identifier, 'headers'], + [SyntaxKind.ColonToken], + [SyntaxKind.OpenBraceToken], + [SyntaxKind.CloseBraceToken], + [SyntaxKind.CommaToken], + [SyntaxKind.Identifier, 'params'], + [SyntaxKind.ColonToken], + [SyntaxKind.OpenBraceToken], + [SyntaxKind.CloseBraceToken], + [SyntaxKind.CommaToken], + [SyntaxKind.Identifier, 'payload'], + [SyntaxKind.CommaToken], + [SyntaxKind.Identifier, 'query'], + [SyntaxKind.ColonToken], + [SyntaxKind.OpenBraceToken], + [SyntaxKind.CloseBraceToken], + ...(hasTrailingComma ? ([[SyntaxKind.CommaToken]] as const) : []), + [SyntaxKind.CloseBraceToken], + [SyntaxKind.CloseParenToken], + ] satisfies readonly ExpectedToken[], + ) + : ([false, true] as const).map( + (hasTrailingComma) => + [ + [SyntaxKind.OpenBraceToken], + [SyntaxKind.Identifier, 'payload'], + ...(hasTrailingComma ? ([[SyntaxKind.CommaToken]] as const) : []), + [SyntaxKind.CloseBraceToken], + [SyntaxKind.CloseParenToken], + ] satisfies readonly ExpectedToken[], + ); + +const clientHelperShadowsImports = ( + tokens: readonly GovernedClientToken[], + helper: GovernedClientHelper, + expectation: GovernedClientExpectation, +): boolean => { + const requiredHelperImports = new Set([ + 'Effect', + 'Redacted', + 'makeEffectBffClient', + expectation.ownerApiValue, + ]); + return ( + parametersBindIdentifier( + tokens, + helper.parametersStart, + helper.parametersEnd, + requiredHelperImports, + ) || hasTopLevelDeclaration(tokens, helper.start, helper.end, requiredHelperImports) + ); +}; + +const operationParametersShadowBindings = ( + tokens: readonly GovernedClientToken[], + helper: GovernedClientHelper, + expectation: GovernedClientExpectation, + authorized: ExportedConst, + authorizedArrow: number, + operation: ExportedConst, + operationArrow: number, +): boolean => { + const authorizedShadowsBindings = parametersBindIdentifier( + tokens, + authorized.start, + authorizedArrow, + new Set([helper.name, 'Effect', 'Redacted']), + ); + const operationShadowsBindings = parametersBindIdentifier( + tokens, + operation.start, + operationArrow, + new Set(['operationGateway', expectation.authorizedOperation]), + ); + return authorizedShadowsBindings || operationShadowsBindings; +}; + const exportedOperationsUseClientHelperAndGateway = ( tokens: readonly GovernedClientToken[], helper: GovernedClientHelper, @@ -1180,43 +1276,7 @@ const exportedOperationsUseClientHelperAndGateway = ( if (authorizedArrow === undefined || operationArrow === undefined) { return false; } - const invocationPayloads = - expectation.invocationKind === MODULE_API_INVOCATION_KIND - ? ([false, true] as const).map( - (hasTrailingComma) => - [ - [SyntaxKind.OpenBraceToken], - [SyntaxKind.Identifier, 'headers'], - [SyntaxKind.ColonToken], - [SyntaxKind.OpenBraceToken], - [SyntaxKind.CloseBraceToken], - [SyntaxKind.CommaToken], - [SyntaxKind.Identifier, 'params'], - [SyntaxKind.ColonToken], - [SyntaxKind.OpenBraceToken], - [SyntaxKind.CloseBraceToken], - [SyntaxKind.CommaToken], - [SyntaxKind.Identifier, 'payload'], - [SyntaxKind.CommaToken], - [SyntaxKind.Identifier, 'query'], - [SyntaxKind.ColonToken], - [SyntaxKind.OpenBraceToken], - [SyntaxKind.CloseBraceToken], - ...(hasTrailingComma ? ([[SyntaxKind.CommaToken]] as const) : []), - [SyntaxKind.CloseBraceToken], - [SyntaxKind.CloseParenToken], - ] satisfies readonly ExpectedToken[], - ) - : ([false, true] as const).map( - (hasTrailingComma) => - [ - [SyntaxKind.OpenBraceToken], - [SyntaxKind.Identifier, 'payload'], - ...(hasTrailingComma ? ([[SyntaxKind.CommaToken]] as const) : []), - [SyntaxKind.CloseBraceToken], - [SyntaxKind.CloseParenToken], - ] satisfies readonly ExpectedToken[], - ); + const invocationPayloads = generatedInvocationPayloads(expectation.invocationKind); const authorizedInvocation = [ [SyntaxKind.Identifier, helper.name], [SyntaxKind.OpenParenToken], @@ -1250,29 +1310,14 @@ const exportedOperationsUseClientHelperAndGateway = ( [SyntaxKind.OpenParenToken], ] satisfies readonly ExpectedToken[]; const authorizedInvocationEnd = authorizedArrow + 1 + authorizedInvocation.length; - const matchingInvocationPayload = invocationPayloads.find((payload) => - matchesSequence(tokens, authorizedInvocationEnd, payload), + const authorizedInvocationTail = matchingSequenceEnd( + tokens, + authorizedInvocationEnd, + invocationPayloads, ); - const authorizedInvocationTail = - matchingInvocationPayload === undefined - ? undefined - : authorizedInvocationEnd + matchingInvocationPayload.length; const authorizedUsesHelper = matchesSequence(tokens, authorizedArrow + 1, authorizedInvocation) && - authorizedInvocationTail !== undefined && - (matchesSequence(tokens, authorizedInvocationTail, [ - [SyntaxKind.CloseParenToken], - [SyntaxKind.CommaToken], - [SyntaxKind.CloseParenToken], - [SyntaxKind.SemicolonToken], - ]) || - matchesSequence(tokens, authorizedInvocationTail, [ - [SyntaxKind.CommaToken], - [SyntaxKind.CloseParenToken], - [SyntaxKind.CommaToken], - [SyntaxKind.CloseParenToken], - [SyntaxKind.SemicolonToken], - ])); + hasInvocationClosure(tokens, authorizedInvocationTail); const gatewayInvocation = [ [SyntaxKind.Identifier, 'operationGateway'], [SyntaxKind.DotToken], @@ -1295,43 +1340,16 @@ const exportedOperationsUseClientHelperAndGateway = ( const gatewayInvocationEnd = operationArrow + 1 + gatewayInvocation.length; const operationUsesGateway = matchesSequence(tokens, operationArrow + 1, gatewayInvocation) && - (matchesSequence(tokens, gatewayInvocationEnd, [ - [SyntaxKind.CloseParenToken], - [SyntaxKind.CommaToken], - [SyntaxKind.CloseParenToken], - [SyntaxKind.SemicolonToken], - ]) || - matchesSequence(tokens, gatewayInvocationEnd, [ - [SyntaxKind.CommaToken], - [SyntaxKind.CloseParenToken], - [SyntaxKind.CommaToken], - [SyntaxKind.CloseParenToken], - [SyntaxKind.SemicolonToken], - ])); - const requiredHelperImports = new Set([ - 'Effect', - 'Redacted', - 'makeEffectBffClient', - expectation.ownerApiValue, - ]); - const helperShadowsImports = - parametersBindIdentifier( - tokens, - helper.parametersStart, - helper.parametersEnd, - requiredHelperImports, - ) || hasTopLevelDeclaration(tokens, helper.start, helper.end, requiredHelperImports); - const authorizedShadowsBindings = parametersBindIdentifier( + hasInvocationClosure(tokens, gatewayInvocationEnd); + const helperShadowsImports = clientHelperShadowsImports(tokens, helper, expectation); + const shadowsBindings = operationParametersShadowBindings( tokens, - authorized.start, + helper, + expectation, + authorized, authorizedArrow, - new Set([helper.name, 'Effect', 'Redacted']), - ); - const operationShadowsBindings = parametersBindIdentifier( - tokens, - operation.start, + operation, operationArrow, - new Set(['operationGateway', expectation.authorizedOperation]), ); return ( hasExactGeneratedOperationParameters( @@ -1346,8 +1364,7 @@ const exportedOperationsUseClientHelperAndGateway = ( authorizedUsesHelper && operationUsesGateway && !helperShadowsImports && - !authorizedShadowsBindings && - !operationShadowsBindings + !shadowsBindings ); }; @@ -1399,7 +1416,8 @@ export const generatedApiGroup = (source: string, ownerApiValue: string): string const endpointAdd = group + sequence.length; if ( endpointAdd === outerCallClose || - (tokens[endpointAdd]?.kind === SyntaxKind.CommaToken && endpointAdd + 1 === outerCallClose) + (tokenKind(tokens, endpointAdd) === SyntaxKind.CommaToken && + endpointAdd + 1 === outerCallClose) ) { return true; } @@ -1415,41 +1433,33 @@ export const generatedApiGroup = (source: string, ownerApiValue: string): string const endpointAddClose = findClosingParenthesis(tokens, endpointAdd + 2, outerCallClose + 1); return ( endpointAddClose !== undefined && - (endpointAddClose + 1 === outerCallClose || - (tokens[endpointAddClose + 1]?.kind === SyntaxKind.CommaToken && - endpointAddClose + 2 === outerCallClose)) + isOptionalTrailingComma(tokens, endpointAddClose + 1, outerCallClose) ); }; const makeOpen = apiDeclaration + 7; const makeClose = - tokens[makeOpen]?.kind === SyntaxKind.OpenParenToken + tokenKind(tokens, makeOpen) === SyntaxKind.OpenParenToken ? findClosingParenthesis(tokens, makeOpen, end) : undefined; const hasExactApiRoot = makeClose !== undefined && matchesSequence(tokens, makeOpen + 1, [[SyntaxKind.StringLiteral, ownerApiValue]]) && - (makeOpen + 2 === makeClose || - (tokens[makeOpen + 2]?.kind === SyntaxKind.CommaToken && makeOpen + 3 === makeClose)); - const group = makeClose === undefined ? undefined : makeClose + 1; - return hasExactApiRoot && - group !== undefined && - matchesSequence(tokens, group, sequence) && - isExactGroupArgument(group) - ? tokens[group + 7]?.value + isOptionalTrailingComma(tokens, makeOpen + 2, makeClose); + if (makeClose === undefined) { + return undefined; + } + const group = makeClose + 1; + return hasExactApiRoot && matchesSequence(tokens, group, sequence) && isExactGroupArgument(group) + ? tokenValue(tokens, group + 7) : undefined; }; -export const hasGeneratedProviderApiContract = ( +const hasGeneratedEndpointContract = ( source: string, ownerApiValue: string, - moduleId: string, - name: string, - kind: 'report' | 'search', + groupName: string, + endpointPath: string, ): boolean => { - const groupName = generatedApiGroup(source, ownerApiValue); - if (groupName === undefined) { - return false; - } const tokens = tokenizeGovernedClient(source); const declaration = findTopLevelSequence( tokens, @@ -1495,88 +1505,45 @@ export const hasGeneratedProviderApiContract = ( [SyntaxKind.OpenParenToken], [SyntaxKind.StringLiteral, 'execute'], [SyntaxKind.CommaToken], - [SyntaxKind.StringLiteral, `/${moduleId}/${kind === 'report' ? 'reports' : 'search'}/${name}`], + [SyntaxKind.StringLiteral, endpointPath], ] satisfies readonly ExpectedToken[]; const endpointOpen = endpoint + 3; const endpointClose = matchesSequence(tokens, endpoint, endpointSequence) - ? findClosingParenthesis(tokens, endpointOpen, tokens.length) + ? findClosingParenthesis(tokens, endpointOpen, declarationEnd) : undefined; - const groupAddClose = findClosingParenthesis(tokens, groupAddOpen, tokens.length); + const groupAddClose = findClosingParenthesis(tokens, groupAddOpen, declarationEnd); return ( endpointClose !== undefined && groupAddClose !== undefined && - (endpointClose + 1 === groupAddClose || - (tokens[endpointClose + 1]?.kind === SyntaxKind.CommaToken && - endpointClose + 2 === groupAddClose)) + isOptionalTrailingComma(tokens, endpointClose + 1, groupAddClose) ); }; - -export const hasGeneratedModuleApiContract = ( +export const hasGeneratedProviderApiContract = ( source: string, ownerApiValue: string, - groupName: string, - stem: string, + moduleId: string, + name: string, + kind: 'report' | 'search', ): boolean => { - const tokens = tokenizeGovernedClient(source); - const declaration = findTopLevelSequence( - tokens, - [ - [SyntaxKind.ExportKeyword], - [SyntaxKind.ConstKeyword], - [SyntaxKind.Identifier, ownerApiValue], - [SyntaxKind.EqualsToken], - ], - 0, - tokens.length, - ); - const declarationEnd = - declaration === undefined ? undefined : findStatementSemicolon(tokens, declaration); - if (declaration === undefined || declarationEnd === undefined) { - return false; - } - const groupMake = findSequence( - tokens, - [ - [SyntaxKind.Identifier, 'HttpApiGroup'], - [SyntaxKind.DotToken], - [SyntaxKind.Identifier, 'make'], - [SyntaxKind.OpenParenToken], - [SyntaxKind.StringLiteral, groupName], - [SyntaxKind.CloseParenToken], - [SyntaxKind.DotToken], - [SyntaxKind.Identifier, 'add'], - [SyntaxKind.OpenParenToken], - ], - declaration, - declarationEnd, - ); - if (groupMake === undefined) { - return false; - } - const groupAddOpen = groupMake + 8; - const endpoint = groupAddOpen + 1; - const endpointOpen = endpoint + 3; - const endpointClose = matchesSequence(tokens, endpoint, [ - [SyntaxKind.Identifier, 'HttpApiEndpoint'], - [SyntaxKind.DotToken], - [SyntaxKind.Identifier, 'post'], - [SyntaxKind.OpenParenToken], - [SyntaxKind.StringLiteral, 'execute'], - [SyntaxKind.CommaToken], - [SyntaxKind.StringLiteral, `/reads/${stem}`], - ]) - ? findClosingParenthesis(tokens, endpointOpen, declarationEnd) - : undefined; - const groupAddClose = findClosingParenthesis(tokens, groupAddOpen, declarationEnd); + const groupName = generatedApiGroup(source, ownerApiValue); return ( - endpointClose !== undefined && - groupAddClose !== undefined && - (endpointClose + 1 === groupAddClose || - (tokens[endpointClose + 1]?.kind === SyntaxKind.CommaToken && - endpointClose + 2 === groupAddClose)) + groupName !== undefined && + hasGeneratedEndpointContract( + source, + ownerApiValue, + groupName, + `/${moduleId}/${kind === 'report' ? 'reports' : 'search'}/${name}`, + ) ); }; +export const hasGeneratedModuleApiContract = ( + source: string, + ownerApiValue: string, + groupName: string, + stem: string, +): boolean => hasGeneratedEndpointContract(source, ownerApiValue, groupName, `/reads/${stem}`); + const topLevelCallObject = ( tokens: readonly GovernedClientToken[], exportedName: string, @@ -1597,25 +1564,17 @@ const topLevelCallObject = ( 0, tokens.length, ); - const declarationEnd = - declaration === undefined - ? undefined - : findRootExpressionSequence( - tokens, - [[SyntaxKind.SemicolonToken]], - declaration, - tokens.length, - ); - const open = declaration === undefined ? undefined : declaration + (requireExport ? 6 : 5); - const close = open === undefined ? undefined : findClosingBrace(tokens, open); - const callClose = - declaration === undefined || declarationEnd === undefined - ? undefined - : findClosingParenthesis(tokens, declaration + (requireExport ? 5 : 4), declarationEnd); - return declaration !== undefined && - declarationEnd !== undefined && - open !== undefined && - close !== undefined && + if (declaration === undefined) { + return undefined; + } + const declarationEnd = findStatementSemicolon(tokens, declaration); + if (declarationEnd === undefined) { + return undefined; + } + const open = declaration + (requireExport ? 6 : 5); + const close = findClosingBrace(tokens, open); + const callClose = findClosingParenthesis(tokens, open - 1, declarationEnd); + return close !== undefined && close < declarationEnd && callClose !== undefined && callClose + 1 === declarationEnd @@ -1635,60 +1594,57 @@ const objectHasExactString = ( [SyntaxKind.StringLiteral, value], ]); +const objectHasExactStrings = ( + tokens: readonly GovernedClientToken[], + open: number, + close: number, + expected: Readonly>, +): boolean => + Object.entries(expected).every(([property, value]) => + objectHasExactString(tokens, open, close, property, value), + ); + +const objectReferencesEntrypoint = ( + tokens: readonly GovernedClientToken[], + open: number, + close: number, + entrypoint: string, +): boolean => + directObjectPropertyOccurrences(tokens, open, close, 'entrypoint') === 1 && + hasExactObjectPropertyValue(tokens, findObjectPropertyValue(tokens, open, close, 'entrypoint'), [ + [SyntaxKind.Identifier, entrypoint], + ]); + export interface GeneratedReadAuthorization { readonly kind: 'authenticated_principal' | 'context_permission' | 'public'; readonly permission?: string; } -// eslint-disable-next-line complexity -- This parser rejects every non-canonical authorization object shape. -const generatedReadAuthorization = ( +const objectStringProperty = ( + tokens: readonly GovernedClientToken[], + open: number, + close: number, + property: string, +): string | undefined => { + const start = findObjectPropertyValue(tokens, open, close, property); + return start !== undefined && + hasExactObjectPropertyValue(tokens, start, [[SyntaxKind.StringLiteral]]) + ? tokenValue(tokens, start) + : undefined; +}; + +const authorizationObject = ( tokens: readonly GovernedClientToken[], open: number, close: number, ): GeneratedReadAuthorization | undefined => { - if (directObjectPropertyOccurrences(tokens, open, close, 'authorization') !== 1) { - return undefined; - } - const authorizationOpen = findObjectPropertyValue(tokens, open, close, 'authorization'); - const authorizationClose = - authorizationOpen === undefined || tokens[authorizationOpen]?.kind !== SyntaxKind.OpenBraceToken - ? undefined - : findClosingBrace(tokens, authorizationOpen); - if ( - authorizationOpen === undefined || - authorizationClose === undefined || - !directObjectHasNoSpread(tokens, authorizationOpen, authorizationClose) - ) { - return undefined; - } - const kindStart = findObjectPropertyValue(tokens, authorizationOpen, authorizationClose, 'kind'); - const kind = - kindStart !== undefined && - tokens[kindStart]?.kind === SyntaxKind.StringLiteral && - hasExactObjectPropertyValue(tokens, kindStart, [[SyntaxKind.StringLiteral]]) - ? tokens[kindStart]?.value - : undefined; - const permissionStart = findObjectPropertyValue( - tokens, - authorizationOpen, - authorizationClose, - 'permission', - ); - const permission = - permissionStart !== undefined && - tokens[permissionStart]?.kind === SyntaxKind.StringLiteral && - hasExactObjectPropertyValue(tokens, permissionStart, [[SyntaxKind.StringLiteral]]) - ? tokens[permissionStart]?.value - : undefined; - const properties = directObjectPropertyNames(tokens, authorizationOpen, authorizationClose); + const kind = objectStringProperty(tokens, open, close, 'kind'); + const properties = directObjectPropertyNames(tokens, open, close); if (kind === 'context_permission') { - if (permission === undefined) { - return undefined; - } - if (!/^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/u.test(permission)) { - return undefined; - } - return hasExactProperties(properties, new Set(['kind', 'permission'])) + const permission = objectStringProperty(tokens, open, close, 'permission'); + return permission !== undefined && + /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/u.test(permission) && + hasExactProperties(properties, new Set(['kind', 'permission'])) ? { kind, permission } : undefined; } @@ -1698,6 +1654,34 @@ const generatedReadAuthorization = ( return hasExactProperties(properties, new Set(['kind'])) ? { kind } : undefined; }; +const nestedObjectRange = ( + tokens: readonly GovernedClientToken[], + open: number, + close: number, + property: string, +): readonly [number, number] | undefined => { + const value = findObjectPropertyValue(tokens, open, close, property); + if (value === undefined || tokenKind(tokens, value) !== SyntaxKind.OpenBraceToken) { + return undefined; + } + const end = findClosingBrace(tokens, value); + return end === undefined ? undefined : [value, end]; +}; + +const generatedReadAuthorization = ( + tokens: readonly GovernedClientToken[], + open: number, + close: number, +): GeneratedReadAuthorization | undefined => { + if (directObjectPropertyOccurrences(tokens, open, close, 'authorization') !== 1) { + return undefined; + } + const range = nestedObjectRange(tokens, open, close, 'authorization'); + return range !== undefined && directObjectHasNoSpread(tokens, ...range) + ? authorizationObject(tokens, ...range) + : undefined; +}; + const matchesGeneratedReadAuthorization = ( actual: GeneratedReadAuthorization | undefined, expected: GeneratedReadAuthorization | undefined, @@ -1735,28 +1719,23 @@ const hasGeneratedReadContract = ( generatedReadAuthorization(tokens, entrypointOpen, entrypointClose), authorization, ) && - tokens[entrypointClose + 1]?.kind === SyntaxKind.CloseParenToken && - tokens[entrypointClose + 2]?.kind === SyntaxKind.SemicolonToken && + matchesSequence(tokens, entrypointClose + 1, [ + [SyntaxKind.CloseParenToken], + [SyntaxKind.SemicolonToken], + ]) && entrypointClose + 2 === entrypointEnd && - objectHasExactString(tokens, entrypointOpen, entrypointClose, 'access', 'read') && - objectHasExactString( - tokens, - entrypointOpen, - entrypointClose, - 'entrypointKey', - `${moduleId}.${role}.${name}`, - ) && - objectHasExactString(tokens, entrypointOpen, entrypointClose, 'moduleKey', moduleId) && - objectHasExactString(tokens, entrypointOpen, entrypointClose, 'role', role) && - directObjectPropertyOccurrences(tokens, readOpen, readClose, 'entrypoint') === 1 && - hasExactObjectPropertyValue( - tokens, - findObjectPropertyValue(tokens, readOpen, readClose, 'entrypoint'), - [[SyntaxKind.Identifier, entrypointName]], - ) && - objectHasExactString(tokens, readOpen, readClose, 'owningModuleKey', moduleId) && - objectHasExactString(tokens, readOpen, readClose, 'readKey', `${moduleId}.${role}.${name}`) && - objectHasExactString(tokens, readOpen, readClose, 'schemaVersion', '1') + objectHasExactStrings(tokens, entrypointOpen, entrypointClose, { + access: 'read', + entrypointKey: `${moduleId}.${role}.${name}`, + moduleKey: moduleId, + role, + }) && + objectReferencesEntrypoint(tokens, readOpen, readClose, entrypointName) && + objectHasExactStrings(tokens, readOpen, readClose, { + owningModuleKey: moduleId, + readKey: `${moduleId}.${role}.${name}`, + schemaVersion: '1', + }) ); }; @@ -1784,9 +1763,9 @@ const enclosingBraceRange = ( ): readonly [start: number, end: number] | undefined => { const openBraces: number[] = []; for (let cursor = 0; cursor <= index; cursor += 1) { - if (tokens[cursor]?.kind === SyntaxKind.OpenBraceToken) { + if (tokenKind(tokens, cursor) === SyntaxKind.OpenBraceToken) { openBraces.push(cursor); - } else if (tokens[cursor]?.kind === SyntaxKind.CloseBraceToken) { + } else if (tokenKind(tokens, cursor) === SyntaxKind.CloseBraceToken) { openBraces.pop(); } } @@ -1830,16 +1809,11 @@ export const hasMatchingGeneratedProviderAuthorization = ( ); const contribution = identity === undefined ? undefined : enclosingBraceRange(shellTokens, identity); - const manifestEntrypointOpen = + const manifestEntrypoint = contribution === undefined ? undefined - : findObjectPropertyValue(shellTokens, contribution[0], contribution[1], 'entrypoint'); - const manifestEntrypointClose = - manifestEntrypointOpen === undefined || - shellTokens[manifestEntrypointOpen]?.kind !== SyntaxKind.OpenBraceToken - ? undefined - : findClosingBrace(shellTokens, manifestEntrypointOpen); - if (manifestEntrypointOpen === undefined || manifestEntrypointClose === undefined) { + : nestedObjectRange(shellTokens, ...contribution, 'entrypoint'); + if (manifestEntrypoint === undefined) { return false; } const providerAuthorization = generatedReadAuthorization( @@ -1847,11 +1821,7 @@ export const hasMatchingGeneratedProviderAuthorization = ( entrypoint[0], entrypoint[1], ); - const manifestAuthorization = generatedReadAuthorization( - shellTokens, - manifestEntrypointOpen, - manifestEntrypointClose, - ); + const manifestAuthorization = generatedReadAuthorization(shellTokens, ...manifestEntrypoint); return matchesGeneratedReadAuthorization(providerAuthorization, manifestAuthorization); }; @@ -1882,31 +1852,23 @@ export const hasGeneratedModuleApiReadContract = ( generatedReadAuthorization(tokens, entrypoint[0], entrypoint[1]), authorization, ) && - (hasExactObjectPropertyValue(tokens, access, [[SyntaxKind.StringLiteral, 'read']]) || - hasExactObjectPropertyValue(tokens, access, [ - [SyntaxKind.StringLiteral, 'historical_read'], - ])) && - objectHasExactString( - tokens, - entrypoint[0], - entrypoint[1], - 'entrypointKey', - `${moduleId}.api.${name}`, - ) && - objectHasExactString(tokens, entrypoint[0], entrypoint[1], 'moduleKey', moduleId) && - objectHasExactString(tokens, entrypoint[0], entrypoint[1], 'role', 'api') && - directObjectPropertyOccurrences(tokens, read[0], read[1], 'entrypoint') === 1 && - hasExactObjectPropertyValue( - tokens, - findObjectPropertyValue(tokens, read[0], read[1], 'entrypoint'), - [[SyntaxKind.Identifier, entrypointName]], + ['read', 'historical_read'].some((value) => + hasExactObjectPropertyValue(tokens, access, [[SyntaxKind.StringLiteral, value]]), ) && + objectHasExactStrings(tokens, entrypoint[0], entrypoint[1], { + entrypointKey: `${moduleId}.api.${name}`, + moduleKey: moduleId, + role: 'api', + }) && + objectReferencesEntrypoint(tokens, read[0], read[1], entrypointName) && ['legalEntityScope', 'permissionTarget', 'policies'].every( (property) => directObjectPropertyOccurrences(tokens, read[0], read[1], property) === 1, ) && - objectHasExactString(tokens, read[0], read[1], 'owningModuleKey', moduleId) && - objectHasExactString(tokens, read[0], read[1], 'readKey', `${moduleId}.api.${name}`) && - objectHasExactString(tokens, read[0], read[1], 'schemaVersion', '1') + objectHasExactStrings(tokens, read[0], read[1], { + owningModuleKey: moduleId, + readKey: `${moduleId}.api.${name}`, + schemaVersion: '1', + }) ); }; @@ -1931,11 +1893,13 @@ export const hasGeneratedGovernedClientContract = ( factoryConsumesClientConfig(tokens, helper) && exportedOperationsUseClientHelperAndGateway(tokens, helper, expectation) && hasOnlyAllowedModuleStatements(tokens, helper, expectation) && - identifierOccurrences(tokens, 'makeEffectBffClient') === 2 && - identifierOccurrences(tokens, 'operationGateway') === 2 && - identifierOccurrences(tokens, expectation.ownerApiValue) === 2 && - identifierOccurrences(tokens, 'Effect') === 2 && - identifierOccurrences(tokens, helper.name) === 2 && + [ + 'makeEffectBffClient', + 'operationGateway', + expectation.ownerApiValue, + 'Effect', + helper.name, + ].every((name) => identifierOccurrences(tokens, name) === 2) && !tokens.some( ({ value }) => value === 'makeEffectHttpApiClient' || value === 'HttpClientRequest', ) @@ -1968,46 +1932,26 @@ const directPropertyKeyOccurrences = (source: string, key: string): number => { return count; }; -// eslint-disable-next-line complexity -- Delimiter balance is required to reject computed or nested slot identities. const topLevelCommaSeparatedRanges = ( tokens: readonly GovernedClientToken[], ): readonly (readonly [start: number, end: number])[] | undefined => { - const ranges: (readonly [start: number, end: number])[] = []; + const ranges: (readonly [number, number])[] = []; + const depth = new DelimiterDepth(); let start = 0; - let braceDepth = 0; - let bracketDepth = 0; - let parenthesisDepth = 0; for (let index = 0; index < tokens.length; index += 1) { - const kind = tokens[index]?.kind; - if (kind === SyntaxKind.OpenBraceToken) { - braceDepth += 1; - } else if (kind === SyntaxKind.CloseBraceToken) { - braceDepth -= 1; - } else if (kind === SyntaxKind.OpenBracketToken) { - bracketDepth += 1; - } else if (kind === SyntaxKind.CloseBracketToken) { - bracketDepth -= 1; - } else if (kind === SyntaxKind.OpenParenToken) { - parenthesisDepth += 1; - } else if (kind === SyntaxKind.CloseParenToken) { - parenthesisDepth -= 1; - } - if (braceDepth < 0 || bracketDepth < 0 || parenthesisDepth < 0) { + const kind = tokenKind(tokens, index); + depth.update(tokenDelimiter.get(kind ?? SyntaxKind.Unknown)); + if (depth.hasUnmatchedClose()) { return undefined; } - if ( - kind === SyntaxKind.CommaToken && - braceDepth === 0 && - bracketDepth === 0 && - parenthesisDepth === 0 - ) { + if (kind === SyntaxKind.CommaToken && depth.isTopLevel()) { if (start < index) { ranges.push([start, index]); } start = index + 1; } } - if (braceDepth !== 0 || bracketDepth !== 0 || parenthesisDepth !== 0) { + if (!depth.isTopLevel()) { return undefined; } if (start < tokens.length) { @@ -2030,7 +1974,7 @@ const directSlotPropertyNames = (source: string | undefined): readonly string[] const key = tokens[start]; if ( (key?.kind !== SyntaxKind.Identifier && key?.kind !== SyntaxKind.StringLiteral) || - tokens[start + 1]?.kind !== SyntaxKind.ColonToken + tokenKind(tokens, start + 1) !== SyntaxKind.ColonToken ) { return undefined; } @@ -2103,21 +2047,84 @@ export const hasGeneratedProviderRegistration = ( ); }; -// eslint-disable-next-line complexity -- Exact descriptor, contribution, entrypoint, and slot ownership are one contract. +const hasProviderShellEntrypoint = ( + tokens: readonly GovernedClientToken[], + contribution: readonly [number, number], + identity: Readonly>, +): boolean => { + const range = nestedObjectRange(tokens, ...contribution, 'entrypoint'); + return ( + range !== undefined && + directObjectHasNoSpread(tokens, ...range) && + objectHasExactStrings(tokens, ...range, identity) + ); +}; + +const slotOmitsIdentity = ( + source: string | undefined, + identity: readonly ExpectedToken[], +): boolean => + source === undefined || + sequenceOccurrencesAtBraceDepth(tokenizeGovernedClient(source), identity, 1) === 0; + +const hasOwnedProviderDescriptor = ( + tokens: readonly GovernedClientToken[], + identity: readonly ExpectedToken[], + moduleId: string, +): boolean => + sequenceOccurrencesAtBraceDepth(tokens, identity, 1) === 1 && + hasRelatedSequenceInObject(tokens, identity, [ + [SyntaxKind.Identifier, 'owningModuleId'], + [SyntaxKind.ColonToken], + [SyntaxKind.StringLiteral, moduleId], + ]); + +const hasOwnedProviderShellContribution = ( + shellTokens: readonly GovernedClientToken[], + shellIdentity: readonly ExpectedToken[], + shellContribution: readonly [number, number] | undefined, + shellContributionKey: string, + moduleId: string, + kind: 'report' | 'search', + descriptorKey: string, +): boolean => + sequenceOccurrencesAtBraceDepth(shellTokens, shellIdentity, 1) === 1 && + shellContribution !== undefined && + hasProviderShellEntrypoint(shellTokens, shellContribution, { + access: 'read', + entrypointKey: shellContributionKey, + moduleKey: moduleId, + role: kind, + scope: 'tenant', + }) && + hasRelatedSequenceInObject(shellTokens, shellIdentity, [ + [SyntaxKind.Identifier, kind === 'report' ? 'reportKey' : 'searchKey'], + [SyntaxKind.ColonToken], + [SyntaxKind.StringLiteral, descriptorKey], + ]); + export const hasGeneratedProviderManifest = ( manifest: string, moduleId: string, name: string, kind: 'report' | 'search', ): boolean => { - const descriptorSlot = generatedSlotSource( - manifest, - kind === 'report' ? MANIFEST_REPORT_SLOT : MANIFEST_SEARCH_SLOT, - ); - const shellSlot = generatedSlotSource( - manifest, - kind === 'report' ? MANIFEST_SHELL_REPORT_SLOT : MANIFEST_SHELL_SEARCH_SLOT, - ); + const [descriptorMarkers, shellMarkers, otherDescriptorMarkers, otherShellMarkers] = + kind === 'report' + ? [ + MANIFEST_REPORT_SLOT, + MANIFEST_SHELL_REPORT_SLOT, + MANIFEST_SEARCH_SLOT, + MANIFEST_SHELL_SEARCH_SLOT, + ] + : [ + MANIFEST_SEARCH_SLOT, + MANIFEST_SHELL_SEARCH_SLOT, + MANIFEST_REPORT_SLOT, + MANIFEST_SHELL_REPORT_SLOT, + ]; + const descriptorSlot = generatedSlotSource(manifest, descriptorMarkers); + const shellSlot = generatedSlotSource(manifest, shellMarkers); if (descriptorSlot === undefined || shellSlot === undefined) { return false; } @@ -2135,14 +2142,8 @@ export const hasGeneratedProviderManifest = ( [SyntaxKind.ColonToken], [SyntaxKind.StringLiteral, shellContributionKey], ] satisfies readonly ExpectedToken[]; - const otherDescriptorSlot = generatedSlotSource( - manifest, - kind === 'report' ? MANIFEST_SEARCH_SLOT : MANIFEST_REPORT_SLOT, - ); - const otherShellSlot = generatedSlotSource( - manifest, - kind === 'report' ? MANIFEST_SHELL_SEARCH_SLOT : MANIFEST_SHELL_REPORT_SLOT, - ); + const otherDescriptorSlot = generatedSlotSource(manifest, otherDescriptorMarkers); + const otherShellSlot = generatedSlotSource(manifest, otherShellMarkers); const shellIdentityIndex = findSequenceAtBraceDepth( shellTokens, shellIdentity, @@ -2154,55 +2155,19 @@ export const hasGeneratedProviderManifest = ( shellIdentityIndex === undefined ? undefined : enclosingBraceRange(shellTokens, shellIdentityIndex); - const entrypointOpen = - shellContribution === undefined - ? undefined - : findObjectPropertyValue( - shellTokens, - shellContribution[0], - shellContribution[1], - 'entrypoint', - ); - const entrypointClose = - entrypointOpen === undefined || shellTokens[entrypointOpen]?.kind !== SyntaxKind.OpenBraceToken - ? undefined - : findClosingBrace(shellTokens, entrypointOpen); return ( - sequenceOccurrencesAtBraceDepth(descriptorTokens, descriptorIdentity, 1) === 1 && - hasRelatedSequenceInObject(descriptorTokens, descriptorIdentity, [ - [SyntaxKind.Identifier, 'owningModuleId'], - [SyntaxKind.ColonToken], - [SyntaxKind.StringLiteral, moduleId], - ]) && - sequenceOccurrencesAtBraceDepth(shellTokens, shellIdentity, 1) === 1 && - entrypointOpen !== undefined && - entrypointClose !== undefined && - directObjectHasNoSpread(shellTokens, entrypointOpen, entrypointClose) && - objectHasExactString(shellTokens, entrypointOpen, entrypointClose, 'access', 'read') && - objectHasExactString( + hasOwnedProviderDescriptor(descriptorTokens, descriptorIdentity, moduleId) && + hasOwnedProviderShellContribution( shellTokens, - entrypointOpen, - entrypointClose, - 'entrypointKey', + shellIdentity, + shellContribution, shellContributionKey, + moduleId, + kind, + descriptorKey, ) && - objectHasExactString(shellTokens, entrypointOpen, entrypointClose, 'moduleKey', moduleId) && - objectHasExactString(shellTokens, entrypointOpen, entrypointClose, 'role', kind) && - objectHasExactString(shellTokens, entrypointOpen, entrypointClose, 'scope', 'tenant') && - hasRelatedSequenceInObject(shellTokens, shellIdentity, [ - [SyntaxKind.Identifier, kind === 'report' ? 'reportKey' : 'searchKey'], - [SyntaxKind.ColonToken], - [SyntaxKind.StringLiteral, descriptorKey], - ]) && - (otherDescriptorSlot === undefined || - sequenceOccurrencesAtBraceDepth( - tokenizeGovernedClient(otherDescriptorSlot), - descriptorIdentity, - 1, - ) === 0) && - (otherShellSlot === undefined || - sequenceOccurrencesAtBraceDepth(tokenizeGovernedClient(otherShellSlot), shellIdentity, 1) === - 0) + slotOmitsIdentity(otherDescriptorSlot, descriptorIdentity) && + slotOmitsIdentity(otherShellSlot, shellIdentity) ); }; @@ -2234,17 +2199,13 @@ const slotProviderNames = ( [SyntaxKind.StringLiteral], ]) ) { - const value = tokens[index + 2]?.value ?? ''; + const value = tokenValue(tokens, index + 2) ?? ''; const name = value.startsWith(prefix) ? value.slice(prefix.length) : ''; if (/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(name)) { names.push(name); } } - if (tokens[index]?.kind === SyntaxKind.OpenBraceToken) { - braceDepth += 1; - } else if (tokens[index]?.kind === SyntaxKind.CloseBraceToken) { - braceDepth -= 1; - } + braceDepth += tokenBraceDelta(tokenKind(tokens, index)); } return names; }; @@ -2414,7 +2375,7 @@ const hasGatewayBindingMutation = (tokens: readonly GovernedClientToken[]): bool [SyntaxKind.OpenParenToken], [SyntaxKind.Identifier, binding.value], ]) || - (tokens[index + 1]?.kind === SyntaxKind.DotToken && + (tokenKind(tokens, index + 1) === SyntaxKind.DotToken && findSequence( tokens, [[SyntaxKind.EqualsToken]], @@ -2468,8 +2429,9 @@ export const hasGeneratedOperationGatewayContract = ( factory !== undefined && factoryEnd !== undefined && hasExactGeneratedGatewayFactory(tokens, factory, factoryEnd) && - hasExclusiveNamedImportFrom(source, 'issueGatewayContext', '@app/shared-contracts') && - hasExclusiveNamedImportFrom(source, 'makeSharedOperationGateway', '@app/shared-contracts') && + ['issueGatewayContext', 'makeSharedOperationGateway'].every((name) => + hasExclusiveNamedImportFrom(source, name, '@app/shared-contracts'), + ) && findSequence( tokens, [ diff --git a/app/scripts/migrate-strict-effect.mts b/app/scripts/migrate-strict-effect.mts index b46329f17..a377d5f89 100644 --- a/app/scripts/migrate-strict-effect.mts +++ b/app/scripts/migrate-strict-effect.mts @@ -1,21 +1,14 @@ #!/usr/bin/env node import { NodeServices } from '@effect/platform-node'; -import { Effect, Schema } from 'effect'; +import { Effect } from 'effect'; import { runUltramodernScript, ultramodernExitCode } from './shared/ultramodern-command.mts'; - -class StrictEffectMigrationError extends Schema.TaggedError()( - 'StrictEffectMigrationError', - { reason: Schema.String }, -) {} - -const failure = (reason: string): StrictEffectMigrationError => - new StrictEffectMigrationError({ reason }); +import { ultramodernCommandFailure } from './ultramodern-command-failure.mts'; const exit = await Effect.runPromiseExit( runUltramodernScript({ command: 'migrate-strict-effect', directoryFailure: 'Unable to resolve the strict-Effect migration directory', - failure, + failure: ultramodernCommandFailure, moduleUrl: import.meta.url, }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); diff --git a/app/scripts/proof-cloudflare-version.mts b/app/scripts/proof-cloudflare-version.mts index 2865c803b..705190563 100644 --- a/app/scripts/proof-cloudflare-version.mts +++ b/app/scripts/proof-cloudflare-version.mts @@ -1,21 +1,14 @@ #!/usr/bin/env node import { NodeServices } from '@effect/platform-node'; -import { Effect, Schema } from 'effect'; +import { Effect } from 'effect'; import { runUltramodernScript, ultramodernExitCode } from './shared/ultramodern-command.mts'; - -class CloudflareProofLaunchError extends Schema.TaggedError()( - 'CloudflareProofLaunchError', - { reason: Schema.String }, -) {} - -const failure = (reason: string): CloudflareProofLaunchError => - new CloudflareProofLaunchError({ reason }); +import { ultramodernCommandFailure } from './ultramodern-command-failure.mts'; const exit = await Effect.runPromiseExit( runUltramodernScript({ command: 'cloudflare-proof', directoryFailure: 'Unable to resolve the Cloudflare proof directory', - failure, + failure: ultramodernCommandFailure, moduleUrl: import.meta.url, nodeExecutable: process.execPath, }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), diff --git a/app/scripts/proof-workerd-ssr.mts b/app/scripts/proof-workerd-ssr.mts index c2d247088..930fad823 100644 --- a/app/scripts/proof-workerd-ssr.mts +++ b/app/scripts/proof-workerd-ssr.mts @@ -1287,6 +1287,27 @@ const proveBoundary = ( ); }); +const routeHtml = ( + response: MiniflareResponse, + appId: string, + route: string, + outboundRequests: readonly OutboundRequest[], + outboundStart: number, +) => + Effect.gen(function* routeHtmlEffect() { + const html = yield* Effect.tryPromise({ + catch: (cause) => proofError(`${appId} route ${route} body failed`, cause), + try: async () => await response.text(), + }); + const routeOutboundRequests = outboundRequests.slice(outboundStart); + const outboundEvidence = yield* encodeJson(routeOutboundRequests); + yield* ensure( + response.status === 200, + `${appId} returned HTTP ${response.status} for ${route} in workerd; outbound requests: ${outboundEvidence}; response: ${html.slice(0, 500)} ... ${html.slice(-1000)}`, + ); + return { html, routeOutboundRequests }; + }); + const proveShellRoute = ( apps: readonly App[], miniflare: Miniflare, @@ -1306,15 +1327,12 @@ const proveShellRoute = ( headers: { accept: 'text/html' }, }), }); - const html = yield* Effect.tryPromise({ - catch: (cause) => proofError(`${shell.id} route ${route} body failed`, cause), - try: async () => await response.text(), - }); - const routeOutboundRequests = state.outboundRequests.slice(outboundStart); - const outboundEvidence = yield* encodeJson(routeOutboundRequests); - yield* ensure( - response.status === 200, - `${shell.id} returned HTTP ${response.status} for ${route} in workerd; outbound requests: ${outboundEvidence}; response: ${html.slice(0, 500)} ... ${html.slice(-1000)}`, + const { html, routeOutboundRequests } = yield* routeHtml( + response, + shell.id, + route, + state.outboundRequests, + outboundStart, ); yield* ensure( !html.includes(DEGRADED_BOUNDARY_MARKER), @@ -1363,7 +1381,6 @@ const proveShellRoute = ( const proveRemote = ( miniflare: Miniflare, remote: App, - shell: App, state: ShellProofState, ): Effect.Effect => Effect.gen(function* proveRemoteEffect() { @@ -1382,15 +1399,12 @@ const proveRemote = ( headers: { accept: 'text/html' }, }), }); - const html = yield* Effect.tryPromise({ - catch: (cause) => proofError(`${remote.id} route ${route} body failed`, cause), - try: async () => await response.text(), - }); - const routeOutboundRequests = state.outboundRequests.slice(outboundStart); - const outboundEvidence = yield* encodeJson(routeOutboundRequests); - yield* ensure( - response.status === 200, - `${remote.id} returned HTTP ${response.status} for ${route} in workerd; outbound requests: ${outboundEvidence}; response: ${html.slice(0, 500)} ... ${html.slice(-1000)}`, + const { html, routeOutboundRequests } = yield* routeHtml( + response, + remote.id, + route, + state.outboundRequests, + outboundStart, ); yield* ensure( response.headers.get(CONTENT_TYPE_HEADER)?.includes('text/html') === true, @@ -1413,10 +1427,6 @@ const proveRemote = ( }); state.renderedRemoteIds.add(remote.id); } - yield* ensure( - state.renderedRemoteIds.has(remote.id), - `${shell.id} proof routes are missing independently rendered ${remote.id} content`, - ); }); const runShellProof = ( @@ -1480,7 +1490,7 @@ const runShellProof = ( (route) => proveShellRoute(apps, miniflare, route, shell, shellWorkerName, state), { concurrency: 1 }, ); - yield* Effect.forEach(remotes, (remote) => proveRemote(miniflare, remote, shell, state), { + yield* Effect.forEach(remotes, (remote) => proveRemote(miniflare, remote, state), { concurrency: 1, }); const apiProofs = yield* runApiProofs(apps, miniflare, shell, executionByAppId); diff --git a/app/scripts/quality-audit-gate.mts b/app/scripts/quality-audit-gate.mts new file mode 100644 index 000000000..c62cb136a --- /dev/null +++ b/app/scripts/quality-audit-gate.mts @@ -0,0 +1,167 @@ +#!/usr/bin/env node +import { NodeRuntime, NodeServices } from '@effect/platform-node'; +import { Console, Data, Effect, FileSystem, Layer, Match, Path, Schema } from 'effect'; +import { Command, Flag } from 'effect/unstable/cli'; + +const FALLOW_FILES = 'fallow-files'; +const FALLOW_HEALTH = 'fallow-health'; +const Count = Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)); +const PositiveCount = Count.check(Schema.isGreaterThan(0)); +const Counts = Schema.Record(Schema.String, Count); +const base = { + diagnostic: Schema.Literal(''), + files: PositiveCount, + findings: Count, + status: Schema.Literal('reported'), +}; +const primary = { ...base, advisory: Schema.Literal(false) }; +const tokenCoverage = Schema.Struct({ tokenEligibleFiles: PositiveCount }); +const ResultSchema = Schema.Union([ + Schema.Struct({ + ...primary, + coverage: Schema.Struct({ + findingCounts: Counts, + modeledUsages: Count, + nativeFindingCounts: Counts, + processed: PositiveCount, + total: PositiveCount, + workspaces: Schema.Array(Schema.NonEmptyString).check(Schema.isMinLength(1)), + }), + name: Schema.Literal('knip'), + }), + Schema.Struct({ ...primary, coverage: tokenCoverage, name: Schema.Literal('jscpd') }), + Schema.Struct({ + ...primary, + coverage: Schema.Struct({ discoveredFiles: PositiveCount }), + name: Schema.Literal(FALLOW_FILES), + }), + Schema.Struct({ ...primary, coverage: tokenCoverage, name: Schema.Literal('fallow-clones') }), + Schema.Struct({ + ...base, + advisory: Schema.Literal(true), + coverage: tokenCoverage, + name: Schema.Literal('fallow-similarity'), + }), + Schema.Struct({ + ...primary, + coverage: Schema.Struct({ + analyzedFiles: PositiveCount, + analyzedFunctions: PositiveCount, + controlFlowFindings: Count, + uiOnlyFindings: Count, + weightedFindings: Count, + }), + name: Schema.Literal(FALLOW_HEALTH), + }), +]); +const Summary = Schema.fromJsonString( + Schema.Struct({ results: Schema.Array(ResultSchema), status: Schema.Literal('reported') }), +); + +class QualityAuditGateError extends Data.TaggedError('QualityAuditGateError')<{ + message: string; +}> {} +const reject = (message: string) => Effect.fail(new QualityAuditGateError({ message })); +const sum = (counts: Readonly>) => + Object.values(counts).reduce((total, count) => total + count, 0); + +const consistent = (entry: typeof ResultSchema.Type) => + Match.value(entry).pipe( + Match.when({ name: 'knip' }, (result) => { + const { files, findings } = result; + const knip = result.coverage; + const keys = Object.keys(knip.findingCounts); + return ( + keys.length > 0 && + keys.length === Object.keys(knip.nativeFindingCounts).length && + keys.every( + (key) => + knip.nativeFindingCounts[key] === + (knip.findingCounts[key] ?? 0) + (key === 'unlisted' ? knip.modeledUsages : 0), + ) && + sum(knip.findingCounts) === findings && + sum(knip.nativeFindingCounts) === findings + knip.modeledUsages && + knip.processed === files && + knip.total === files + ); + }), + Match.when({ name: FALLOW_FILES }, (result) => { + const { files, findings } = result; + return result.coverage.discoveredFiles === files && findings === 0; + }), + Match.when({ name: FALLOW_HEALTH }, (result) => { + const { files, findings } = result; + const health = result.coverage; + return ( + health.analyzedFiles === files && + health.controlFlowFindings === findings && + health.weightedFindings === findings + health.uiOnlyFindings && + health.weightedFindings <= health.analyzedFunctions + ); + }), + Match.orElse((result) => result.coverage.tokenEligibleFiles === result.files), + ); + +export const validateQualityAuditSummary = Effect.fn('qualityAuditGate.validate')( + function* validateQualityAuditSummaryEffect(source: string) { + const summary = yield* Schema.decodeEffect(Summary)(source).pipe( + Effect.mapError( + (cause) => + new QualityAuditGateError({ message: `Malformed audit summary: ${String(cause)}` }), + ), + ); + // The schema admits exactly six names; cardinality plus uniqueness requires all of them. + if ( + summary.results.length !== 6 || + new Set(summary.results.map(({ name }) => name)).size !== 6 + ) { + return yield* reject( + 'Audit gate requires all six unique analyzer results; run the full audit', + ); + } + for (const result of summary.results) { + if (!consistent(result)) { + return yield* reject(`${result.name}: inconsistent audit counts or incomplete analysis`); + } + } + const discovery = summary.results.find(({ name }) => name === FALLOW_FILES); + const health = summary.results.find(({ name }) => name === FALLOW_HEALTH); + if (discovery?.files !== health?.files) { + return yield* reject('Fallow discovery and health coverage disagree'); + } + const findings = summary.results.filter((result) => !result.advisory && result.findings > 0); + const details = findings.map(({ findings: count, name }) => `${name}=${count}`).join(', '); + if (findings.length > 0) { + return yield* reject(`Quality audit gate failed: ${details}`); + } + return yield* Effect.void; + }, +); + +const cli = Command.make( + 'quality-audit-gate', + { + summary: Flag.string('summary').pipe( + Flag.withDefault('.codex/reports/quality-audit/summary.json'), + ), + }, + ({ summary }) => + Effect.gen(function* qualityAuditGateCommand() { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* path.fromFileUrl(new URL('..', import.meta.url)); + yield* validateQualityAuditSummary(yield* fs.readFileString(path.resolve(root, summary))); + yield* Console.log('Quality audit gate passed (semantic similarity remains advisory)'); + }), +); + +if (Schema.is(Schema.Struct({ main: Schema.Literal(true) }))(import.meta)) { + const mainLayer = Layer.effectDiscard( + Command.run(cli, { version: '1.0.0' }).pipe( + Effect.tapError((issue) => Console.error(String(issue))), + ), + ).pipe(Layer.provide(NodeServices.layer)); + NodeRuntime.runMain(Effect.scoped(Layer.build(mainLayer)).pipe(Effect.asVoid), { + disableErrorReporting: true, + }); +} diff --git a/app/scripts/scaffolding/cli.mts b/app/scripts/scaffolding/cli.mts index 42c405ede..eb530051e 100644 --- a/app/scripts/scaffolding/cli.mts +++ b/app/scripts/scaffolding/cli.mts @@ -481,7 +481,7 @@ Options: catch: (cause) => new ScaffoldingError({ cause, - message: cause instanceof Error ? cause.message : 'route refresh failed', + message: Predicate.isError(cause) ? cause.message : 'route refresh failed', }), try: async () => await options.routeRefresh?.(input), }); @@ -958,17 +958,11 @@ const runScaffoldEffect = Effect.fn('runScaffold')(function* runScaffoldEffectGe return { kind: 'generated', result }; }); -const makeScaffoldProgram = ( - command: ScaffoldCommand, - rawArguments: readonly string[], - options: RunScaffoldOptions = {}, -) => runScaffoldEffect(command, rawArguments, options); - export const runScaffold: ( command: ScaffoldCommand, rawArguments: readonly string[], options?: RunScaffoldOptions, -) => Promise = flow(makeScaffoldProgram, scaffoldingRuntime.runPromise); +) => Promise = flow(runScaffoldEffect, scaffoldingRuntime.runPromise); const optionalTextFlag = (name: string) => Flag.string(name).pipe(Flag.optional); const forwardedArguments = Argument.variadic(Argument.string('forwarded flags')); @@ -998,215 +992,45 @@ const cliFlags = { worker: optionalTextFlag('worker'), } as const; +const cliFlagName = (key: string): string => + key.replaceAll(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`); + const toCliArguments = ( values: Readonly>>>, -): readonly string[] => { - const entries: readonly (readonly [string, Option.Option | undefined])[] = [ - [ACCESS_FILTERING_FLAG, values.accessFiltering], - ['action', values.action], - ['authorization', values.authorization], - ['kind', values.kind], - [LEGAL_ENTITY_SCOPE_FLAG, values.legalEntityScope], - ['module', values.module], - ['name', values.name], - ['operation', values.operation], - ['page', values.page], - ['permission', values.permission], - ['policy', values.policy], - ['producer', values.producer], - ['provider', values.provider], - ['provisioning', values.provisioning], - [REQUEST_FILTERS_FLAG, values.requestFilters], - ['resource', values.resource], - ['scope', values.scope], - ['service', values.service], - [TENANT_PERMISSION_FLAG, values.tenantPermission], - ['topic', values.topic], - ['url', values.url], - ['vertical', values.vertical], - ['worker', values.worker], - ]; - return entries.flatMap(([name, value]) => - value !== undefined && Option.isSome(value) ? [`--${name}`, value.value] : [], +): readonly string[] => + Object.entries(values).flatMap(([key, value]) => + value !== undefined && Option.isSome(value) ? [`--${cliFlagName(key)}`, value.value] : [], ); -}; const executeCliCommand = (command: ScaffoldCommand) => - ( - values: Readonly>>> & { - readonly forwarded: readonly string[]; - }, - ) => + ({ + forwarded, + ...values + }: Readonly>>> & { + readonly forwarded: readonly string[]; + }) => Effect.gen(function* executeCliCommandEffect() { - const result = yield* runScaffoldEffect(command, [ - ...toCliArguments(values), - ...values.forwarded, - ]); + const result = yield* runScaffoldEffect(command, [...toCliArguments(values), ...forwarded]); if (result.kind === 'help') { yield* Console.log(result.help); } }); -const cliSubcommands = [ +const cliSubcommands = scaffoldCommandValues.map((command) => Command.make( - scaffoldCommandValues[0], + command, { - action: cliFlags.action, - authorization: cliFlags.authorization, - forwarded: forwardedArguments, - legalEntityScope: cliFlags.legalEntityScope, - module: cliFlags.module, - provisioning: cliFlags.provisioning, - scope: cliFlags.scope, - vertical: cliFlags.vertical, - }, - executeCliCommand(scaffoldCommandValues[0]), - ), - Command.make( - scaffoldCommandValues[1], - { forwarded: forwardedArguments, service: cliFlags.service, vertical: cliFlags.vertical }, - executeCliCommand(scaffoldCommandValues[1]), - ), - Command.make( - scaffoldCommandValues[2], - { - forwarded: forwardedArguments, - operation: cliFlags.operation, - provider: cliFlags.provider, - vertical: cliFlags.vertical, - }, - executeCliCommand(scaffoldCommandValues[2]), - ), - Command.make( - scaffoldCommandValues[3], - { forwarded: forwardedArguments, vertical: cliFlags.vertical }, - executeCliCommand(scaffoldCommandValues[3]), - ), - Command.make( - scaffoldCommandValues[4], - { - authorization: cliFlags.authorization, - forwarded: forwardedArguments, - page: cliFlags.page, - permission: cliFlags.permission, - url: cliFlags.url, - vertical: cliFlags.vertical, - }, - executeCliCommand(scaffoldCommandValues[4]), - ), - Command.make( - scaffoldCommandValues[5], - { forwarded: forwardedArguments, module: cliFlags.module, vertical: cliFlags.vertical }, - executeCliCommand(scaffoldCommandValues[5]), - ), - Command.make( - scaffoldCommandValues[6], - { - authorization: cliFlags.authorization, - forwarded: forwardedArguments, - name: cliFlags.name, - permission: cliFlags.permission, - vertical: cliFlags.vertical, - }, - executeCliCommand(scaffoldCommandValues[6]), - ), - Command.make( - scaffoldCommandValues[7], - { - action: cliFlags.action, - forwarded: forwardedArguments, - topic: cliFlags.topic, - vertical: cliFlags.vertical, - }, - executeCliCommand(scaffoldCommandValues[7]), - ), - Command.make( - scaffoldCommandValues[8], - { - authorization: cliFlags.authorization, - forwarded: forwardedArguments, - producer: cliFlags.producer, - topic: cliFlags.topic, - vertical: cliFlags.vertical, - worker: cliFlags.worker, - }, - executeCliCommand(scaffoldCommandValues[8]), - ), - Command.make( - scaffoldCommandValues[9], - { - forwarded: forwardedArguments, - policy: cliFlags.policy, - scope: cliFlags.scope, - vertical: cliFlags.vertical, - }, - executeCliCommand(scaffoldCommandValues[9]), - ), - Command.make( - scaffoldCommandValues[10], - { - authorization: cliFlags.authorization, - forwarded: forwardedArguments, - name: cliFlags.name, - permission: cliFlags.permission, - vertical: cliFlags.vertical, - }, - executeCliCommand(scaffoldCommandValues[10]), - ), - Command.make( - scaffoldCommandValues[11], - { - authorization: cliFlags.authorization, - forwarded: forwardedArguments, - name: cliFlags.name, - permission: cliFlags.permission, - resource: cliFlags.resource, - vertical: cliFlags.vertical, - }, - executeCliCommand(scaffoldCommandValues[11]), - ), - Command.make( - scaffoldCommandValues[12], - { forwarded: forwardedArguments, resource: cliFlags.resource, vertical: cliFlags.vertical }, - executeCliCommand(scaffoldCommandValues[12]), - ), - Command.make( - scaffoldCommandValues[13], - { - forwarded: forwardedArguments, - kind: cliFlags.kind, - name: cliFlags.name, - vertical: cliFlags.vertical, - }, - executeCliCommand(scaffoldCommandValues[13]), - ), - Command.make( - scaffoldCommandValues[14], - { - accessFiltering: cliFlags.accessFiltering, - forwarded: forwardedArguments, - legalEntityScope: cliFlags.legalEntityScope, - name: cliFlags.name, - requestFilters: cliFlags.requestFilters, - tenantPermission: cliFlags.tenantPermission, - vertical: cliFlags.vertical, - }, - executeCliCommand(scaffoldCommandValues[14]), - ), - Command.make( - scaffoldCommandValues[15], - { - authorization: cliFlags.authorization, + ...Object.fromEntries( + Object.entries(cliFlags).filter(([key]) => + commandDefinitions[command].flags.includes(cliFlagName(key)), + ), + ), forwarded: forwardedArguments, - name: cliFlags.name, - permission: cliFlags.permission, - resource: cliFlags.resource, - vertical: cliFlags.vertical, }, - executeCliCommand(scaffoldCommandValues[15]), + executeCliCommand(command), ), -] as const; +); const cliRoot = Command.make('scaffold').pipe(Command.withSubcommands(cliSubcommands)); diff --git a/app/scripts/scaffolding/governed-contribution/scaffold.mts b/app/scripts/scaffolding/governed-contribution/scaffold.mts index 7fa53c144..5dc1b886a 100644 --- a/app/scripts/scaffolding/governed-contribution/scaffold.mts +++ b/app/scripts/scaffolding/governed-contribution/scaffold.mts @@ -95,48 +95,76 @@ const ProviderContributionKindSchema = Schema.Literals([REPORT_KIND, SEARCH_PROV type ProviderContributionKind = typeof ProviderContributionKindSchema.Type; const isProviderContribution = Schema.is(ProviderContributionKindSchema); -const directTokenStringProperty = ( - tokens: ReturnType, +type GovernedToken = ReturnType[number]; + +const propertyValueKind = ( + tokens: readonly GovernedToken[], property: string, -): string | undefined => { - const identities: string[] = []; +): SyntaxKind | undefined => { + const [key, colon, value] = tokens; + return key?.kind === SyntaxKind.Identifier && + key.value === property && + colon?.kind === SyntaxKind.ColonToken + ? value?.kind + : undefined; +}; + +const directPropertyIndexes = ( + tokens: readonly GovernedToken[], + property: string, + kind: SyntaxKind, +): readonly number[] => { + const indexes: number[] = []; let braceDepth = 0; - for (let index = 0; index < tokens.length - 2; index += 1) { - if ( - braceDepth === 1 && - tokens[index]?.kind === SyntaxKind.Identifier && - tokens[index]?.value === property && - tokens[index + 1]?.kind === SyntaxKind.ColonToken && - tokens[index + 2]?.kind === SyntaxKind.StringLiteral - ) { - const identity = tokens[index + 2]?.value; - if (identity !== undefined) { - identities.push(identity); - } + for (const [index, token] of tokens.slice(0, -2).entries()) { + if (braceDepth === 1 && propertyValueKind(tokens.slice(index, index + 3), property) === kind) { + indexes.push(index); } - if (tokens[index]?.kind === SyntaxKind.OpenBraceToken) { + if (token.kind === SyntaxKind.OpenBraceToken) { braceDepth += 1; - } else if (tokens[index]?.kind === SyntaxKind.CloseBraceToken) { + } else if (token.kind === SyntaxKind.CloseBraceToken) { braceDepth -= 1; } } - return identities.length === 1 ? identities[0] : undefined; + return indexes; +}; + +const directTokenStringProperty = ( + tokens: readonly GovernedToken[], + property: string, +): string | undefined => { + const indexes = directPropertyIndexes(tokens, property, SyntaxKind.StringLiteral); + const [index] = indexes; + return indexes.length === 1 && index !== undefined ? tokens[index + 2]?.value : undefined; +}; + +const identityTokenKinds = new Set([SyntaxKind.Identifier, SyntaxKind.StringLiteral]); + +const compositionIdentity = (source: string): string | undefined => { + for (const pattern of [ + /^import \{ (?[^}]+) \}/u, + /^\.addHttpApi\((?[^)]+)\)/u, + /^(?[A-Za-z][A-Za-z0-9]*ReadApiLive)\.pipe\(/u, + ]) { + const value = pattern.exec(source)?.groups?.['value']; + if (value !== undefined) { + return value; + } + } + return undefined; }; const slotEntryIdentity = (source: string): string | undefined => { - const compositionIdentity = - /^import \{ (?[^}]+) \}/u.exec(source)?.groups?.['value'] ?? - /^\.addHttpApi\((?[^)]+)\)/u.exec(source)?.groups?.['value'] ?? - /^(?[A-Za-z][A-Za-z0-9]*ReadApiLive)\.pipe\(/u.exec(source)?.groups?.['value']; - if (compositionIdentity !== undefined) { - return compositionIdentity; + const composed = compositionIdentity(source); + if (composed !== undefined) { + return composed; } const tokens = tokenizeGovernedClient(source); const [registrationProperty, registrationColon] = tokens; if ( registrationColon?.kind === SyntaxKind.ColonToken && - (registrationProperty?.kind === SyntaxKind.StringLiteral || - registrationProperty?.kind === SyntaxKind.Identifier) + registrationProperty !== undefined && + identityTokenKinds.has(registrationProperty.kind) ) { return registrationProperty.value; } @@ -991,24 +1019,8 @@ const directStringArrayProperty = ( property: string, ): readonly string[] | undefined => { const tokens = tokenizeGovernedClient(source); - let braceDepth = 0; - for (let index = 0; index < tokens.length - 3; index += 1) { - if ( - braceDepth === 1 && - tokens[index]?.kind === SyntaxKind.Identifier && - tokens[index]?.value === property && - tokens[index + 1]?.kind === SyntaxKind.ColonToken && - tokens[index + 2]?.kind === SyntaxKind.OpenBracketToken - ) { - return readStringArray(tokens, index + 3); - } - if (tokens[index]?.kind === SyntaxKind.OpenBraceToken) { - braceDepth += 1; - } else if (tokens[index]?.kind === SyntaxKind.CloseBraceToken) { - braceDepth -= 1; - } - } - return undefined; + const [index] = directPropertyIndexes(tokens, property, SyntaxKind.OpenBracketToken); + return index === undefined ? undefined : readStringArray(tokens, index + 3); }; // Owners may adapt accessFiltering/tenantPermission and report label/dimensions. These describe @@ -1056,17 +1068,16 @@ const structurallyMatchesGeneratedEntry = (current: string, expected: string): b } return expectedTokens.every((expectedToken, index) => { const currentToken = currentTokens[index]; + if (currentToken === undefined) { + return false; + } + const carriesIdentity = identityTokenKinds.has(expectedToken.kind); const isPropertyKey = - index === 0 && - (expectedToken.kind === SyntaxKind.Identifier || - expectedToken.kind === SyntaxKind.StringLiteral) && - (currentToken?.kind === SyntaxKind.Identifier || - currentToken?.kind === SyntaxKind.StringLiteral); - const sameKind = isPropertyKey || currentToken?.kind === expectedToken.kind; - const carriesIdentity = - expectedToken.kind === SyntaxKind.Identifier || - expectedToken.kind === SyntaxKind.StringLiteral; - return sameKind && (!carriesIdentity || currentToken?.value === expectedToken.value); + index === 0 && carriesIdentity && identityTokenKinds.has(currentToken.kind); + return ( + (isPropertyKey || currentToken.kind === expectedToken.kind) && + (!carriesIdentity || currentToken.value === expectedToken.value) + ); }); }; @@ -1098,10 +1109,9 @@ const patchSlots = ( `generated owner slot contains unsupported developer content: ${start}`, ); } - const identityMatches = - identity === undefined - ? [] - : allOwnerEntries.filter(({ entry }) => slotEntryIdentity(entry) === identity); + const identityMatches = allOwnerEntries.filter( + ({ entry }) => identity !== undefined && slotEntryIdentity(entry) === identity, + ); if (identityMatches.some((match) => match.start !== start)) { return raiseScaffoldFailure( `generated owner slot contains mismatched identity in the wrong contribution category: ${identity}`, @@ -1116,7 +1126,6 @@ const patchSlots = ( const [identityMatch] = identityMatches; if ( identityMatch !== undefined && - identityMatch.start === start && (structurallyMatchesGeneratedEntry(identityMatch.entry, line) || acceptsAdaptedProviderDescriptor(start, identityMatch.entry, line)) ) { @@ -1387,10 +1396,7 @@ export const planGovernedContributionScaffold = Effect.fn('GovernedContributionS if (isApi) { return renderApiContract(name); } - if (isProviderContribution(kind)) { - return renderProvider(kind, vertical, name, config); - } - return raiseScaffoldFailure('unsupported governed contribution', kind); + return renderProvider(kind, vertical, name, config); }); const artifactMutation = isComponent ? Option.some(yield* createMutationEffect(artifactPath, artifact)) @@ -1463,12 +1469,9 @@ export const planGovernedContributionScaffold = Effect.fn('GovernedContributionS const registrationMutation = yield* tryScaffold('failed to update module registration', () => updateMutation(vertical.registrationPath, vertical.registrationContent, registration), ); - if (manifestMutation !== undefined) { - mutations.push(manifestMutation); - } - if (registrationMutation !== undefined) { - mutations.push(registrationMutation); - } + mutations.push( + ...[manifestMutation, registrationMutation].filter((mutation) => mutation !== undefined), + ); if (isComponent) { mutations.push(yield* patchFederationExposure(vertical, name)); } diff --git a/app/scripts/scaffolding/microvertical-action-boundary/scaffold.mts b/app/scripts/scaffolding/microvertical-action-boundary/scaffold.mts index b5725f8cb..c9fbefd2c 100644 --- a/app/scripts/scaffolding/microvertical-action-boundary/scaffold.mts +++ b/app/scripts/scaffolding/microvertical-action-boundary/scaffold.mts @@ -166,7 +166,7 @@ export const GatewayAssertionRedemptionLive = Layer.succeed( ); `; -export const renderActionHttpRunner = ( +const renderActionHttpRunner = ( vertical: Pick, ): string => `${ACTION_BOUNDARY_GENERATOR_HEADER} // @ontos-action-boundary-owner ${vertical.appId} diff --git a/app/scripts/scaffolding/module-contract/scaffold.mts b/app/scripts/scaffolding/module-contract/scaffold.mts index 30a4cc071..650133d17 100644 --- a/app/scripts/scaffolding/module-contract/scaffold.mts +++ b/app/scripts/scaffolding/module-contract/scaffold.mts @@ -1,3 +1,4 @@ +import { topLevelSeparators } from '../../boundary-source-structure.mts'; import { Array as EffectArray, Effect, FileSystem, Option, Predicate, Schema } from 'effect'; import { createCodesmithGenerator } from '../generator-adapter.mts'; import { @@ -105,30 +106,8 @@ export const governedHttpApi = HttpApi.make('${toCamelCase(vertical.slug)}Govern .pipe(identity); `; -const topLevelStatementEnd = (structure: string, start: number): number => { - let roundDepth = 0; - let squareDepth = 0; - let curlyDepth = 0; - for (let index = start; index < structure.length; index += 1) { - const character = structure[index]; - if (character === '(') { - roundDepth += 1; - } else if (character === ')') { - roundDepth -= 1; - } else if (character === '[') { - squareDepth += 1; - } else if (character === ']') { - squareDepth -= 1; - } else if (character === '{') { - curlyDepth += 1; - } else if (character === '}') { - curlyDepth -= 1; - } else if (character === ';' && roundDepth === 0 && squareDepth === 0 && curlyDepth === 0) { - return index; - } - } - return -1; -}; +const topLevelStatementEnd = (structure: string, start: number): number => + topLevelSeparators(structure, ';', start)[0] ?? -1; const initializeGovernedHttpApiRoot = (source: string, vertical: VerticalMetadata): string => { if ( diff --git a/app/scripts/scaffolding/search-provider-access/scaffold.mts b/app/scripts/scaffolding/search-provider-access/scaffold.mts index ff21acb84..e52d6de6c 100644 --- a/app/scripts/scaffolding/search-provider-access/scaffold.mts +++ b/app/scripts/scaffolding/search-provider-access/scaffold.mts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Schema } from 'effect'; +import { Effect, FileSystem, Predicate, Schema } from 'effect'; import { discoverOntosModuleEffect, ensureUniqueMutationPaths, @@ -35,10 +35,10 @@ const scaffoldError = (message: string, cause?: unknown): SearchProviderAccessSc const trySync = (operation: () => Value) => Effect.try({ catch: (cause) => - cause instanceof SearchProviderAccessScaffoldError + Schema.is(SearchProviderAccessScaffoldError)(cause) ? cause : scaffoldError( - cause instanceof Error ? cause.message : 'search provider access update failed', + Predicate.isError(cause) ? cause.message : 'search provider access update failed', cause, ), try: operation, diff --git a/app/scripts/scaffolding/shared.mts b/app/scripts/scaffolding/shared.mts index 031dfd8c7..bed4dd0c3 100644 --- a/app/scripts/scaffolding/shared.mts +++ b/app/scripts/scaffolding/shared.mts @@ -1676,27 +1676,18 @@ export const readGeneratedSlotEntries = ( return entries.success; }; -export const removeGeneratedSlotEntry = ( +const renderGeneratedSlotEntries = ( content: string, startMarker: string, endMarker: string, - matches: (candidate: string) => boolean, - label: string, + entries: readonly string[], ): string => { - const entries = readGeneratedSlotEntries(content, startMarker, endMarker); - const matching = entries.filter(matches); - if (matching.length !== 1) { - return raiseScaffoldFailure( - `expected exactly one generated ${label}; found ${matching.length}`, - ); - } - const remaining = entries.filter((entry) => !matches(entry)); const start = content.indexOf(startMarker); const end = content.indexOf(endMarker); const bodyStart = start + startMarker.length; const endLineStart = Math.max(content.lastIndexOf('\n', end - 1) + 1, 0); const indentation = /^[ \t]*/u.exec(content.slice(endLineStart, end))?.[0] ?? ''; - const rendered = remaining + const rendered = entries .map((entry) => entry .split('\n') @@ -1707,6 +1698,24 @@ export const removeGeneratedSlotEntry = ( return `${content.slice(0, bodyStart)}\n${rendered}\n${content.slice(end)}`; }; +export const removeGeneratedSlotEntry = ( + content: string, + startMarker: string, + endMarker: string, + matches: (candidate: string) => boolean, + label: string, +): string => { + const entries = readGeneratedSlotEntries(content, startMarker, endMarker); + const matching = entries.filter(matches); + if (matching.length !== 1) { + return raiseScaffoldFailure( + `expected exactly one generated ${label}; found ${matching.length}`, + ); + } + const remaining = entries.filter((entry) => !matches(entry)); + return renderGeneratedSlotEntries(content, startMarker, endMarker, remaining); +}; + export const generatedSlotContainsExactEntry = ( content: string, startMarker: string, @@ -1728,9 +1737,6 @@ export const insertSortedSlot = ( additions: readonly string[], validateEntry: (candidate: string) => boolean, ): string => { - const start = content.indexOf(startMarker); - const end = content.indexOf(endMarker); - const bodyStart = start + startMarker.length; const existing = readGeneratedSlotEntries(content, startMarker, endMarker); if (existing.some((line) => !validateEntry(line))) { return raiseScaffoldFailure( @@ -1746,15 +1752,5 @@ export const insertSortedSlot = ( const entries = [...existing, ...additions].toSorted((left, right) => generatedSlotSortKey(left).localeCompare(generatedSlotSortKey(right)), ); - const endLineStart = Math.max(content.lastIndexOf('\n', end - 1) + 1, 0); - const indentation = /^[ \t]*/u.exec(content.slice(endLineStart, end))?.[0] ?? ''; - const renderedEntries = entries - .map((entry) => - entry - .split('\n') - .map((line) => `${indentation}${line}`) - .join('\n'), - ) - .join('\n'); - return `${content.slice(0, bodyStart)}\n${renderedEntries}\n${content.slice(end)}`; + return renderGeneratedSlotEntries(content, startMarker, endMarker, entries); }; diff --git a/app/scripts/scaffolding/tests/resource-generator.test.mts b/app/scripts/scaffolding/tests/resource-generator.test.mts index 483c053b3..a4a340716 100644 --- a/app/scripts/scaffolding/tests/resource-generator.test.mts +++ b/app/scripts/scaffolding/tests/resource-generator.test.mts @@ -174,6 +174,20 @@ const scaffoldResource = async (root: string, resource = resourceName) => workspaceRoot: root, }); +/** + * A refused resource scaffold must leave the fixture tree byte-identical, so each guard proves + * its own rejection message against a snapshot taken immediately before the run. + */ +const assertResourceScaffoldRefused = async ( + root: string, + expected: RegExp, + ignored: readonly string[] = ['node_modules'], +): Promise => { + const before = await snapshotTree(root, ignored); + await assert.rejects(scaffoldResource(root), expected); + assert.deepEqual(await snapshotTree(root, ignored), before); +}; + await test('resource help documents the public command and writes nothing', async () => { const missingRoot = path.join(tmpdir(), 'resource-help-does-not-exist'); const result = await runScaffold('resource', ['--help'], { @@ -291,9 +305,7 @@ await test('resource scaffold rejects traversal and reruns without partial write assert.deepEqual(await snapshotTree(root, ['node_modules']), beforeTraversal); await scaffoldResource(root); - const afterFirstRun = await snapshotTree(root, ['node_modules']); - await assert.rejects(scaffoldResource(root), /refusing to overwrite existing business file/u); - assert.deepEqual(await snapshotTree(root, ['node_modules']), afterFirstRun); + await assertResourceScaffoldRefused(root, /refusing to overwrite existing business file/u); }); }); @@ -306,9 +318,7 @@ await test('resource scaffold leaves no artifact when generated owner slots or e manifest.replace('// ', '// invalid-resource-slot'), 'utf-8', ); - const beforeMissingSlot = await snapshotTree(root, ['node_modules']); - await assert.rejects(scaffoldResource(root), /generated owner file/u); - assert.deepEqual(await snapshotTree(root, ['node_modules']), beforeMissingSlot); + await assertResourceScaffoldRefused(root, /generated owner file/u); }); await withFixture(async (root) => { @@ -324,9 +334,7 @@ await test('resource scaffold leaves no artifact when generated owner slots or e }, }; await writeFile(packagePath, json(packageWithExportCollision), 'utf-8'); - const beforeExportCollision = await snapshotTree(root, ['node_modules']); - await assert.rejects(scaffoldResource(root), /resource contract export .* already exists/u); - assert.deepEqual(await snapshotTree(root, ['node_modules']), beforeExportCollision); + await assertResourceScaffoldRefused(root, /resource contract export .* already exists/u); }); }); @@ -346,8 +354,6 @@ await test('resource scaffold rejects a manifest without the governed resource s 'utf-8', ); - const before = await snapshotTree(root); - await assert.rejects(scaffoldResource(root), /slot/u); - assert.deepEqual(await snapshotTree(root), before); + await assertResourceScaffoldRefused(root, /slot/u, []); }); }); diff --git a/app/scripts/scaffolding/tests/retire-contribution.test.mts b/app/scripts/scaffolding/tests/retire-contribution.test.mts index b0f1ad49f..5e6e8e209 100644 --- a/app/scripts/scaffolding/tests/retire-contribution.test.mts +++ b/app/scripts/scaffolding/tests/retire-contribution.test.mts @@ -1,3 +1,12 @@ +import { NodeServices } from '@effect/platform-node'; +import { Effect, ManagedRuntime } from 'effect'; +import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; +import { fileURLToPath } from 'node:url'; +import { + insertSortedSlot, + readGeneratedSlotEntries, + removeGeneratedSlotEntry, +} from '../shared.mts'; import { snapshotTree, write } from './fixture-files.mts'; import assert from 'node:assert/strict'; import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; @@ -9,6 +18,8 @@ import type { JsonValue } from '../shared.mts'; const json = (value: JsonValue): string => `${JSON.stringify(value, null, 2)}\n`; +const VERTICAL_FLAG = '--vertical'; + const RETIRE_CONTRIBUTION_COMMAND = 'retire-contribution'; const ARCHIVE_ITEM = 'archive-item'; const ITEM_DETAIL = 'item-detail'; @@ -175,7 +186,7 @@ const withFixture = async (run: (root: string) => Promise): Promise const retire = async (root: string, kind: 'action' | 'api' | 'page', name: string) => await runScaffold( RETIRE_CONTRIBUTION_COMMAND, - ['--vertical', 'inventory', '--kind', kind, '--name', name], + [VERTICAL_FLAG, 'inventory', '--kind', kind, '--name', name], { workspaceRoot: root }, ); @@ -257,3 +268,112 @@ await test('refuses traversal, reruns, customized artifacts, and dependent Actio assert.deepEqual(await snapshotTree(root), retired); }); }); + +await test('slot insertion and retirement share multiline indentation and preserve the owner suffix', () => { + const start = '// '; + const end = '// '; + const content = `before + ${start} + ${end} +after`; + const entry = `item({ + key: "example", +}),`; + const inserted = insertSortedSlot(content, start, end, [entry], () => true); + assert.equal( + inserted, + `before + ${start} + item({ + key: "example", + }), +${end} +after`, + ); + assert.deepEqual(readGeneratedSlotEntries(inserted, start, end), [entry]); + const removed = removeGeneratedSlotEntry( + inserted, + start, + end, + (candidate) => candidate === entry, + 'item', + ); + assert.equal( + removed, + `before + ${start} + +${end} +after`, + ); + assert.throws( + () => removeGeneratedSlotEntry(removed, start, end, () => true, 'item'), + /found 0/u, + ); +}); + +await test('native scaffold CLI maps kebab-case flags and forwards trailing arguments', async (context) => { + const cliRuntime = ManagedRuntime.make(NodeServices.layer); + context.after(async () => await cliRuntime.dispose()); + const cases = [ + { + args: [ + 'action', + '--action', + 'archive-item', + VERTICAL_FLAG, + 'inventory', + '--legal-entity-scope', + 'optional', + '--authorization', + 'action_execution', + '--provisioning', + 'invalid', + ], + message: '--provisioning must be tenant_membership_default or explicit', + }, + { + args: [ + 'search-provider-access', + VERTICAL_FLAG, + 'inventory', + '--name', + 'items', + '--legal-entity-scope', + 'required', + '--access-filtering', + 'tenant_scope', + '--request-filters', + 'role', + ], + message: 'search provider access flags are internally inconsistent', + }, + { + args: [ + 'retire-contribution', + '--', + VERTICAL_FLAG, + 'inventory', + '--name', + 'item', + '--kind', + 'invalid', + ], + message: '--kind must be action, api, or page', + }, + ]; + await cliRuntime.runPromise( + Effect.gen(function* nativeCliFlags() { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + for (const { args, message } of cases) { + const output = yield* spawner.string( + ChildProcess.make(process.execPath, [ + fileURLToPath(new URL('../cli.mts', import.meta.url)), + ...args, + ]), + ); + assert.ok(output.includes(message), output); + } + }), + ); +}); diff --git a/app/scripts/scaffolding/tests/scaffold-generators.test.mts b/app/scripts/scaffolding/tests/scaffold-generators.test.mts index bfe91adab..f58093f05 100644 --- a/app/scripts/scaffolding/tests/scaffold-generators.test.mts +++ b/app/scripts/scaffolding/tests/scaffold-generators.test.mts @@ -721,6 +721,21 @@ const run = async ( }, ); +/** + * A refused scaffold must leave the workspace byte-identical: the generator either completes or + * writes nothing at all, so every guard proves its own message against an unchanged fixture tree. + */ +const assertScaffoldRefused = async ( + fixture: Fixture, + command: ScaffoldCommand, + commandArguments: readonly string[], + expected: RegExp, +): Promise => { + const before = await snapshotTree(fixture.root); + await assert.rejects(run(fixture, command, commandArguments), expected); + assert.deepEqual(await snapshotTree(fixture.root), before); +}; + const addInventoryItemResourceType = async (fixture: Fixture): Promise => { const manifestPath = path.join(fixture.root, inventoryManifestFile); const manifest = await readFile(manifestPath, 'utf-8'); @@ -872,9 +887,10 @@ test('search-provider access updates only generated access metadata and fails at providerPath, provider.replace('// @generated by OntOS Codesmith ', '// custom '), ); - const beforeRejectedUpdate = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.searchProviderAccess, [ + await assertScaffoldRefused( + fixture, + scaffoldCommand.searchProviderAccess, + [ scaffoldFlag.vertical, inventorySlug, '--name', @@ -885,10 +901,9 @@ test('search-provider access updates only generated access metadata and fails at 'resource_permission', scaffoldFlag.requestFilters, 'includeArchived,role', - ]), + ], /Codesmith-owned provider/u, ); - assert.deepEqual(await snapshotTree(fixture.root), beforeRejectedUpdate); }); }); @@ -1119,6 +1134,100 @@ test('the migrated Party governed API slot accepts future generated additions', assert.match(next, /\.addHttpApi\(FutureReadApi\)/u); }); +const requiredGeneratedSlot = (source: string, start: string, end: string): string => { + const slot = new RegExp(`${start}[\\s\\S]*?${end}`, 'u').exec(source)?.[0]; + assert.ok(slot !== undefined, `expected the generated slot between ${start} and ${end}`); + return slot; +}; + +/** + * Moving a generated composition slot into a string literal leaves the real binding missing, so + * the generator must refuse rather than accept the relocated copy as the composition point. + */ +const assertRelocatedSlotRefused = async ( + fixture: Fixture, + file: string, + validSource: string, + [slotStart, slotEnd]: readonly [string, string], +): Promise => { + const slot = requiredGeneratedSlot(validSource, slotStart, slotEnd); + await writeFile( + file, + `${validSource.replace(slot, '')}\nconst relocatedSlot = String.raw\`${slot}\`;\n`, + 'utf-8', + ); + await assertScaffoldRefused( + fixture, + scaffoldCommand.moduleApi, + [scaffoldFlag.vertical, inventorySlug, '--name', fixtureName.resourceDetail], + /composition slots are not bound/u, + ); + await writeFile(file, validSource, 'utf-8'); +}; +const assertGovernedReadClients = (clients: readonly string[]): void => { + for (const client of clients) { + assert.match(client, /from '@app\/shared-contracts\/client-runtime'/u); + assert.match(client, /return makeEffectBffClient\(/u); + assert.match(client, /defaultApiPrefix: '\/inventory-stock-api'/u); + assert.match(client, /operationGateway\.invoke\(\(credential\) =>/u); + assert.match(client, /WithAuthorization/u); + assert.match( + client, + /authorization: Redacted\.value\(credential\),\s+'x-correlation-id': requestCorrelation/u, + ); + assert.doesNotMatch( + client, + /makeEffectHttpApiClient|Context\.Reference|HttpClientRequest|HttpClient\.mapRequest/u, + ); + } +}; +const assertGovernedReadProviders = (providers: readonly string[]): void => { + for (const provider of providers) { + assert.match(provider, /defineRead\(/u); + assert.match(provider, /legalEntityScope: 'required'/u); + assert.match(provider, /permissionTarget: 'module'/u); + assert.doesNotMatch(provider, /CoreDatabase|ScopedTransactionExecutor|from 'pg'/u); + } +}; +const assertGovernedReadServers = (servers: readonly string[]): void => { + for (const server of servers) { + assert.match(server, /makeGovernedReadHttpHandler\(\{/u); + assert.match(server, /authenticatePrincipal: authenticateOperationPrincipal/u); + assert.match(server, /registration: \w+Read/u); + assert.doesNotMatch(server, /ReadRuntime|Match\.tags|catchTags|bearerChallenge/u); + assert.doesNotMatch(server, /tenantId|legalEntityId|principalId|CoreDatabase|from 'pg'/u); + } +}; +const assertComposedGovernedReads = (composedApi: string, composedHandlers: string): void => { + for (const [contract, layer] of [ + ['InventoryItemsSearchApi', 'inventoryItemsReadApiLive'], + ['ResourceDetailApi', 'resourceDetailReadApiLive'], + ['StockLevelsReportApi', 'stockLevelsReadApiLive'], + ] as const) { + assert.match(composedApi, new RegExp(`import \\{ ${contract} \\}`, 'u')); + assert.match(composedApi, new RegExp(`\\.addHttpApi\\(${contract}\\)`, 'u')); + assert.match(composedHandlers, new RegExp(`import \\{ ${layer} \\}`, 'u')); + assert.match( + composedHandlers, + new RegExp( + `${layer}\\.pipe\\([\\s\\S]*?GovernedReadLayer\\.provide\\(governedReadRuntimeLive\\)`, + 'u', + ), + ); + } +}; +const assertGovernedProblemDetailsContracts = (contracts: readonly string[]): void => { + for (const contract of contracts) { + assert.match( + contract, + /import \{\s*makeProblemDetailsSchema,\s*makeRetryableProblemDetailsSchema,?\s*\} from '@app\/shared-contracts\/problem-details';/u, + ); + assert.match(contract, /makeProblemDetailsSchema\([^)]*,\s*409,?\s*\)/u); + assert.match(contract, /makeRetryableProblemDetailsSchema\([^)]*,\s*503,?\s*\)/u); + assert.doesNotMatch(contract, /application\/problem\+json|HttpApiSchema/u); + } +}; + test('governed contribution generators patch owner contracts and lazy adapters atomically', async () => { await withFixture(async (fixture) => { const manifestPath = path.join(fixture.root, inventoryManifestFile); @@ -1231,70 +1340,22 @@ test('governed contribution generators patch owner contracts and lazy adapters a assert.match(moduleApiContract, /HttpApiGroup\.make\('resourceDetail'\)/u); assert.match(secondModuleApiContract, /HttpApiGroup\.make\('resourceHistory'\)/u); assert.match(secondModuleApiClient, /client\.resourceHistory\.execute\(/u); - for (const client of [moduleApiClient, searchClient, reportClient]) { - assert.match(client, /from '@app\/shared-contracts\/client-runtime'/u); - assert.match(client, /return makeEffectBffClient\(/u); - assert.match(client, /defaultApiPrefix: '\/inventory-stock-api'/u); - assert.match(client, /operationGateway\.invoke\(\(credential\) =>/u); - assert.match(client, /WithAuthorization/u); - assert.match( - client, - /authorization: Redacted\.value\(credential\),\s+'x-correlation-id': requestCorrelation/u, - ); - assert.doesNotMatch( - client, - /makeEffectHttpApiClient|Context\.Reference|HttpClientRequest|HttpClient\.mapRequest/u, - ); - } + assertGovernedReadClients([moduleApiClient, searchClient, reportClient]); assert.doesNotMatch(searchClient, /\.provider\.ts|import\(/u); assert.doesNotMatch(reportClient, /\.provider\.ts|import\(/u); - for (const provider of [searchProvider, reportProvider]) { - assert.match(provider, /defineRead\(/u); - assert.match(provider, /legalEntityScope: 'required'/u); - assert.match(provider, /permissionTarget: 'module'/u); - assert.doesNotMatch(provider, /CoreDatabase|ScopedTransactionExecutor|from 'pg'/u); - } + assertGovernedReadProviders([searchProvider, reportProvider]); assert.match(searchProvider, /result\.map\(\(\{ ref \}\) => ref\)/u); assert.match(moduleApiRead, /defineRead\(/u); assert.match(moduleApiRead, /legalEntityScope: 'required'/u); - for (const server of [moduleApiServer, searchServer, reportServer]) { - assert.match(server, /makeGovernedReadHttpHandler\(\{/u); - assert.match(server, /authenticatePrincipal: authenticateOperationPrincipal/u); - assert.match(server, /registration: \w+Read/u); - assert.doesNotMatch(server, /ReadRuntime|Match\.tags|catchTags|bearerChallenge/u); - assert.doesNotMatch(server, /tenantId|legalEntityId|principalId|CoreDatabase|from 'pg'/u); - } + assertGovernedReadServers([moduleApiServer, searchServer, reportServer]); assert.match(operationBoundary, /export const authenticateOperationPrincipal/u); - for (const [contract, layer] of [ - ['InventoryItemsSearchApi', 'inventoryItemsReadApiLive'], - ['ResourceDetailApi', 'resourceDetailReadApiLive'], - ['StockLevelsReportApi', 'stockLevelsReadApiLive'], - ] as const) { - assert.match(composedApi, new RegExp(`import \\{ ${contract} \\}`, 'u')); - assert.match(composedApi, new RegExp(`\\.addHttpApi\\(${contract}\\)`, 'u')); - assert.match(composedHandlers, new RegExp(`import \\{ ${layer} \\}`, 'u')); - assert.match( - composedHandlers, - new RegExp( - `${layer}\\.pipe\\([\\s\\S]*?GovernedReadLayer\\.provide\\(governedReadRuntimeLive\\)`, - 'u', - ), - ); - } + assertComposedGovernedReads(composedApi, composedHandlers); const searchContract = await readFixtureFile(fixture.root, inventorySearchContractFile); const reportContract = await readFixtureFile( fixture.root, 'verticals/inventory-stock/shared/apis/stock-levels-report.ts', ); - for (const contract of [moduleApiContract, searchContract, reportContract]) { - assert.match( - contract, - /import \{\s*makeProblemDetailsSchema,\s*makeRetryableProblemDetailsSchema,?\s*\} from '@app\/shared-contracts\/problem-details';/u, - ); - assert.match(contract, /makeProblemDetailsSchema\([^)]*,\s*409,?\s*\)/u); - assert.match(contract, /makeRetryableProblemDetailsSchema\([^)]*,\s*503,?\s*\)/u); - assert.doesNotMatch(contract, /application\/problem\+json|HttpApiSchema/u); - } + assertGovernedProblemDetailsContracts([moduleApiContract, searchContract, reportContract]); assert.match( searchContract, /HttpApiEndpoint\.post\('execute', '\/inventory\.stock\/search\/inventory-items'/u, @@ -1465,7 +1526,7 @@ try { }, }, ); - assert.equal(execution.status, 0, execution.stderr || execution.error?.message); + assert.equal(execution.status, 0, execution.stderr); const expectedGeneratedPrincipal = { authContextRef: 'job:generated-fixture:run:governed-read', authMethod: 'system', @@ -1473,7 +1534,9 @@ try { tenantId: '00000000-0000-4000-8000-000000000002', }; const expectedGeneratedTransport = { correlationId: 'generated-correlation' }; - assert.deepEqual(JSON.parse(execution.stdout.trim().split('\n').at(-1) ?? ''), { + const lastGeneratedLine = execution.stdout.trim().split('\n').at(-1); + assert.ok(lastGeneratedLine !== undefined); + assert.deepEqual(JSON.parse(lastGeneratedLine), { calls: [ { input: {}, @@ -1661,43 +1724,18 @@ try { ), 'utf-8', ); - const beforeInvalidSharedSlot = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.moduleApi, [ - scaffoldFlag.vertical, - inventorySlug, - '--name', - fixtureName.resourceDetail, - ]), + await assertScaffoldRefused( + fixture, + scaffoldCommand.moduleApi, + [scaffoldFlag.vertical, inventorySlug, '--name', fixtureName.resourceDetail], /composition slots are not bound|unsupported developer content/u, ); - assert.deepEqual(await snapshotTree(fixture.root), beforeInvalidSharedSlot); await writeFile(sharedApiPath, validSharedApi, 'utf-8'); - const governedApiSlot = new RegExp( - `${GOVERNED_HTTP_API_ADDITION_SLOT_START}[\\s\\S]*?${GOVERNED_HTTP_API_ADDITION_SLOT_END}`, - 'u', - ).exec(validSharedApi)?.[0]; - if (governedApiSlot === undefined) { - assert.fail('expected generated governed API slot'); - } - await writeFile( - sharedApiPath, - `${validSharedApi.replace(governedApiSlot, '')}\nconst relocatedGovernedApiSlot = String.raw\`${governedApiSlot}\`;\n`, - 'utf-8', - ); - const beforeRelocatedSharedSlot = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.moduleApi, [ - scaffoldFlag.vertical, - inventorySlug, - '--name', - fixtureName.resourceDetail, - ]), - /composition slots are not bound/u, - ); - assert.deepEqual(await snapshotTree(fixture.root), beforeRelocatedSharedSlot); - await writeFile(sharedApiPath, validSharedApi, 'utf-8'); + await assertRelocatedSlotRefused(fixture, sharedApiPath, validSharedApi, [ + GOVERNED_HTTP_API_ADDITION_SLOT_START, + GOVERNED_HTTP_API_ADDITION_SLOT_END, + ]); const registrationPath = path.join( fixture.root, @@ -1720,19 +1758,12 @@ try { // eslint-disable-next-line no-await-in-loop await writeFile(registrationPath, invalidRegistration, 'utf-8'); // eslint-disable-next-line no-await-in-loop - const beforeWrongCategoryRegistration = await snapshotTree(fixture.root); - // eslint-disable-next-line no-await-in-loop - await assert.rejects( - run(fixture, scaffoldCommand.moduleApi, [ - scaffoldFlag.vertical, - inventorySlug, - '--name', - fixtureName.resourceDetail, - ]), + await assertScaffoldRefused( + fixture, + scaffoldCommand.moduleApi, + [scaffoldFlag.vertical, inventorySlug, '--name', fixtureName.resourceDetail], /wrong contribution category/u, ); - // eslint-disable-next-line no-await-in-loop - assert.deepEqual(await snapshotTree(fixture.root), beforeWrongCategoryRegistration); } await writeFile(registrationPath, validRegistration, 'utf-8'); @@ -1746,42 +1777,17 @@ try { ), 'utf-8', ); - const beforeDriftedHandlerSlot = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.moduleApi, [ - scaffoldFlag.vertical, - inventorySlug, - '--name', - fixtureName.resourceDetail, - ]), + await assertScaffoldRefused( + fixture, + scaffoldCommand.moduleApi, + [scaffoldFlag.vertical, inventorySlug, '--name', fixtureName.resourceDetail], /contains drift/u, ); - assert.deepEqual(await snapshotTree(fixture.root), beforeDriftedHandlerSlot); - await writeFile(handlerRootPath, validHandlerRoot, 'utf-8'); - const handlerLayerSlot = new RegExp( - `${GOVERNED_HTTP_HANDLER_LAYER_SLOT_START}[\\s\\S]*?${GOVERNED_HTTP_HANDLER_LAYER_SLOT_END}`, - 'u', - ).exec(validHandlerRoot)?.[0]; - if (handlerLayerSlot === undefined) { - assert.fail('expected generated governed handler slot'); - } - await writeFile( - handlerRootPath, - `${validHandlerRoot.replace(handlerLayerSlot, '')}\nconst relocatedHandlerSlot = String.raw\`${handlerLayerSlot}\`;\n`, - 'utf-8', - ); - const beforeRelocatedHandlerSlot = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.moduleApi, [ - scaffoldFlag.vertical, - inventorySlug, - '--name', - fixtureName.resourceDetail, - ]), - /composition slots are not bound/u, - ); - assert.deepEqual(await snapshotTree(fixture.root), beforeRelocatedHandlerSlot); await writeFile(handlerRootPath, validHandlerRoot, 'utf-8'); + await assertRelocatedSlotRefused(fixture, handlerRootPath, validHandlerRoot, [ + GOVERNED_HTTP_HANDLER_LAYER_SLOT_START, + GOVERNED_HTTP_HANDLER_LAYER_SLOT_END, + ]); await assert.rejects( run(fixture, scaffoldCommand.moduleApi, [ scaffoldFlag.vertical, @@ -1820,17 +1826,12 @@ void ignored; assert.match(commentSafeFederation, /\/exposes: \\\{\\\}\/u/u); assert.match(commentSafeFederation, /\.\/BillingSummary/u); await writeFile(billingFederationPath, 'export default {};\n', 'utf-8'); - const beforeUnpatchable = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.publicComponent, [ - scaffoldFlag.vertical, - 'billing', - '--name', - 'billing-details', - ]), + await assertScaffoldRefused( + fixture, + scaffoldCommand.publicComponent, + [scaffoldFlag.vertical, 'billing', '--name', 'billing-details'], /exposes object is missing/u, ); - assert.deepEqual(await snapshotTree(fixture.root), beforeUnpatchable); }); }); @@ -1848,12 +1849,12 @@ test('governed contribution reruns cannot be spoofed by comments or corrupt owne invalidApiContract: string, ): Promise => { await writeFixtureFile(fixture.root, inventoryModuleApiContractFile, invalidApiContract); - const beforeInvalidContractRerun = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.moduleApi, scaffoldArguments), + await assertScaffoldRefused( + fixture, + scaffoldCommand.moduleApi, + scaffoldArguments, /refusing to overwrite existing business file/u, ); - assert.deepEqual(await snapshotTree(fixture.root), beforeInvalidContractRerun); }; await assertInvalidApiContractRerunRejected( apiContract.replace('/reads/resource-detail', '/reads/wrong'), @@ -1886,12 +1887,12 @@ test('governed contribution reruns cannot be spoofed by comments or corrupt owne const entry = "'resource-detail': () => import('./src/api/resource-detail-client.ts'),"; const corrupted = registration.replace(entry, `${entry}\n${entry}`); await writeFixtureFile(fixture.root, inventoryRegistrationFile, corrupted); - const beforeRejectedRerun = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.moduleApi, scaffoldArguments), + await assertScaffoldRefused( + fixture, + scaffoldCommand.moduleApi, + scaffoldArguments, /generated export already exists|generated owner slot/u, ); - assert.deepEqual(await snapshotTree(fixture.root), beforeRejectedRerun); await writeFixtureFile(fixture.root, inventoryRegistrationFile, registration); await writeFixtureFile( @@ -1899,12 +1900,12 @@ test('governed contribution reruns cannot be spoofed by comments or corrupt owne inventoryRegistrationFile, registration.replace(entry, "'resource-detail': () => import('./src/api/evil-client.ts'),"), ); - const beforeWrongBinding = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.moduleApi, scaffoldArguments), + await assertScaffoldRefused( + fixture, + scaffoldCommand.moduleApi, + scaffoldArguments, /generated owner slot contains mismatched identity/u, ); - assert.deepEqual(await snapshotTree(fixture.root), beforeWrongBinding); const wrongSlotRegistration = registration .replace(`${entry}\n`, '') @@ -1913,12 +1914,12 @@ test('governed contribution reruns cannot be spoofed by comments or corrupt owne ` ${entry}\n // `, ); await writeFixtureFile(fixture.root, inventoryRegistrationFile, wrongSlotRegistration); - const beforeWrongSlot = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.moduleApi, scaffoldArguments), + await assertScaffoldRefused( + fixture, + scaffoldCommand.moduleApi, + scaffoldArguments, /generated owner slot contains mismatched identity/u, ); - assert.deepEqual(await snapshotTree(fixture.root), beforeWrongSlot); await writeFixtureFile( fixture.root, @@ -1940,12 +1941,12 @@ test('governed contribution reruns cannot be spoofed by comments or corrupt owne "import { ResourceDetailApi } from './shared/apis/evil.ts';", ), ); - const beforeWrongImport = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.moduleApi, scaffoldArguments), + await assertScaffoldRefused( + fixture, + scaffoldCommand.moduleApi, + scaffoldArguments, /generated owner import binding conflicts/u, ); - assert.deepEqual(await snapshotTree(fixture.root), beforeWrongImport); }); }); @@ -1968,12 +1969,12 @@ test('adapted governed artifacts require executable owner identity instead of co file, `${current.replace(identity, removedIdentity)}\n/* ${identity} */\nconst identitySpoof = ${JSON.stringify(identity)};\n`, ); - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, command, commandArguments), + await assertScaffoldRefused( + fixture, + command, + commandArguments, /refusing to overwrite existing business file/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); await writeFixtureFile(fixture.root, file, current); await assertSpoofsRejected(remaining, command, commandArguments); }; @@ -2004,12 +2005,12 @@ test('adapted governed artifacts require executable owner identity instead of co ): Promise => { const current = await readFixtureFile(fixture.root, file); await writeFixtureFile(fixture.root, file, adapt(current)); - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.moduleApi, moduleArguments), + await assertScaffoldRefused( + fixture, + scaffoldCommand.moduleApi, + moduleArguments, /refusing to overwrite existing business file/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); await writeFixtureFile(fixture.root, file, current); }; await assertAdaptationRejected( @@ -2085,17 +2086,12 @@ test('governed client generation rejects an incompatible shared runtime dependen }), 'utf-8', ); - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.moduleApi, [ - scaffoldFlag.vertical, - inventorySlug, - '--name', - fixtureName.resourceDetail, - ]), + await assertScaffoldRefused( + fixture, + scaffoldCommand.moduleApi, + [scaffoldFlag.vertical, inventorySlug, '--name', fixtureName.resourceDetail], /incompatible @app\/shared-contracts dependency/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }); }); @@ -2383,15 +2379,12 @@ test('Action identity boundary preflight refuses unsafe writes', async () => { export const ownerCode = true; `, ); - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.microverticalActionBoundary, [ - scaffoldFlag.vertical, - inventorySlug, - ]), + await assertScaffoldRefused( + fixture, + scaffoldCommand.microverticalActionBoundary, + [scaffoldFlag.vertical, inventorySlug], /refusing to overwrite existing business file/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }); }); @@ -3014,12 +3007,12 @@ export const createOrder2Action = defineAction( zeta: '1.0.0', }); assert.equal(packageJson.scripts['existing'], preservedFixtureValue); - const beforeRerun = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, 'action', [scaffoldFlag.vertical, inventorySlug, '--action', 'create-order2']), + await assertScaffoldRefused( + fixture, + 'action', + [scaffoldFlag.vertical, inventorySlug, '--action', 'create-order2'], /refusing to overwrite/u, ); - assert.deepEqual(await snapshotTree(fixture.root), beforeRerun); }); }); @@ -3043,17 +3036,12 @@ import { Effect } from 'effect'; export const inventoryPersistenceService = () => Effect.succeed({}); `, ); - const beforeRerun = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.actionService, [ - scaffoldFlag.vertical, - inventorySlug, - '--service', - 'inventory-persistence', - ]), + await assertScaffoldRefused( + fixture, + scaffoldCommand.actionService, + [scaffoldFlag.vertical, inventorySlug, '--service', 'inventory-persistence'], /refusing to overwrite/u, ); - assert.deepEqual(await snapshotTree(fixture.root), beforeRerun); }); }); @@ -3134,19 +3122,19 @@ export const AresSubjectServiceLive = Layer.effect(AresSubjectService, makeAresS /fetch\(|httpClient\.(?:execute|get|head|post|patch|put|del|options)\(|https?:\/\//u, ); - const beforeOverwrite = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.externalHttpAdapter, [ + await assertScaffoldRefused( + fixture, + scaffoldCommand.externalHttpAdapter, + [ scaffoldFlag.vertical, 'contacts', scaffoldFlag.provider, 'ares', scaffoldFlag.operation, 'subject', - ]), + ], /refusing to overwrite/u, ); - assert.deepEqual(await snapshotTree(fixture.root), beforeOverwrite); }); }); @@ -3299,19 +3287,19 @@ test('external HTTP adapter planner rejects malformed OntOS ownership atomically ), 'utf-8', ); - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.externalHttpAdapter, [ + await assertScaffoldRefused( + fixture, + scaffoldCommand.externalHttpAdapter, + [ scaffoldFlag.vertical, 'contacts', scaffoldFlag.provider, 'ares', scaffoldFlag.operation, 'subject', - ]), + ], /is not a generated module owner/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }); await withFixture(async (fixture) => { @@ -3320,19 +3308,19 @@ test('external HTTP adapter planner rejects malformed OntOS ownership atomically 'verticals/contacts/src/integrations', 'planner fixture blocks the required directory\n', ); - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.externalHttpAdapter, [ + await assertScaffoldRefused( + fixture, + scaffoldCommand.externalHttpAdapter, + [ scaffoldFlag.vertical, 'contacts', scaffoldFlag.provider, 'ares', scaffoldFlag.operation, 'subject', - ]), + ], /ENOTDIR|not a directory/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }); }); @@ -3349,12 +3337,12 @@ import { fakeRead } from './src/api/fake.read.ts';`, ), 'utf-8', ); - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, 'action', [scaffoldFlag.vertical, inventorySlug, '--action', 'create-order3']), + await assertScaffoldRefused( + fixture, + 'action', + [scaffoldFlag.vertical, inventorySlug, '--action', 'create-order3'], /generated owner slot contains unsupported developer content/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }); }); @@ -3413,19 +3401,12 @@ test('generates Core-owned Actions only through the Core owner slot with atomic ); assert.match(coreCatalog, /export const existingCatalogSurface = true/u); - const beforeOverwrite = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, 'action', [ - '--scope', - 'core', - '--module', - fixtureName.actionModule, - '--action', - 'account-change', - ]), + await assertScaffoldRefused( + fixture, + 'action', + ['--scope', 'core', '--module', fixtureName.actionModule, '--action', 'account-change'], /refusing to overwrite/u, ); - assert.deepEqual(await snapshotTree(fixture.root), beforeOverwrite); }); await withFixture(async (fixture) => { @@ -3435,19 +3416,12 @@ test('generates Core-owned Actions only through the Core owner slot with atomic `export const existingCoreSurface = true;\n\n// \n// \n`, 'utf-8', ); - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, 'action', [ - '--scope', - 'core', - '--module', - fixtureName.actionModule, - '--action', - fixtureName.action, - ]), + await assertScaffoldRefused( + fixture, + 'action', + ['--scope', 'core', '--module', fixtureName.actionModule, '--action', fixtureName.action], /generated owner file does not contain one valid/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }); await withFixture(async (fixture) => { @@ -3461,19 +3435,12 @@ test('generates Core-owned Actions only through the Core owner slot with atomic ), 'utf-8', ); - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, 'action', [ - '--scope', - 'core', - '--module', - fixtureName.actionModule, - '--action', - fixtureName.action, - ]), + await assertScaffoldRefused( + fixture, + 'action', + ['--scope', 'core', '--module', fixtureName.actionModule, '--action', fixtureName.action], /unsupported developer content/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }); await withFixture(async (fixture) => { @@ -3487,19 +3454,12 @@ test('generates Core-owned Actions only through the Core owner slot with atomic ), 'utf-8', ); - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, 'action', [ - '--scope', - 'core', - '--module', - fixtureName.actionModule, - '--action', - fixtureName.action, - ]), + await assertScaffoldRefused( + fixture, + 'action', + ['--scope', 'core', '--module', fixtureName.actionModule, '--action', fixtureName.action], /unsupported developer content/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }); }); @@ -3515,17 +3475,12 @@ test('preflights the Action dependency patch before creating a file', async () = }), 'utf-8', ); - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, 'action', [ - scaffoldFlag.vertical, - inventorySlug, - '--action', - fixtureName.action, - ]), + await assertScaffoldRefused( + fixture, + 'action', + [scaffoldFlag.vertical, inventorySlug, '--action', fixtureName.action], /incompatible/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }); }); @@ -3541,18 +3496,12 @@ test('rejects Action generation when a vertical app identity is duplicated', asy }), 'utf-8', ); - const before = await snapshotTree(fixture.root); - - await assert.rejects( - run(fixture, 'action', [ - scaffoldFlag.vertical, - inventorySlug, - '--action', - fixtureName.action, - ]), + await assertScaffoldRefused( + fixture, + 'action', + [scaffoldFlag.vertical, inventorySlug, '--action', fixtureName.action], /duplicate generated appId inventory-stock/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }); }); @@ -3568,18 +3517,12 @@ test('rejects Action generation when the target identity is absent from topology }), 'utf-8', ); - const before = await snapshotTree(fixture.root); - - await assert.rejects( - run(fixture, 'action', [ - scaffoldFlag.vertical, - inventorySlug, - '--action', - fixtureName.action, - ]), + await assertScaffoldRefused( + fixture, + 'action', + [scaffoldFlag.vertical, inventorySlug, '--action', fixtureName.action], /must have exactly one matching generated topology entry/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }); }); @@ -3704,56 +3647,56 @@ export const outboxProducerModuleKey = 'inventory.stock' as const; '--topic', 'events.foo-1-bar', ]); - const beforeIdentifierCollision = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.outboxMessage, [ + await assertScaffoldRefused( + fixture, + scaffoldCommand.outboxMessage, + [ scaffoldFlag.vertical, inventorySlug, '--action', fixtureName.action, '--topic', 'events.foo1-bar', - ]), + ], /Outbox identifier CreateOrderEventsFoo1BarOutbox already exists/u, ); - assert.deepEqual(await snapshotTree(fixture.root), beforeIdentifierCollision); }); }); test('rejects missing, handwritten, duplicate, and normalized-collision Outbox targets without partial writes', async () => { await withFixture(async (fixture) => { - const beforeMissing = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.outboxMessage, [ + await assertScaffoldRefused( + fixture, + scaffoldCommand.outboxMessage, + [ scaffoldFlag.vertical, inventorySlug, '--action', 'missing-action', '--topic', fixtureName.ordersCreated, - ]), + ], /requires the generated Action/u, ); - assert.deepEqual(await snapshotTree(fixture.root), beforeMissing); await writeFixtureFile( fixture.root, 'verticals/inventory-stock/src/actions/handwritten.action.ts', `// \n// \n`, ); - const beforeHandwritten = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.outboxMessage, [ + await assertScaffoldRefused( + fixture, + scaffoldCommand.outboxMessage, + [ scaffoldFlag.vertical, inventorySlug, '--action', 'handwritten', '--topic', fixtureName.ordersCreated, - ]), + ], /only the matching generated Action/u, ); - assert.deepEqual(await snapshotTree(fixture.root), beforeHandwritten); await run(fixture, 'action', [ scaffoldFlag.vertical, @@ -3768,19 +3711,19 @@ test('rejects missing, handwritten, duplicate, and normalized-collision Outbox t governedActionPath, governedAction.replace(" access: 'write',", " access: 'read',"), ); - const beforeMismatchedEntrypoint = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.outboxMessage, [ + await assertScaffoldRefused( + fixture, + scaffoldCommand.outboxMessage, + [ scaffoldFlag.vertical, inventorySlug, '--action', fixtureName.action, '--topic', fixtureName.ordersCreated, - ]), + ], /matching generated Action with its governed write entrypoint/u, ); - assert.deepEqual(await snapshotTree(fixture.root), beforeMismatchedEntrypoint); await writeFixtureFile(fixture.root, governedActionPath, governedAction); await run(fixture, scaffoldCommand.outboxMessage, [ scaffoldFlag.vertical, @@ -4025,9 +3968,10 @@ export const startBillingOutboxWorker = (): void => registry.indexOf('ordersCreatedLoggerWorker') < registry.indexOf('ordersShippedProjectorWorker'), ); - const beforeRerun = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.outboxWorker, [ + await assertScaffoldRefused( + fixture, + scaffoldCommand.outboxWorker, + [ scaffoldFlag.vertical, 'billing', '--worker', @@ -4036,10 +3980,9 @@ export const startBillingOutboxWorker = (): void => inventorySlug, '--topic', fixtureName.ordersCreated, - ]), + ], /refusing to overwrite/u, ); - assert.deepEqual(await snapshotTree(fixture.root), beforeRerun); }); }); @@ -4113,12 +4056,12 @@ test('generates self-consuming Outbox Workers without circular project or packag for (const script of ['dev:worker', 'worker:start']) { assert.equal(ownerPackage.scripts[script], workerStartScript); } - const beforeRerun = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.outboxWorker, args), + await assertScaffoldRefused( + fixture, + scaffoldCommand.outboxWorker, + args, /refusing to overwrite/u, ); - assert.deepEqual(await snapshotTree(fixture.root), beforeRerun); await run(fixture, 'action', [ scaffoldFlag.vertical, inventorySlug, @@ -4133,9 +4076,10 @@ test('generates self-consuming Outbox Workers without circular project or packag inventoryTsconfigFile, JSON.stringify({ references: [{ path: '../inventory-stock' }] }), ); - const beforeCircularReference = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.outboxWorker, [ + await assertScaffoldRefused( + fixture, + scaffoldCommand.outboxWorker, + [ scaffoldFlag.vertical, inventorySlug, '--worker', @@ -4144,18 +4088,18 @@ test('generates self-consuming Outbox Workers without circular project or packag inventorySlug, '--topic', fixtureName.ordersCreated, - ]), + ], /circular self project reference/u, ); - assert.deepEqual(await snapshotTree(fixture.root), beforeCircularReference); }); }); test('refuses unpublished or malformed Outbox contracts without partial consumer writes', async () => { await withFixture(async (fixture) => { - const beforeUnpublished = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.outboxWorker, [ + await assertScaffoldRefused( + fixture, + scaffoldCommand.outboxWorker, + [ scaffoldFlag.vertical, 'billing', '--worker', @@ -4164,10 +4108,9 @@ test('refuses unpublished or malformed Outbox contracts without partial consumer inventorySlug, '--topic', 'orders.missing', - ]), + ], /published producer Outbox contract is missing/u, ); - assert.deepEqual(await snapshotTree(fixture.root), beforeUnpublished); await run(fixture, 'action', [ scaffoldFlag.vertical, @@ -4193,9 +4136,10 @@ test('refuses unpublished or malformed Outbox contracts without partial consumer ), 'utf-8', ); - const beforeMalformed = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.outboxWorker, [ + await assertScaffoldRefused( + fixture, + scaffoldCommand.outboxWorker, + [ scaffoldFlag.vertical, 'billing', '--worker', @@ -4204,10 +4148,9 @@ test('refuses unpublished or malformed Outbox contracts without partial consumer inventorySlug, '--topic', fixtureName.ordersCreated, - ]), + ], /owner\/topic\/schema mismatch/u, ); - assert.deepEqual(await snapshotTree(fixture.root), beforeMalformed); }); }); @@ -4280,20 +4223,20 @@ export { tenantActivePolicy } from './policies/tenant-active.policy.ts'; ], workspaceVersion, ); - const beforeDuplicate = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, 'policy', ['--scope', 'global', '--policy', fixtureName.policy]), + await assertScaffoldRefused( + fixture, + 'policy', + ['--scope', 'global', '--policy', fixtureName.policy], /refusing to overwrite/u, ); - assert.deepEqual(await snapshotTree(fixture.root), beforeDuplicate); await run(fixture, 'policy', ['--scope', 'global', '--policy', 'foo-1-bar']); - const beforeIdentifierCollision = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, 'policy', ['--scope', 'global', '--policy', 'foo1-bar']), + await assertScaffoldRefused( + fixture, + 'policy', + ['--scope', 'global', '--policy', 'foo1-bar'], /Policy identifier foo1BarPolicy already exists/u, ); - assert.deepEqual(await snapshotTree(fixture.root), beforeIdentifierCollision); }); }); @@ -4721,19 +4664,12 @@ test('supports an explicit nested page URL and rejects unsafe URL inputs atomica ].map( async (url) => await withFixture(async (fixture) => { - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.microverticalPage, [ - scaffoldFlag.vertical, - inventorySlug, - '--page', - 'orders', - '--url', - url, - ]), + await assertScaffoldRefused( + fixture, + scaffoldCommand.microverticalPage, + [scaffoldFlag.vertical, inventorySlug, '--page', 'orders', '--url', url], /--url/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }), ), ); @@ -4886,13 +4822,12 @@ test('generates the Contacts Contact-detail two-parameter page atomically and sa 'apps/shell-super-app/src/routes/[lang]/contacts/customers/[id]/contacts/[contactId]/page.tsx', 'export default function DeveloperOwnedPage() { return null; }\n', ); - const before = await snapshotTree(fixture.root); - - await assert.rejects( - run(fixture, scaffoldCommand.microverticalPage, generatorArguments), + await assertScaffoldRefused( + fixture, + scaffoldCommand.microverticalPage, + generatorArguments, /refusing to overwrite|already exists/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }); }); @@ -4911,19 +4846,19 @@ test('rejects unsafe dynamic parameters and dynamic route collisions without wri ].map( async (url) => await withFixture(async (fixture) => { - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.microverticalPage, [ + await assertScaffoldRefused( + fixture, + scaffoldCommand.microverticalPage, + [ scaffoldFlag.vertical, inventorySlug, '--page', fixtureName.customerEditPage, '--url', url, - ]), + ], /--url/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }), ), ); @@ -4938,19 +4873,19 @@ test('rejects unsafe dynamic parameters and dynamic route collisions without wri '--url', customerDetailUrl, ]); - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.microverticalPage, [ + await assertScaffoldRefused( + fixture, + scaffoldCommand.microverticalPage, + [ scaffoldFlag.vertical, inventorySlug, '--page', fixtureName.customerEditPage, '--url', '/inventory/customers/:customerId', - ]), + ], /routing collision|already registered|collides/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }), withFixture(async (fixture) => { const generatorArguments = [ @@ -4971,12 +4906,12 @@ test('rejects unsafe dynamic parameters and dynamic route collisions without wri `${await readFile(pagePath, 'utf-8')}\n// developer edit\n`, 'utf-8', ); - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.microverticalPage, generatorArguments), + await assertScaffoldRefused( + fixture, + scaffoldCommand.microverticalPage, + generatorArguments, /collides/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }), withFixture(async (fixture) => { await writeFixtureFile( @@ -4984,19 +4919,19 @@ test('rejects unsafe dynamic parameters and dynamic route collisions without wri 'verticals/inventory-stock/src/routes/[lang]/inventory/customers/[id]/edit/page.tsx', 'export default function PartialPage() { return null; }\n', ); - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.microverticalPage, [ + await assertScaffoldRefused( + fixture, + scaffoldCommand.microverticalPage, + [ scaffoldFlag.vertical, inventorySlug, '--page', fixtureName.customerEditPage, '--url', customerEditUrl, - ]), + ], /collides with nested content/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }), withFixture(async (fixture) => { await run(fixture, scaffoldCommand.microverticalPage, [ @@ -5007,19 +4942,19 @@ test('rejects unsafe dynamic parameters and dynamic route collisions without wri '--url', '/inventory/customers/new', ]); - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.microverticalPage, [ + await assertScaffoldRefused( + fixture, + scaffoldCommand.microverticalPage, + [ scaffoldFlag.vertical, inventorySlug, '--page', fixtureName.customerEditPage, '--url', customerDetailUrl, - ]), + ], /static route segment|collides/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }), withFixture(async (fixture) => { await run(fixture, scaffoldCommand.microverticalPage, [ @@ -5037,19 +4972,19 @@ test('rejects unsafe dynamic parameters and dynamic route collisions without wri ), { recursive: true }, ); - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.microverticalPage, [ + await assertScaffoldRefused( + fixture, + scaffoldCommand.microverticalPage, + [ scaffoldFlag.vertical, inventorySlug, '--page', fixtureName.customerEditPage, '--url', '/shared/customers/:id/edit', - ]), + ], /already registered by billing/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }), ]); }); @@ -5096,19 +5031,19 @@ test('rejects reserved, dynamic, and cross-owner page URLs before writing', asyn 'apps/shell-super-app/src/routes/[lang]/modules/[moduleId]/page.tsx', 'export default function ModulePage() { return null; }\n', ); - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.microverticalPage, [ + await assertScaffoldRefused( + fixture, + scaffoldCommand.microverticalPage, + [ scaffoldFlag.vertical, inventorySlug, '--page', 'customers', '--url', '/modules/customers', - ]), + ], /collides with dynamic route segment \[moduleId\]/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }), withFixture(async (fixture) => { await writeFixtureFile( @@ -5116,19 +5051,12 @@ test('rejects reserved, dynamic, and cross-owner page URLs before writing', asyn 'apps/shell-super-app/src/routes/[lang]/login/page.tsx', 'export default function LoginPage() { return null; }\n', ); - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.microverticalPage, [ - scaffoldFlag.vertical, - inventorySlug, - '--page', - 'customers', - '--url', - '/login/customers', - ]), + await assertScaffoldRefused( + fixture, + scaffoldCommand.microverticalPage, + [scaffoldFlag.vertical, inventorySlug, '--page', 'customers', '--url', '/login/customers'], /reserved route prefix \/login/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }), withFixture(async (fixture) => { await run(fixture, scaffoldCommand.microverticalPage, [ @@ -5142,19 +5070,19 @@ test('rejects reserved, dynamic, and cross-owner page URLs before writing', asyn await rm(path.join(fixture.root, 'apps/shell-super-app/src/routes/[lang]/shared/customers'), { recursive: true, }); - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.microverticalPage, [ + await assertScaffoldRefused( + fixture, + scaffoldCommand.microverticalPage, + [ scaffoldFlag.vertical, inventorySlug, '--page', 'customer-list', '--url', '/shared/customers', - ]), + ], /already registered by billing/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }), ]); }); @@ -5200,19 +5128,12 @@ test('uses exact page identities and rejects edited generated wiring', async () .replaceAll("'inventory.stock.page.orders'", '"inventory.stock.page.orders"'), 'utf-8', ); - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.microverticalPage, [ - scaffoldFlag.vertical, - inventorySlug, - '--page', - 'orders', - '--url', - '/second/orders', - ]), + await assertScaffoldRefused( + fixture, + scaffoldCommand.microverticalPage, + [scaffoldFlag.vertical, inventorySlug, '--page', 'orders', '--url', '/second/orders'], /page identity inventory\.stock\.page\.orders already exists/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }), withFixture(async (fixture) => { const generatorArguments = [scaffoldFlag.vertical, inventorySlug, '--page', 'orders']; @@ -5220,12 +5141,12 @@ test('uses exact page identities and rejects edited generated wiring', async () const manifestPath = path.join(fixture.root, inventoryManifestFile); const manifest = await readFile(manifestPath, 'utf-8'); await writeFile(manifestPath, manifest.replace('order: 100', 'order: 101'), 'utf-8'); - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.microverticalPage, generatorArguments), + await assertScaffoldRefused( + fixture, + scaffoldCommand.microverticalPage, + generatorArguments, /already exists|collides/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }), withFixture(async (fixture) => { const generatorArguments = [scaffoldFlag.vertical, inventorySlug, '--page', 'orders']; @@ -5241,12 +5162,12 @@ test('uses exact page identities and rejects edited generated wiring', async () ), 'utf-8', ); - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.microverticalPage, generatorArguments), + await assertScaffoldRefused( + fixture, + scaffoldCommand.microverticalPage, + generatorArguments, /already exists|collides/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }), withFixture(async (fixture) => { const generatorArguments = [scaffoldFlag.vertical, inventorySlug, '--page', 'orders']; @@ -5261,12 +5182,12 @@ test('uses exact page identities and rejects edited generated wiring', async () ), 'utf-8', ); - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.microverticalPage, generatorArguments), + await assertScaffoldRefused( + fixture, + scaffoldCommand.microverticalPage, + generatorArguments, /already exists|collides/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }), withFixture(async (fixture) => { const generatorArguments = [scaffoldFlag.vertical, inventorySlug, '--page', 'orders']; @@ -5276,12 +5197,12 @@ test('uses exact page identities and rejects edited generated wiring', async () 'apps/shell-super-app/src/routes/[lang]/inventory-stock/orders/developer-note.ts', 'export const developerNote = true;\n', ); - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.microverticalPage, generatorArguments), + await assertScaffoldRefused( + fixture, + scaffoldCommand.microverticalPage, + generatorArguments, /already exists|collides/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }), ]); }); @@ -5381,12 +5302,12 @@ export const loader = ({ request }: ShellPageLoaderArguments) => }), ); - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.microverticalPage, generatorArguments), + await assertScaffoldRefused( + fixture, + scaffoldCommand.microverticalPage, + generatorArguments, /page route already exists or collides/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }); }); @@ -5407,18 +5328,12 @@ test('rejects page generation when an owning locale has no truthful starter tran 'verticals/inventory-stock/locales/de/inventory.json', json({ inventory: { existing: 'de-preserved' } }), ); - const before = await snapshotTree(fixture.root); - - await assert.rejects( - run(fixture, scaffoldCommand.microverticalPage, [ - scaffoldFlag.vertical, - inventorySlug, - '--page', - fixtureName.purchaseOrdersPage, - ]), + await assertScaffoldRefused( + fixture, + scaffoldCommand.microverticalPage, + [scaffoldFlag.vertical, inventorySlug, '--page', fixtureName.purchaseOrdersPage], /no starter translation for locale de/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }); }); @@ -5427,17 +5342,12 @@ test('page prerequisite and nested-route failures are preflighted, while refresh await rm( path.join(fixture.root, 'verticals/inventory-stock/src/routes/ultramodern-route-head.tsx'), ); - const beforeMissingHead = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.microverticalPage, [ - scaffoldFlag.vertical, - inventorySlug, - '--page', - 'orders', - ]), + await assertScaffoldRefused( + fixture, + scaffoldCommand.microverticalPage, + [scaffoldFlag.vertical, inventorySlug, '--page', 'orders'], /UltramodernRouteHead is missing/u, ); - assert.deepEqual(await snapshotTree(fixture.root), beforeMissingHead); }); await withFixture(async (fixture) => { @@ -5446,17 +5356,12 @@ test('page prerequisite and nested-route failures are preflighted, while refresh 'verticals/inventory-stock/src/routes/[lang]/inventory-stock/orders/nested.ts', 'export {};\n', ); - const beforeCollision = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.microverticalPage, [ - scaffoldFlag.vertical, - inventorySlug, - '--page', - 'orders', - ]), + await assertScaffoldRefused( + fixture, + scaffoldCommand.microverticalPage, + [scaffoldFlag.vertical, inventorySlug, '--page', 'orders'], /collides with nested content/u, ); - assert.deepEqual(await snapshotTree(fixture.root), beforeCollision); }); await withFixture(async (fixture) => { @@ -5694,115 +5599,66 @@ test('all generated files typecheck against the real workspace contracts', async await mkdir(path.join(fixture.root, 'node_modules', '@effect'), { recursive: true }); await mkdir(path.join(fixture.root, 'node_modules', '@modern-js'), { recursive: true }); await mkdir(path.join(fixture.root, 'node_modules', '@types'), { recursive: true }); - await symlink( - path.join(appRoot, 'packages/core-runtime/node_modules/effect'), - path.join(fixture.root, effectNodeModulePath), - 'dir', - ); - await symlink( - path.join(appRoot, 'packages/core-runtime/node_modules/@effect/sql-pg'), - path.join(fixture.root, 'node_modules/@effect/sql-pg'), - 'dir', - ); - await symlink( - path.join(appRoot, 'packages/core-runtime/node_modules/@effect/platform-node'), - path.join(fixture.root, 'node_modules/@effect/platform-node'), - 'dir', - ); - await symlink( - path.join(appRoot, 'apps/shell-super-app/node_modules/jose'), - path.join(fixture.root, 'node_modules/jose'), - 'dir', - ); - await symlink( - path.join(appRoot, 'packages/core-runtime/node_modules/drizzle-orm'), - path.join(fixture.root, 'node_modules/drizzle-orm'), - 'dir', - ); - await symlink( - path.join(appRoot, 'packages/core-runtime/node_modules/dotenv'), - path.join(fixture.root, 'node_modules/dotenv'), - 'dir', - ); - await symlink( - path.join(appRoot, 'packages/core-runtime/node_modules/pg'), - path.join(fixture.root, 'node_modules/pg'), - 'dir', - ); - await symlink( - path.join(appRoot, 'packages/core-runtime/node_modules/@authzed/authzed-node'), - path.join(fixture.root, 'node_modules/@authzed/authzed-node'), - 'dir', - ); - await symlink( - path.join(appRoot, 'apps/shell-super-app/node_modules/@modern-js/plugin-i18n'), - path.join(fixture.root, 'node_modules/@modern-js/plugin-i18n'), - 'dir', - ); - await symlink( - path.join(appRoot, 'apps/shell-super-app/node_modules/@modern-js/plugin-bff'), - path.join(fixture.root, pluginBffNodeModulePath), - 'dir', - ); - await symlink( - path.join(appRoot, 'apps/shell-super-app/node_modules/@types/react'), - path.join(fixture.root, 'node_modules/@types/react'), - 'dir', - ); - await symlink( - path.join(appRoot, 'packages/core-runtime/node_modules/@types/pg'), - path.join(fixture.root, 'node_modules/@types/pg'), - 'dir', - ); - await symlink( - path.join(appRoot, 'node_modules/@types/node'), - path.join(fixture.root, 'node_modules/@types/node'), - 'dir', - ); - await symlink( - path.join(appRoot, 'packages/core-runtime/src/actions'), - path.join(fixture.root, 'packages/core-runtime/src/actions'), - 'dir', - ); - await symlink( - path.join(appRoot, 'packages/core-runtime/src/db'), - path.join(fixture.root, 'packages/core-runtime/src/db'), - 'dir', - ); - await symlink( - path.join(appRoot, 'packages/core-runtime/src/operations'), - path.join(fixture.root, 'packages/core-runtime/src/operations'), - 'dir', - ); - await symlink( - path.join(appRoot, 'packages/core-runtime/src/database'), - path.join(fixture.root, 'packages/core-runtime/src/database'), - 'dir', - ); - await symlink( - path.join(appRoot, 'packages/core-runtime/src/environment'), - path.join(fixture.root, 'packages/core-runtime/src/environment'), - 'dir', - ); - await symlink( - path.join(appRoot, 'packages/core-runtime/src/permissions'), - path.join(fixture.root, 'packages/core-runtime/src/permissions'), - 'dir', - ); - await symlink( - path.join(appRoot, 'packages/core-runtime/src/auth'), - path.join(fixture.root, 'packages/core-runtime/src/auth'), - 'dir', - ); - await symlink( - path.join(appRoot, 'packages/core-runtime/src/authorization'), - path.join(fixture.root, 'packages/core-runtime/src/authorization'), - 'dir', - ); - await symlink( - path.join(appRoot, 'packages/core-runtime/src/modules/module-entrypoint.ts'), - path.join(fixture.root, 'packages/core-runtime/src/modules/module-entrypoint.ts'), - 'file', + // Every generated-runtime dependency is linked from the real workspace so the fixture + // typechecks and runs against the same modules the shipped verticals resolve. + await Promise.all( + ( + [ + ['packages/core-runtime/node_modules/effect', effectNodeModulePath, 'dir'], + [ + 'packages/core-runtime/node_modules/@effect/sql-pg', + 'node_modules/@effect/sql-pg', + 'dir', + ], + [ + 'packages/core-runtime/node_modules/@effect/platform-node', + 'node_modules/@effect/platform-node', + 'dir', + ], + ['apps/shell-super-app/node_modules/jose', 'node_modules/jose', 'dir'], + ['packages/core-runtime/node_modules/drizzle-orm', 'node_modules/drizzle-orm', 'dir'], + ['packages/core-runtime/node_modules/dotenv', 'node_modules/dotenv', 'dir'], + ['packages/core-runtime/node_modules/pg', 'node_modules/pg', 'dir'], + [ + 'packages/core-runtime/node_modules/@authzed/authzed-node', + 'node_modules/@authzed/authzed-node', + 'dir', + ], + [ + 'apps/shell-super-app/node_modules/@modern-js/plugin-i18n', + 'node_modules/@modern-js/plugin-i18n', + 'dir', + ], + [ + 'apps/shell-super-app/node_modules/@modern-js/plugin-bff', + pluginBffNodeModulePath, + 'dir', + ], + ['apps/shell-super-app/node_modules/@types/react', 'node_modules/@types/react', 'dir'], + ['packages/core-runtime/node_modules/@types/pg', 'node_modules/@types/pg', 'dir'], + ['node_modules/@types/node', 'node_modules/@types/node', 'dir'], + ['packages/core-runtime/src/actions', 'packages/core-runtime/src/actions', 'dir'], + ['packages/core-runtime/src/db', 'packages/core-runtime/src/db', 'dir'], + ['packages/core-runtime/src/operations', 'packages/core-runtime/src/operations', 'dir'], + ['packages/core-runtime/src/database', 'packages/core-runtime/src/database', 'dir'], + ['packages/core-runtime/src/environment', 'packages/core-runtime/src/environment', 'dir'], + ['packages/core-runtime/src/permissions', 'packages/core-runtime/src/permissions', 'dir'], + ['packages/core-runtime/src/auth', 'packages/core-runtime/src/auth', 'dir'], + [ + 'packages/core-runtime/src/authorization', + 'packages/core-runtime/src/authorization', + 'dir', + ], + [ + 'packages/core-runtime/src/modules/module-entrypoint.ts', + 'packages/core-runtime/src/modules/module-entrypoint.ts', + 'file', + ], + ] as const + ).map( + async ([source, target, kind]) => + await symlink(path.join(appRoot, source), path.join(fixture.root, target), kind), + ), ); await Promise.all( [ @@ -5964,15 +5820,12 @@ test('Action identity boundary rejects an owned file without the authentication source.replaceAll('authenticateOperationPrincipal', 'removedAuthenticationAdapter'), 'utf-8', ); - const before = await snapshotTree(fixture.root); - await assert.rejects( - run(fixture, scaffoldCommand.microverticalActionBoundary, [ - scaffoldFlag.vertical, - inventorySlug, - ]), + await assertScaffoldRefused( + fixture, + scaffoldCommand.microverticalActionBoundary, + [scaffoldFlag.vertical, inventorySlug], /refusing|owned|boundary/u, ); - assert.deepEqual(await snapshotTree(fixture.root), before); }); }); diff --git a/app/scripts/tests/api-only-tooling.test.mts b/app/scripts/tests/api-only-tooling.test.mts index 6d429fa98..cb2db9c06 100644 --- a/app/scripts/tests/api-only-tooling.test.mts +++ b/app/scripts/tests/api-only-tooling.test.mts @@ -936,7 +936,6 @@ const validAdversarialStrictRuntimeSources = [ `, ] as const; -// oxlint-disable-next-line complexity -- This table-driven contract test keeps each distinct validator failure observable. void test('static API validation proves the imported helper call topology', () => { const valid = ` import { assembleEffectBffRuntime } from '@fixture/shared-contracts/server/effect-bff-runtime'; @@ -1074,19 +1073,22 @@ void test('static API validation proves the imported helper call topology', () = for (const source of adversarialStrictRuntimeSources) { assert.notEqual(strictEffectRuntimeTopologyViolation(source), undefined); } - assert.match( - strictEffectRuntimeTopologyViolation(` + for (const [label, source, expected] of [ + [ + 'defineEffectBff with a typed layer annotation', + ` import { defineEffectBff, HttpApiBuilder, Layer } from '@modern-js/plugin-bff/effect-edge'; import { fixtureApi } from '../shared/api.ts'; const fixtureLayer: Layer.Layer = HttpApiBuilder.layer(fixtureApi).pipe( Layer.provide(fixtureHandlers), ); export default defineEffectBff({ api: fixtureApi, layer: fixtureLayer }); - `) ?? '', - /server-only shared Effect BFF assembly helper/u, - ); - assert.match( - strictEffectRuntimeTopologyViolation(` + `, + /server-only shared Effect BFF assembly helper/u, + ], + [ + 'handlers composed only inside an unreachable function', + ` import { assembleEffectBffRuntime } from '@fixture/shared-contracts/server/effect-bff-runtime'; import { Layer } from '@modern-js/plugin-bff/effect-edge'; import { fixtureApi } from '../shared/api.ts'; @@ -1097,11 +1099,12 @@ void test('static API validation proves the imported helper call topology', () = } const handlers = Layer.empty; export default assembleEffectBffRuntime({ api: fixtureApi, handlers: handlers }); - `) ?? '', - /explicitly composed Layer/u, - ); - assert.match( - strictEffectRuntimeTopologyViolation(` + `, + /explicitly composed Layer/u, + ], + [ + 'runtime assembled behind an unreachable branch', + ` import { assembleEffectBffRuntime } from '@fixture/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder, Layer } from '@modern-js/plugin-bff/effect-edge'; import { fixtureApi } from '../shared/api.ts'; @@ -1117,29 +1120,32 @@ void test('static API validation proves the imported helper call topology', () = }; const apiRuntime = makeRuntime(); export default apiRuntime; - `) ?? '', - /return or export the assembled strict Effect BFF runtime/u, - ); - assert.match( - strictEffectRuntimeTopologyViolation(` + `, + /return or export the assembled strict Effect BFF runtime/u, + ], + [ + 'defineEffectBff with an empty layer', + ` import { defineEffectBff, Layer } from '@modern-js/plugin-bff/effect-edge'; import { fixtureApi } from '../shared/api.ts'; const fixtureLayer = Layer.empty; export default defineEffectBff({ api: fixtureApi, layer: fixtureLayer }); - `) ?? '', - /server-only shared Effect BFF assembly helper/u, - ); - assert.match( - strictEffectRuntimeTopologyViolation(` + `, + /server-only shared Effect BFF assembly helper/u, + ], + [ + 'defineEffectBff layer piped to an empty layer', + ` import { defineEffectBff, HttpApiBuilder, Layer } from '@modern-js/plugin-bff/effect-edge'; import { fixtureApi } from '../shared/api.ts'; const fixtureLayer = HttpApiBuilder.layer(fixtureApi).pipe(() => Layer.empty); defineEffectBff({ api: fixtureApi, layer: fixtureLayer }); - `) ?? '', - /server-only shared Effect BFF assembly helper/u, - ); - assert.match( - strictEffectRuntimeTopologyViolation(` + `, + /server-only shared Effect BFF assembly helper/u, + ], + [ + 'defineEffectBff layer piped past its provided handlers', + ` import { defineEffectBff, HttpApiBuilder, Layer } from '@modern-js/plugin-bff/effect-edge'; import { fixtureApi } from '../shared/api.ts'; const fixtureLayer = HttpApiBuilder.layer(fixtureApi).pipe( @@ -1147,66 +1153,73 @@ void test('static API validation proves the imported helper call topology', () = () => Layer.empty, ); defineEffectBff({ api: fixtureApi, layer: fixtureLayer }); - `) ?? '', - /server-only shared Effect BFF assembly helper/u, - ); - assert.match( - strictEffectRuntimeTopologyViolation(` + `, + /server-only shared Effect BFF assembly helper/u, + ], + [ + 'locally defined defineEffectBff', + ` import { fixtureApi } from '../shared/api.ts'; const defineEffectBff = () => undefined; defineEffectBff({ api: fixtureApi, layer: fakeLayer }); - `) ?? '', - /server-only shared Effect BFF assembly helper/u, - ); - assert.match( - strictEffectRuntimeTopologyViolation(` + `, + /server-only shared Effect BFF assembly helper/u, + ], + [ + 'defineEffectBff call inside a string literal', + ` import { fixtureApi } from '../shared/api.ts'; const decoy = "defineEffectBff({ api: fixtureApi, layer: fakeLayer })"; - `) ?? '', - /server-only shared Effect BFF assembly helper/u, - ); - assert.match( - strictEffectRuntimeTopologyViolation(` + `, + /server-only shared Effect BFF assembly helper/u, + ], + [ + 'locally defined assembleEffectBffRuntime', + ` import { fixtureApi } from '../shared/api.ts'; const unrelated = Layer.mergeAll(groupLayer); const assembleEffectBffRuntime = () => undefined; assembleEffectBffRuntime({ api: fixtureApi, handlers: unrelated }); - `) ?? '', - /server-only shared Effect BFF assembly helper/u, - ); - assert.match( - strictEffectRuntimeTopologyViolation(` + `, + /server-only shared Effect BFF assembly helper/u, + ], + [ + 'assembly of a foreign API binding', + ` import { assembleEffectBffRuntime } from '@fixture/shared-contracts/server/effect-bff-runtime'; import { Layer } from '@modern-js/plugin-bff/effect-edge'; import { fixtureApi } from '../shared/api.ts'; const unrelated = Layer.mergeAll(groupLayer); assembleEffectBffRuntime({ api: otherApi, handlers: unrelated }); - `) ?? '', - /API imported from \.\.\/shared\/api\.ts/u, - ); - assert.match( - strictEffectRuntimeTopologyViolation(` + `, + /API imported from \.\.\/shared\/api\.ts/u, + ], + [ + 'handlers aliased from an uncomposed binding', + ` import { assembleEffectBffRuntime } from '@fixture/shared-contracts/server/effect-bff-runtime'; import { Layer } from '@modern-js/plugin-bff/effect-edge'; import { fixtureApi } from '../shared/api.ts'; const unrelated = Layer.mergeAll(groupLayer); const handlers = unrelated; assembleEffectBffRuntime({ api: fixtureApi, handlers: handlers }); - `) ?? '', - /explicitly composed Layer/u, - ); - assert.match( - strictEffectRuntimeTopologyViolation(` + `, + /explicitly composed Layer/u, + ], + [ + 'handlers piped to an empty layer', + ` import { assembleEffectBffRuntime } from '@fixture/shared-contracts/server/effect-bff-runtime'; import { Layer } from '@modern-js/plugin-bff/effect-edge'; import { fixtureApi } from '../shared/api.ts'; const handlers = Layer.mergeAll(groupLayer).pipe(() => Layer.empty); assembleEffectBffRuntime({ api: fixtureApi, handlers: handlers }); - `) ?? '', - /explicitly composed Layer/u, - ); - assert.match( - strictEffectRuntimeTopologyViolation(` + `, + /explicitly composed Layer/u, + ], + [ + 'transport piped to an empty layer', + ` import { assembleEffectBffRuntime } from '@fixture/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder, Layer } from '@modern-js/plugin-bff/effect-edge'; import { fixtureApi } from '../shared/api.ts'; @@ -1218,21 +1231,23 @@ void test('static API validation proves the imported helper call topology', () = const handlers = Layer.mergeAll(groupLayer); const transport = Layer.mergeAll(transportLayer).pipe(() => Layer.empty); assembleEffectBffRuntime({ api: fixtureApi, handlers: handlers, transport: transport }); - `) ?? '', - /transport/u, - ); - assert.match( - strictEffectRuntimeTopologyViolation(` + `, + /transport/u, + ], + [ + 'handlers built by an unknown Layer member', + ` import { assembleEffectBffRuntime } from '@fixture/shared-contracts/server/effect-bff-runtime'; import { Layer } from '@modern-js/plugin-bff/effect-edge'; import { fixtureApi } from '../shared/api.ts'; const handlers = Layer.thisDoesNotExist(groupLayer); assembleEffectBffRuntime({ api: fixtureApi, handlers: handlers }); - `) ?? '', - /explicitly composed Layer/u, - ); - assert.match( - strictEffectRuntimeTopologyViolation(` + `, + /explicitly composed Layer/u, + ], + [ + 'assembly helper shadowed by a function parameter', + ` import { assembleEffectBffRuntime } from '@fixture/shared-contracts/server/effect-bff-runtime'; import { Layer } from '@modern-js/plugin-bff/effect-edge'; import { fixtureApi } from '../shared/api.ts'; @@ -1240,11 +1255,12 @@ void test('static API validation proves the imported helper call topology', () = function fake(assembleEffectBffRuntime) { return assembleEffectBffRuntime({ api: fixtureApi, handlers: handlers }); } - `) ?? '', - /unshadowed/u, - ); - assert.match( - strictEffectRuntimeTopologyViolation(` + `, + /unshadowed/u, + ], + [ + 'assembly helper shadowed by a block destructuring', + ` import { assembleEffectBffRuntime } from '@fixture/shared-contracts/server/effect-bff-runtime'; import { Layer } from '@modern-js/plugin-bff/effect-edge'; import { fixtureApi } from '../shared/api.ts'; @@ -1253,11 +1269,12 @@ void test('static API validation proves the imported helper call topology', () = const handlers = Layer.mergeAll(groupLayer); assembleEffectBffRuntime({ api: fixtureApi, handlers: handlers }); } - `) ?? '', - /unshadowed/u, - ); - assert.match( - strictEffectRuntimeTopologyViolation(` + `, + /unshadowed/u, + ], + [ + 'Layer shadowed by a catch binding', + ` import { assembleEffectBffRuntime } from '@fixture/shared-contracts/server/effect-bff-runtime'; import { Layer } from '@modern-js/plugin-bff/effect-edge'; import { fixtureApi } from '../shared/api.ts'; @@ -1267,11 +1284,12 @@ void test('static API validation proves the imported helper call topology', () = const handlers = Layer.mergeAll(groupLayer); assembleEffectBffRuntime({ api: fixtureApi, handlers: handlers }); } - `) ?? '', - /unshadowed/u, - ); - assert.match( - strictEffectRuntimeTopologyViolation(` + `, + /unshadowed/u, + ], + [ + 'Layer shadowed by an object method parameter', + ` import { assembleEffectBffRuntime } from '@fixture/shared-contracts/server/effect-bff-runtime'; import { Layer } from '@modern-js/plugin-bff/effect-edge'; import { fixtureApi } from '../shared/api.ts'; @@ -1281,11 +1299,12 @@ void test('static API validation proves the imported helper call topology', () = return assembleEffectBffRuntime({ api: fixtureApi, handlers: handlers }); }, }; - `) ?? '', - /unshadowed/u, - ); - assert.match( - strictEffectRuntimeTopologyViolation(` + `, + /unshadowed/u, + ], + [ + 'Layer shadowed by a function parameter', + ` import { assembleEffectBffRuntime } from '@fixture/shared-contracts/server/effect-bff-runtime'; import { Layer } from '@modern-js/plugin-bff/effect-edge'; import { fixtureApi } from '../shared/api.ts'; @@ -1293,11 +1312,12 @@ void test('static API validation proves the imported helper call topology', () = const handlers = Layer.mergeAll(groupLayer); return assembleEffectBffRuntime({ api: fixtureApi, handlers: handlers }); } - `) ?? '', - /unshadowed/u, - ); - assert.match( - strictEffectRuntimeTopologyViolation(` + `, + /unshadowed/u, + ], + [ + 'imports declared only inside a template literal', + ` import { Layer } from '@modern-js/plugin-bff/effect-edge'; const fakeImport = \` import { assembleEffectBffRuntime } from '@fixture/shared-contracts/server/effect-bff-runtime'; @@ -1307,21 +1327,51 @@ void test('static API validation proves the imported helper call topology', () = const handlers = Layer.mergeAll(groupLayer); const assembleEffectBffRuntime = (input) => input; assembleEffectBffRuntime({ api: fixtureApi, handlers: handlers }); - `) ?? '', - /server-only shared Effect BFF assembly helper/u, - ); - assert.match( - strictEffectRuntimeTopologyViolation(` + `, + /server-only shared Effect BFF assembly helper/u, + ], + [ + 'handlers assigned from a string literal', + ` import { assembleEffectBffRuntime } from '@fixture/shared-contracts/server/effect-bff-runtime'; import { Layer } from '@modern-js/plugin-bff/effect-edge'; import { fixtureApi } from '../shared/api.ts'; const fakeHandlers = "Layer.mergeAll(groupLayer)"; assembleEffectBffRuntime({ api: fixtureApi, handlers: fakeHandlers }); - `) ?? '', - /explicitly composed Layer/u, - ); + `, + /explicitly composed Layer/u, + ], + ] as const) { + assert.match(strictEffectRuntimeTopologyViolation(source) ?? '', expected, label); + } }); +/** Every published rule format must report the same violations for the same candidate root. */ +const strictBoundaryReports = ( + module: typeof StrictEffectApiBoundaryRuleModuleSchema.Type, + filename: string, + source: string, +): readonly string[] => { + const messages: string[] = []; + module + .createStrictEffectApiBoundariesRule() + .create({ + filename, + getSourceCode: () => ({ getText: () => source, text: source }), + report: ({ message }) => { + messages.push(message); + }, + }) + .Program({}); + return messages; +}; +const reportsAssemblyViolation = (messages: readonly string[]): boolean => + messages.some((message) => + /server-only shared Effect BFF assembly helper|explicitly composed handler Layer/u.test( + message, + ), + ); + void test('published lint validators reject comment, string, and local strict-root spoofs', async (context) => { const codeToolsRoot = await realpath( path.join(workspaceRoot, 'node_modules/@modern-js/code-tools'), @@ -1603,21 +1653,8 @@ void test('published lint validators reject comment, string, and local strict-ro ); for (const { module, moduleFormat } of modules) { for (const source of invalidSources) { - const messages: string[] = []; - const listener = module.createStrictEffectApiBoundariesRule().create({ - filename: fixtureApiEntryPath, - getSourceCode: () => ({ getText: () => source, text: source }), - report: ({ message }) => { - messages.push(message); - }, - }); - listener.Program({}); assert.ok( - messages.some((message) => - /server-only shared Effect BFF assembly helper|explicitly composed handler Layer/u.test( - message, - ), - ), + reportsAssemblyViolation(strictBoundaryReports(module, fixtureApiEntryPath, source)), `${moduleFormat} accepted a fake strict runtime root`, ); } @@ -1629,57 +1666,26 @@ void test('published lint validators reject comment, string, and local strict-ro ); export default defineEffectBff({ api: fixtureApi, layer: fixtureLayer }); `; - const legacyMessages: string[] = []; - module - .createStrictEffectApiBoundariesRule() - .create({ - filename: fixtureApiEntryPath, - getSourceCode: () => ({ getText: () => legacySource, text: legacySource }), - report: ({ message }) => { - legacyMessages.push(message); - }, - }) - .Program({}); assert.equal( - legacyMessages.some((message) => - /server-only shared Effect BFF assembly helper|explicitly composed handler Layer/u.test( - message, - ), - ), + reportsAssemblyViolation(strictBoundaryReports(module, fixtureApiEntryPath, legacySource)), true, `${moduleFormat} accepted a legacy runtime root without the shared assembly helper`, ); - const generatedMessages: string[] = []; - module - .createStrictEffectApiBoundariesRule() - .create({ - filename: path.join(generatedRoot, apiIndexFile), - getSourceCode: () => ({ getText: () => generatedSource, text: generatedSource }), - report: ({ message }) => { - generatedMessages.push(message); - }, - }) - .Program({}); + const generatedMessages = strictBoundaryReports( + module, + path.join(generatedRoot, apiIndexFile), + generatedSource, + ); assert.equal( - generatedMessages.some((message) => - /server-only shared Effect BFF assembly helper|explicitly composed handler Layer/u.test( - message, - ), - ), + reportsAssemblyViolation(generatedMessages), false, `${moduleFormat} rejected exact generated helper output: ${generatedMessages.join(' | ')}`, ); - const generatedRpcMessages: string[] = []; - module - .createStrictEffectApiBoundariesRule() - .create({ - filename: path.join(generatedRoot, apiIndexFile), - getSourceCode: () => ({ getText: () => generatedRpcSource, text: generatedRpcSource }), - report: ({ message }) => { - generatedRpcMessages.push(message); - }, - }) - .Program({}); + const generatedRpcMessages = strictBoundaryReports( + module, + path.join(generatedRoot, apiIndexFile), + generatedRpcSource, + ); assert.equal( generatedRpcMessages.some((message) => /server-only shared Effect BFF assembly helper|explicitly composed handler Layer|\.\.\/shared\/api\.ts/u.test( @@ -1690,23 +1696,9 @@ void test('published lint validators reject comment, string, and local strict-ro `${moduleFormat} rejected exact generated RPC output: ${generatedRpcMessages.join(' | ')}`, ); for (const source of [...validAdversarialStrictRuntimeSources, governedLayerAliasFixture]) { - const messages: string[] = []; - module - .createStrictEffectApiBoundariesRule() - .create({ - filename: fixtureApiEntryPath, - getSourceCode: () => ({ getText: () => source, text: source }), - report: ({ message }) => { - messages.push(message); - }, - }) - .Program({}); + const messages = strictBoundaryReports(module, fixtureApiEntryPath, source); assert.equal( - messages.some((message) => - /server-only shared Effect BFF assembly helper|explicitly composed handler Layer/u.test( - message, - ), - ), + reportsAssemblyViolation(messages), false, `${moduleFormat} rejected a valid strict runtime root: ${messages.join(' | ')}`, ); @@ -2423,13 +2415,22 @@ void test('all published scaffold formats generate the shared MicroVertical API ); }); -void test('all published scaffold formats emit the executable AST baseline validator', async (context) => { +/** + * Generator proofs compile real output, so each one gets its own scratch root inside the + * shared-contracts package and drops it when the owning test finishes. + */ +const makeProofRoot = async (context: TestContext, prefix: string): Promise => { const scratchRoot = path.join(workspaceRoot, 'packages/shared-contracts/.scratch'); await mkdir(scratchRoot, { recursive: true }); - const proofRoot = await mkdtemp(path.join(scratchRoot, 'generated-baseline-validator-proof-')); + const proofRoot = await mkdtemp(path.join(scratchRoot, `${prefix}-`)); context.after(async (): Promise => { await rm(proofRoot, { force: true, recursive: true }); }); + return proofRoot; +}; + +void test('all published scaffold formats emit the executable AST baseline validator', async (context) => { + const proofRoot = await makeProofRoot(context, 'generated-baseline-validator-proof'); const expectedHelper = await readFile( path.join(workspaceRoot, 'scripts/microvertical-api-baseline-boundary.mts'), 'utf-8', @@ -2758,21 +2759,19 @@ void test('two generated MicroVertical root contracts execute invariant readines await Promise.all([...handlers.values()].map(async (handler) => await handler.dispose())); } - assert.notEqual(readinessValues[0]?.marker.appId, readinessValues[1]?.marker.appId); - assert.equal('kind' in (readinessValues[0]?.marker ?? {}), false); - assert.equal('schemaVersion' in (readinessValues[0]?.marker ?? {}), false); - assert.deepEqual(readinessValues[0]?.checks, readinessValues[1]?.checks); - assert.equal(readinessValues[0]?.status, readinessValues[1]?.status); - assert.equal(readinessValues[0]?.versionSkew, readinessValues[1]?.versionSkew); + const [firstReadiness, secondReadiness] = readinessValues; + assert.ok(firstReadiness !== undefined); + assert.ok(secondReadiness !== undefined); + assert.notEqual(firstReadiness.marker.appId, secondReadiness.marker.appId); + assert.equal('kind' in firstReadiness.marker, false); + assert.equal('schemaVersion' in firstReadiness.marker, false); + assert.deepEqual(firstReadiness.checks, secondReadiness.checks); + assert.equal(firstReadiness.status, secondReadiness.status); + assert.equal(firstReadiness.versionSkew, secondReadiness.versionSkew); }); void test('generated shared-contracts baseline template is lint-clean and type-safe', async (context) => { - const scratchRoot = path.join(workspaceRoot, 'packages/shared-contracts/.scratch'); - await mkdir(scratchRoot, { recursive: true }); - const proofRoot = await mkdtemp(path.join(scratchRoot, 'generated-baseline-template-proof-')); - context.after(async (): Promise => { - await rm(proofRoot, { force: true, recursive: true }); - }); + const proofRoot = await makeProofRoot(context, 'generated-baseline-template-proof'); const templateSource = await readFile( path.join(generatorRoot, 'templates/packages/shared-contracts-index.ts'), 'utf-8', @@ -2920,37 +2919,12 @@ void test('repository checker respects custom readiness prefixes and diagnoses m } }); -// oxlint-disable-next-line complexity -- This table-driven adversarial test keeps every fail-closed mutation visible. expires: 2026-12-31. void test('static validation rejects a MicroVertical root contract without readiness baseline', async () => { const contract = await readFile( path.join(workspaceRoot, `verticals/${partyId}/shared/api.ts`), 'utf-8', ); assert.equal(microVerticalApiBaselineViolation(partyId, contract), undefined); - assert.match( - microVerticalApiBaselineViolation( - partyId, - contract.replace("HttpApiEndpoint.get('readiness'", "HttpApiEndpoint.get('health'"), - ) ?? '', - /exact readiness endpoint/u, - ); - assert.match( - microVerticalApiBaselineViolation( - partyId, - contract.replace('...MicroVerticalReadinessSchema.fields,', '...Schema.Unknown.fields,'), - ) ?? '', - /shared readiness schema/u, - ); - assert.match( - microVerticalApiBaselineViolation( - partyId, - contract.replace( - 'success: partyRegistryReadinessSchema', - 'success: Schema.String /* success: partyRegistryReadinessSchema */', - ), - ) ?? '', - /exact readiness endpoint/u, - ); const readinessEndpointDecoy = "HttpApiEndpoint.get('readiness', '/party-registry/readiness', { success: partyRegistryReadinessSchema })"; const foundationComposition = '.addHttpApi(partyRegistryFoundationApi)'; @@ -2959,105 +2933,106 @@ void test('static validation rejects a MicroVertical root contract without readi MicroVerticalReadinessSchema, createMicroVerticalOperationContext, } from '@app/shared-contracts';`; - assert.match( - microVerticalApiBaselineViolation( - partyId, + for (const [label, mutated, expected] of [ + [ + 'renamed readiness endpoint', + contract.replace("HttpApiEndpoint.get('readiness'", "HttpApiEndpoint.get('health'"), + /exact readiness endpoint/u, + ], + [ + 'foreign readiness schema fields', + contract.replace('...MicroVerticalReadinessSchema.fields,', '...Schema.Unknown.fields,'), + /shared readiness schema/u, + ], + [ + 'readiness success schema commented out', + contract.replace( + 'success: partyRegistryReadinessSchema', + 'success: Schema.String /* success: partyRegistryReadinessSchema */', + ), + /exact readiness endpoint/u, + ], + [ + 'baseline primitives imported from a copied package', contract.replace("from '@app/shared-contracts';", "from '@app/copied-contracts';"), - ) ?? '', - /import exact baseline primitives from the shared contracts package/u, - ); - assert.match( - microVerticalApiBaselineViolation( - partyId, + /import exact baseline primitives from the shared contracts package/u, + ], + [ + 'Effect API primitives imported from a foreign client', contract.replace( "from '@modern-js/plugin-bff/effect-client';", "from '@evil/fake-effect-client';", ), - ) ?? '', - /import exact Effect API primitives from the framework client package/u, - ); - assert.match( - microVerticalApiBaselineViolation( - partyId, + /import exact Effect API primitives from the framework client package/u, + ], + [ + 'baseline primitives redefined locally', contract.replace( sharedBaselineImport, `const MicroVerticalBuildMarkerSchema = Schema.Struct({ copied: Schema.String }); const MicroVerticalReadinessSchema = Schema.Struct({ copied: Schema.String }); const createMicroVerticalOperationContext = (value: Value): Value => value;`, ), - ) ?? '', - /import exact baseline primitives from the shared contracts package/u, - ); - assert.match( - microVerticalApiBaselineViolation( - partyId, + /import exact baseline primitives from the shared contracts package/u, + ], + [ + 'renamed readiness endpoint with a decoy API name', contract .replace("HttpApiEndpoint.get('readiness'", "HttpApiEndpoint.get('health'") .replace( "HttpApi.make('PartyRegistryFoundationApi')", `HttpApi.make("${readinessEndpointDecoy}")`, ), - ) ?? '', - /exact readiness endpoint/u, - ); - assert.match( - microVerticalApiBaselineViolation(partyId, contract.replace(foundationComposition, '')) ?? '', - /explicitly compose its readiness foundation API/u, - ); - assert.match( - microVerticalApiBaselineViolation( - partyId, + /exact readiness endpoint/u, + ], + [ + 'missing foundation API composition', + contract.replace(foundationComposition, ''), + /explicitly compose its readiness foundation API/u, + ], + [ + 'foundation composition discarded by a pipe', contract.replace( foundationComposition, `${foundationComposition} .pipe(() => HttpApi.make('DiscardedPartyRegistryApi'))`, ), - ) ?? '', - /explicitly compose its readiness foundation API/u, - ); - assert.match( - microVerticalApiBaselineViolation( - partyId, + /explicitly compose its readiness foundation API/u, + ], + [ + 'foundation endpoint discarded by a pipe', contract.replace( ` ), );`, ` ), ).pipe(() => HttpApi.make('DiscardedPartyRegistryFoundationApi'));`, ), - ) ?? '', - /directly compose its exact readiness endpoint/u, - ); - assert.match( - microVerticalApiBaselineViolation( - partyId, + /directly compose its exact readiness endpoint/u, + ], + [ + 'missing foundation composition with a decoy API name', contract .replace(foundationComposition, '') .replace( "HttpApi.make('PartyRegistryApi')", "HttpApi.make('.addHttpApi(partyRegistryFoundationApi)')", ), - ) ?? '', - /explicitly compose its readiness foundation API/u, - ); - assert.match( - microVerticalApiBaselineViolation( - partyId, + /explicitly compose its readiness foundation API/u, + ], + [ + 'renamed foundation API without composition', contract .replaceAll('partyRegistryFoundationApi', 'renamedFoundationApi') .replace('.addHttpApi(renamedFoundationApi)', ''), - ) ?? '', - /directly compose its exact readiness endpoint/u, - ); - assert.match( - microVerticalApiBaselineViolation( - partyId, + /directly compose its exact readiness endpoint/u, + ], + [ + 'hand-forked build marker fields', contract.replace('...MicroVerticalBuildMarkerSchema.fields,', 'build: Schema.String,'), - ) ?? '', - /shared build marker schema/u, - ); - assert.match( - microVerticalApiBaselineViolation( - partyId, + /shared build marker schema/u, + ], + [ + 'readiness schema built by a sequence expression', contract.replace( `export const partyRegistryReadinessSchema = Schema.Struct({ ...MicroVerticalReadinessSchema.fields, @@ -3068,12 +3043,10 @@ const createMicroVerticalOperationContext = (value: Value): Value => valu Schema.Struct({ marker: partyRegistryMarkerSchema, status: Schema.String }) );`, ), - ) ?? '', - /consume the shared readiness schema/u, - ); - assert.match( - microVerticalApiBaselineViolation( - partyId, + /consume the shared readiness schema/u, + ], + [ + 'readiness schema aliased to the shared schema', contract.replace( `export const partyRegistryReadinessSchema = Schema.Struct({ ...MicroVerticalReadinessSchema.fields, @@ -3081,12 +3054,10 @@ const createMicroVerticalOperationContext = (value: Value): Value => valu });`, 'export const partyRegistryReadinessSchema = MicroVerticalReadinessSchema;', ), - ) ?? '', - /consume the shared readiness schema/u, - ); - assert.match( - microVerticalApiBaselineViolation( - partyId, + /consume the shared readiness schema/u, + ], + [ + 'foundation composed inside a pipe callback', contract.replace( `export const partyRegistryApi = HttpApi.make('PartyRegistryApi') .addHttpApi(partyRegistryFoundationApi)`, @@ -3094,114 +3065,89 @@ const createMicroVerticalOperationContext = (value: Value): Value => valu (api) => (api.addHttpApi(partyRegistryFoundationApi), api), )`, ), - ) ?? '', - /explicitly compose its readiness foundation API/u, - ); - assert.match( - microVerticalApiBaselineViolation( - partyId, + /explicitly compose its readiness foundation API/u, + ], + [ + 'decoy API declaration shadowed by an uncomposed API', `${contract.replace( 'export const partyRegistryApi =', 'export const partyRegistryApiDecoy =', )}\nexport const partyRegistryApi = HttpApi.make('PartyRegistryApi');\n`, - ) ?? '', - /explicitly compose its readiness foundation API/u, - ); - assert.match( - microVerticalApiBaselineViolation( - partyId, + /explicitly compose its readiness foundation API/u, + ], + [ + 'decoy foundation API declaration without a readiness endpoint', `${contract.replace( 'export const partyRegistryFoundationApi =', 'export const partyRegistryFoundationApiDecoy =', )}\nexport const partyRegistryFoundationApi = HttpApi.make('PartyRegistryFoundationApi');\n`, - ) ?? '', - /exact readiness endpoint/u, - ); - assert.match( - microVerticalApiBaselineViolation( - partyId, + /exact readiness endpoint/u, + ], + [ + 'decoy contract declaration without path metadata', `${contract.replace( 'export const partyRegistryApiContract =', 'export const partyRegistryApiContractDecoy =', )}\nexport const partyRegistryApiContract = { ownerId: 'party-registry' };\n`, - ) ?? '', - /exact owner and API path metadata/u, - ); - - assert.match( - microVerticalApiBaselineViolation( - partyId, + /exact owner and API path metadata/u, + ], + [ + 'build marker overriding a shared field', contract.replace( '...MicroVerticalBuildMarkerSchema.fields,', '...MicroVerticalBuildMarkerSchema.fields,\n build: Schema.Number,', ), - ) ?? '', - /without overriding shared fields/u, - ); - assert.match( - microVerticalApiBaselineViolation( - partyId, + /without overriding shared fields/u, + ], + [ + 'foreign AppId schema', contract.replace( "const AppIdSchema = Schema.String.pipe(Schema.brand('AppId'));", 'const AppIdSchema = Schema.Number;', ), - ) ?? '', - /shared build marker schema/u, - ); - assert.match( - microVerticalApiBaselineViolation( - partyId, + /shared build marker schema/u, + ], + [ + 'readiness schema overriding a shared field', contract.replace( '...MicroVerticalReadinessSchema.fields,', '...MicroVerticalReadinessSchema.fields,\n status: Schema.String,', ), - ) ?? '', - /without overriding shared fields/u, - ); - assert.match( - microVerticalApiBaselineViolation( - partyId, + /without overriding shared fields/u, + ], + [ + 'renamed foundation group', contract.replace("HttpApiGroup.make('foundation')", "HttpApiGroup.make('not-foundation')"), - ) ?? '', - /exact readiness endpoint and foundation identity/u, - ); - assert.match( - microVerticalApiBaselineViolation( - partyId, + /exact readiness endpoint and foundation identity/u, + ], + [ + 'renamed root API', contract.replace("HttpApi.make('PartyRegistryApi')", "HttpApi.make('WrongApi')"), - ) ?? '', - /explicitly compose its readiness foundation API/u, - ); - assert.match( - microVerticalApiBaselineViolation( - partyId, + /explicitly compose its readiness foundation API/u, + ], + [ + 'renamed operation contexts', contract.replace( 'export const partyRegistryOperationContexts =', 'export const renamedOperationContexts =', ), - ) ?? '', - /construct every operation with the shared context constructor/u, - ); - assert.match( - microVerticalApiBaselineViolation( - partyId, + /construct every operation with the shared context constructor/u, + ], + [ + 'foreign operation id', contract.replace( "operationId: 'PartyRegistryApi:/reads/ares-lookup'", "operationId: 'WrongApi:unrelated'", ), - ) ?? '', - /construct every operation with the shared context constructor/u, - ); - assert.match( - microVerticalApiBaselineViolation( - partyId, + /construct every operation with the shared context constructor/u, + ], + [ + 'foreign route path', contract.replace("routePath: '/reads/ares-lookup'", "routePath: '/not-an-endpoint'"), - ) ?? '', - /construct every operation with the shared context constructor/u, - ); - assert.match( - microVerticalApiBaselineViolation( - partyId, + /construct every operation with the shared context constructor/u, + ], + [ + 'readiness context built without the shared constructor', `${contract.replace( `readiness: createMicroVerticalOperationContext({ method: 'GET', @@ -3220,20 +3166,27 @@ createMicroVerticalOperationContext({ routePath: '/party-registry/readiness', }); `, - ) ?? '', - /construct every operation with the shared context constructor/u, - ); - - for (const [label, mutated] of [ - ['missing apiPrefix', contract.replace(" apiPrefix: '/party-registry-api',\n", '')], + /construct every operation with the shared context constructor/u, + ], + [ + 'missing apiPrefix', + contract.replace(" apiPrefix: '/party-registry-api',\n", ''), + /exact owner and API path metadata/u, + ], [ 'missing basePath', contract.replace(" basePath: '/party-registry-api/party-registry',\n", ''), + /exact owner and API path metadata/u, + ], + [ + 'missing ownerId', + contract.replace(" ownerId: 'party-registry',\n", ''), + /exact owner and API path metadata/u, ], - ['missing ownerId', contract.replace(" ownerId: 'party-registry',\n", '')], [ 'wrong apiPrefix', contract.replace("apiPrefix: '/party-registry-api'", "apiPrefix: '/evil-api'"), + /exact owner and API path metadata/u, ], [ 'wrong basePath', @@ -3241,14 +3194,20 @@ createMicroVerticalOperationContext({ "basePath: '/party-registry-api/party-registry'", "basePath: '/party-registry-api/evil'", ), + /exact owner and API path metadata/u, + ], + [ + 'wrong ownerId', + contract.replace("ownerId: 'party-registry'", "ownerId: 'evil-owner'"), + /exact owner and API path metadata/u, ], - ['wrong ownerId', contract.replace("ownerId: 'party-registry'", "ownerId: 'evil-owner'")], [ 'wrong readinessPath', contract.replace( "readinessPath: '/party-registry-api/party-registry/readiness'", "readinessPath: '/evil-prefix/party-registry/readiness'", ), + /exact owner and API path metadata/u, ], [ 'coordinated topology drift', @@ -3262,6 +3221,7 @@ createMicroVerticalOperationContext({ "readinessPath: '/party-registry-api/party-registry/readiness'", "readinessPath: '/evil-api/party-registry/readiness'", ), + /exact owner and API path metadata/u, ], [ 'forbidden credential metadata', @@ -3269,6 +3229,7 @@ createMicroVerticalOperationContext({ partyReadinessMetadataLine, `${partyReadinessMetadataLine}\n credential: 'secret',`, ), + /exact owner and API path metadata/u, ], [ 'forbidden credential path metadata', @@ -3276,6 +3237,7 @@ createMicroVerticalOperationContext({ partyReadinessMetadataLine, `${partyReadinessMetadataLine}\n credentialPath: '/party-registry-api/party-registry/secret',`, ), + /exact owner and API path metadata/u, ], [ 'unknown path metadata', @@ -3283,6 +3245,7 @@ createMicroVerticalOperationContext({ partyReadinessMetadataLine, `${partyReadinessMetadataLine}\n unknownPath: '/party-registry-api/party-registry/unknown',`, ), + /exact owner and API path metadata/u, ], [ 'spread metadata', @@ -3290,13 +3253,10 @@ createMicroVerticalOperationContext({ 'export const partyRegistryApiContract = {', 'const copiedMetadata = {};\nexport const partyRegistryApiContract = {\n ...copiedMetadata,', ), + /exact owner and API path metadata/u, ], ] as const) { - assert.match( - microVerticalApiBaselineViolation(partyId, mutated) ?? '', - /exact owner and API path metadata/u, - label, - ); + assert.match(microVerticalApiBaselineViolation(partyId, mutated) ?? '', expected, label); } }); diff --git a/app/scripts/tests/boundary-source-structure.test.mts b/app/scripts/tests/boundary-source-structure.test.mts new file mode 100644 index 000000000..234552f79 --- /dev/null +++ b/app/scripts/tests/boundary-source-structure.test.mts @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { SyntaxKind } from '@typescript/native/unstable/ast'; +import { + DelimiterDepth, + matchingDelimiter, + separatedSource, + toCamelCase, + topLevelSeparators, +} from '../boundary-source-structure.mts'; +import { + hasGeneratedModuleApiContract, + hasGeneratedProviderApiContract, + tokenizeGovernedClient, +} from '../generated-module-api-boundary.mts'; +import { maskNonCode } from '../scaffolding/shared.mts'; + +const endpointApi = (endpointPath: string, extra = '') => + `export const StockApi = HttpApi.make('StockApi').add(HttpApiGroup.make('stock').add(HttpApiEndpoint.post('execute', '${endpointPath}'), ${extra}));`; + +const stockReadPath = '/reads/stock'; + +void test('balanced traversal ignores nested separators but preserves source offsets', () => { + const source = "call({ nested: [1, 2], literal: ',);' }, /[,)]/, () => [3, 4]); next();"; + const structure = maskNonCode(source); + const close = matchingDelimiter(structure, source.indexOf('('), '(', ')'); + assert.equal(close, source.indexOf('; next') - 1); + // The semicolon in the string must not terminate the statement. + assert.deepEqual(topLevelSeparators(structure, ';'), [ + source.indexOf('; next'), + source.length - 1, + ]); + assert.deepEqual( + separatedSource(source, topLevelSeparators(structure, ',', 5, close), 5, close), + ["{ nested: [1, 2], literal: ',);' }", '/[,)]/', '() => [3, 4]'], + ); +}); + +void test('generic parameter commas and arrow returns remain separate lexical concerns', () => { + const source = 'value: Map number>, next: number'; + assert.deepEqual(topLevelSeparators(source, ',', 0, source.length, true), [ + source.indexOf(', next'), + ]); + assert.deepEqual(topLevelSeparators('value < maximum; next > minimum;', ';'), [15, 31]); + const depth = new DelimiterDepth(); + depth.update(']'); + assert.equal(depth.hasUnmatchedClose(), true); + assert.equal(depth.isTopLevel(), false); + assert.equal(toCamelCase('Stock-list'), 'stockList'); +}); + +void test('token rescan retains nested template expressions and excludes regex punctuation', () => { + const source = `const result = \`outer \${ { nested: \`inner \${value}\` } }\`; const pattern = /[},;]/;`; + const kinds = tokenizeGovernedClient(source).map(({ kind }) => kind); + assert.equal(kinds.filter((kind) => kind === SyntaxKind.TemplateTail).length, 2); + assert.equal(kinds.filter((kind) => kind === SyntaxKind.RegularExpressionLiteral).length, 1); + assert.equal(kinds.filter((kind) => kind === SyntaxKind.SemicolonToken).length, 2); +}); + +void test('endpoint grammar shares only topology, preserving owner path and endpoint identity', () => { + assert.equal( + hasGeneratedModuleApiContract(endpointApi(stockReadPath), 'StockApi', 'stock', 'stock'), + true, + ); + assert.equal( + hasGeneratedProviderApiContract( + endpointApi('/inventory.stock/reports/stock'), + 'StockApi', + 'inventory.stock', + 'stock', + 'report', + ), + true, + ); + assert.equal( + hasGeneratedProviderApiContract( + endpointApi(stockReadPath), + 'StockApi', + 'inventory.stock', + 'stock', + 'report', + ), + false, + ); + assert.equal( + hasGeneratedModuleApiContract( + endpointApi(stockReadPath, 'UnrelatedEndpoint'), + 'StockApi', + 'stock', + 'stock', + ), + false, + ); + assert.equal( + hasGeneratedModuleApiContract( + endpointApi(stockReadPath).replace("'execute'", "'bypass'"), + 'StockApi', + 'stock', + 'stock', + ), + false, + ); +}); diff --git a/app/scripts/tests/module-entrypoint-boundaries.test.mts b/app/scripts/tests/module-entrypoint-boundaries.test.mts index 830d685f8..22b25eaa3 100644 --- a/app/scripts/tests/module-entrypoint-boundaries.test.mts +++ b/app/scripts/tests/module-entrypoint-boundaries.test.mts @@ -752,36 +752,18 @@ const decoy = { 'stock-list': () => import('./src/api/stock-list-client.ts') };` const gatewayPath = 'verticals/inventory-stock/src/api/action-gateway.ts'; const gateway = await readFile(path.join(root, gatewayPath), 'utf-8'); - await write( - root, - gatewayPath, - gateway.replace( - 'export const operationGateway = makeOperationGateway();', - "namespace Decoy { export const operationGateway = makeOperationGateway(); }\nconst spoof = 'export const operationGateway = actionGateway';", - ), - ); - await assert.rejects( - checkModuleEntrypointBoundaries(root), - /module APIs require an approved Codesmith generator/u, - ); - await write(root, gatewayPath, gateway); - await write( - root, - gatewayPath, - gateway.replace( - 'export const operationGateway = makeOperationGateway();', - "export const operationGateway = { invoke: (attempt) => attempt('Bearer cached') };", - ), - ); - await assert.rejects( - checkModuleEntrypointBoundaries(root), - /module APIs require an approved Codesmith generator/u, - ); - await write(root, gatewayPath, gateway); await assertRejectedSources( root, gatewayPath, [ + gateway.replace( + 'export const operationGateway = makeOperationGateway();', + "namespace Decoy { export const operationGateway = makeOperationGateway(); }\nconst spoof = 'export const operationGateway = actionGateway';", + ), + gateway.replace( + 'export const operationGateway = makeOperationGateway();', + "export const operationGateway = { invoke: (attempt) => attempt('Bearer cached') };", + ), gateway.replace( SHARED_GATEWAY_FACTORY, "makeSharedOperationGateway('wrong-audience', acquire)", @@ -858,39 +840,24 @@ test('rejects generated governed clients that bypass the shared client runtime s await writeGovernedModuleApi(root); const readPath = STOCK_LIST_READ_FILE; const validRead = await readFile(path.join(root, readPath), 'utf-8'); - await write( + await assertRejectedSources( root, readPath, - validRead.replace( - 'policies: []', - "policies: [], ...{ entrypoint: attackerEntrypoint, legalEntityScope: 'required' }", - ), - ); - await assert.rejects( - checkModuleEntrypointBoundaries(root), + [ + validRead.replace( + 'policies: []', + "policies: [], ...{ entrypoint: attackerEntrypoint, legalEntityScope: 'required' }", + ), + validRead.replace( + "owningModuleKey: 'inventory.stock'", + "owningModuleKey: 'attacker.module'", + ), + validRead.replace("readKey: 'inventory.stock.api.stock-list'", "readKey: 'attacker.read'"), + validRead.replace(SCHEMA_VERSION_ONE, "schemaVersion: '2'"), + ], /module APIs require an approved Codesmith generator/u, ); await write(root, readPath, validRead); - const assertModuleReadDriftRejected = async ( - expected: string, - replacement: string, - ): Promise => { - await write(root, readPath, validRead.replace(expected, replacement)); - await assert.rejects( - checkModuleEntrypointBoundaries(root), - /module APIs require an approved Codesmith generator/u, - ); - }; - await assertModuleReadDriftRejected( - "owningModuleKey: 'inventory.stock'", - "owningModuleKey: 'attacker.module'", - ); - await assertModuleReadDriftRejected( - "readKey: 'inventory.stock.api.stock-list'", - "readKey: 'attacker.read'", - ); - await assertModuleReadDriftRejected(SCHEMA_VERSION_ONE, "schemaVersion: '2'"); - await write(root, readPath, validRead); const clientPath = 'verticals/inventory-stock/src/api/stock-list-client.ts'; const validClient = await readFile(path.join(root, clientPath), 'utf-8'); const deadCanonicalHelperClient = validClient @@ -1416,39 +1383,27 @@ const decoyGroup = HttpApiGroup.make('inventoryItemsSearch').add(HttpApiEndpoint ), ); await checkModuleEntrypointBoundaries(root); - await write( + await assertRejectedSources( root, providerSourcePath, - validProviderSource.replace( - "{ kind: 'context_permission', permission: 'module.access' }", - "{ kind: 'public' }", - ), - ); - await assert.rejects( - checkModuleEntrypointBoundaries(root), + [ + validProviderSource.replace( + "{ kind: 'context_permission', permission: 'module.access' }", + "{ kind: 'public' }", + ), + validProviderSource.replace( + "owningModuleKey: 'inventory.stock'", + "owningModuleKey: 'attacker.module'", + ), + validProviderSource.replace( + "readKey: 'inventory.stock.search.inventory-items'", + "readKey: 'attacker.read'", + ), + validProviderSource.replace(SCHEMA_VERSION_ONE, "schemaVersion: '2'"), + ], /generated search and report clients require the shared client runtime/u, ); await write(root, providerSourcePath, validProviderSource); - const assertProviderReadDriftRejected = async ( - expected: string, - replacement: string, - ): Promise => { - await write(root, providerSourcePath, validProviderSource.replace(expected, replacement)); - await assert.rejects( - checkModuleEntrypointBoundaries(root), - /generated search and report clients require the shared client runtime/u, - ); - }; - await assertProviderReadDriftRejected( - "owningModuleKey: 'inventory.stock'", - "owningModuleKey: 'attacker.module'", - ); - await assertProviderReadDriftRejected( - "readKey: 'inventory.stock.search.inventory-items'", - "readKey: 'attacker.read'", - ); - await assertProviderReadDriftRejected(SCHEMA_VERSION_ONE, "schemaVersion: '2'"); - await write(root, providerSourcePath, validProviderSource); await write( root, providerSourcePath, diff --git a/app/scripts/tests/plan-deployment-impact.test.mts b/app/scripts/tests/plan-deployment-impact.test.mts index 485a4ca76..28db02b04 100644 --- a/app/scripts/tests/plan-deployment-impact.test.mts +++ b/app/scripts/tests/plan-deployment-impact.test.mts @@ -271,21 +271,6 @@ for (const changedPath of [ }); } -for (const changedPath of [ - 'scripts/postgres/bootstrap-spicedb-database.mts', - 'packages/core-runtime/src/install/spicedb-database-config.ts', -]) { - test(`includes the migrator, SpiceDB, and every consumer for SpiceDB database bootstrap change ${changedPath}`, async () => { - await withFixture(async (root) => { - const plan = await planDeploymentImpact({ changedPaths: [changedPath], rootDirectory: root }); - assert.deepEqual( - plan.phases.map((phase) => phase.id), - ['migrator', 'spicedb', 'contacts', SHELL_ID], - ); - }); - }); -} - test('expands shared-package changes to every consumer in dependency order', async () => { await withFixture(async (root) => { const plan = await planDeploymentImpact({ @@ -326,6 +311,8 @@ test('orders SpiceDB before all consumers for authorization runtime changes', as }); for (const changedPath of [ + 'scripts/postgres/bootstrap-spicedb-database.mts', + 'packages/core-runtime/src/install/spicedb-database-config.ts', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', '.mise.toml', diff --git a/app/scripts/tests/quality-audit-gate.test.mts b/app/scripts/tests/quality-audit-gate.test.mts new file mode 100644 index 000000000..c98edffc3 --- /dev/null +++ b/app/scripts/tests/quality-audit-gate.test.mts @@ -0,0 +1,272 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Effect, Schema } from 'effect'; +import { runEffectTestPromise } from '../../packages/core-runtime/src/testing/effect-runtime.ts'; +import { validateQualityAuditSummary } from '../quality-audit-gate.mts'; +import { validateReport } from '../quality-audit.mts'; + +const FALLOW_FILES = 'fallow-files'; +const FALLOW_SIMILARITY = 'fallow-similarity'; +const FALLOW_HEALTH = 'fallow-health'; +interface FixtureCoverage { + analyzedFiles?: number; + analyzedFunctions?: number; + controlFlowFindings?: number; + discoveredFiles?: number; + findingCounts?: Schema.Json; + modeledUsages?: number; + nativeFindingCounts?: Schema.Json; + processed?: number; + tokenEligibleFiles: number; + total?: number; + uiOnlyFindings?: number; + weightedFindings?: number; + workspaces?: string[]; +} +const names = ['knip', 'jscpd', FALLOW_FILES, 'fallow-clones', FALLOW_SIMILARITY, FALLOW_HEALTH]; +const clean = () => ({ + results: names.map((name) => { + const coverage: FixtureCoverage = { tokenEligibleFiles: 2 }; + if (name === 'knip') { + Object.assign(coverage, { + findingCounts: { exports: 0, unlisted: 0 }, + modeledUsages: 0, + nativeFindingCounts: { exports: 0, unlisted: 0 }, + processed: 2, + total: 2, + workspaces: ['.'], + }); + } + if (name === FALLOW_FILES) { + Object.assign(coverage, { discoveredFiles: 2 }); + } + if (name === FALLOW_HEALTH) { + Object.assign(coverage, { + analyzedFiles: 2, + analyzedFunctions: 2, + controlFlowFindings: 0, + uiOnlyFindings: 0, + weightedFindings: 0, + }); + } + return { + advisory: name === FALLOW_SIMILARITY, + coverage, + diagnostic: '', + files: 2, + findings: 0, + name, + status: 'reported', + }; + }), + status: 'reported', +}); +const encode = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const validate = async (summary: ReturnType | Schema.Json) => + await runEffectTestPromise(encode(summary).pipe(Effect.flatMap(validateQualityAuditSummary))); + +await test('complete clean summary succeeds; semantic and UI-only findings remain advisory', async () => { + await validate(clean()); + const summary = clean(); + const semantic = summary.results.find(({ name }) => name === FALLOW_SIMILARITY); + const health = summary.results.find(({ name }) => name === FALLOW_HEALTH); + assert.ok(semantic && health); + semantic.findings = 19; + health.coverage.uiOnlyFindings = 2; + health.coverage.weightedFindings = 2; + await validate(summary); +}); + +const positiveReports = [ + [ + 'knip', + `${JSON.stringify({ issues: [] })}\n${JSON.stringify({ coverage: { processed: 2, total: 2 }, findingCounts: { exports: 1, unlisted: 0 }, workspaces: ['.'] })}`, + ], + [ + 'jscpd', + JSON.stringify({ + duplicates: [ + { + firstFile: { name: 'a.ts', start: 1 }, + lines: 12, + secondFile: { name: 'b.ts', start: 1 }, + tokens: 110, + }, + ], + statistics: { total: { clones: 1, sources: 2 } }, + }), + ], + [ + 'fallow-clones', + JSON.stringify({ + clone_groups: [ + { + fingerprint: 'same-body', + instances: [ + { end_line: 12, file: 'a.ts', start_line: 1 }, + { end_line: 12, file: 'b.ts', start_line: 1 }, + ], + line_count: 12, + token_count: 110, + }, + ], + kind: 'dupes', + schema_version: 9, + stats: { clone_groups: 1, total_files: 2 }, + version: '3.22.0', + }), + ], + [ + FALLOW_HEALTH, + JSON.stringify({ + findings: [ + { + cognitive: 0, + contributions: [{ kind: 'if', metric: 'cyclomatic', weight: 10 }], + cyclomatic: 11, + line: 1, + name: 'branchHeavy', + path: 'a.ts', + }, + ], + kind: 'health', + schema_version: 11, + summary: { + files_analyzed: 2, + functions_above_threshold: 1, + functions_analyzed: 2, + max_cognitive_threshold: 15, + max_crap_threshold: 0, + max_cyclomatic_threshold: 10, + }, + version: '3.22.0', + }), + ], +] as const; +await Promise.all( + positiveReports.map(async ([name, source]) => { + await test(`${name} real-positive analyzer report rejects through the normalized gate`, async () => { + const normalized = await runEffectTestPromise(validateReport(name, source)); + const summary = clean(); + const result = summary.results.find((entry) => entry.name === name); + assert.ok(result); + Object.assign(result, normalized); + if (name === 'knip') { + Object.assign(result.coverage, { + modeledUsages: 0, + nativeFindingCounts: result.coverage.findingCounts, + }); + } + await assert.rejects( + validate(summary), + new RegExp(`Quality audit gate failed: ${name}=1`, 'u'), + ); + }); + }), +); + +await test('calibrated modeled consumers do not reintroduce native Knip findings', async () => { + const summary = clean(); + const knip = summary.results.find(({ name }) => name === 'knip'); + assert.ok(knip); + knip.coverage.modeledUsages = 4; + knip.coverage.nativeFindingCounts = { exports: 0, unlisted: 4 }; + await validate(summary); + knip.coverage.modeledUsages = 5; + await assert.rejects(validate(summary), /inconsistent/u); +}); + +await test('partial, duplicate, unknown, failed and empty reports fail closed', async () => { + await assert.rejects(validate({}), /Malformed/u); + await assert.rejects(validate({ ...clean(), status: 'error' }), /Malformed/u); + await assert.rejects(validate({ results: [], status: 'reported' }), /six unique/u); + await Promise.all( + names.map(async (name) => { + const summary = clean(); + await assert.rejects( + validate({ ...summary, results: summary.results.filter((entry) => entry.name !== name) }), + /six unique/u, + ); + const result = summary.results.find((entry) => entry.name === name); + assert.ok(result); + await assert.rejects( + validate({ ...summary, results: [...summary.results, result] }), + /six unique/u, + ); + result.status = 'error'; + await assert.rejects(validate(summary), /Malformed/u); + result.status = 'reported'; + result.files = 0; + await assert.rejects(validate(summary), /Malformed/u); + }), + ); + const summary = clean(); + const [first] = summary.results; + first.name = 'unknown'; + await assert.rejects(validate(summary), /Malformed/u); + await Promise.all( + ['', '{broken', 'null', '{"status":"reported","results":{}}'].map(async (source) => { + await assert.rejects(runEffectTestPromise(validateQualityAuditSummary(source)), /Malformed/u); + }), + ); +}); + +await test('invalid counts, flags, diagnostics and inconsistent coverage cannot imply clean', async () => { + await Promise.all( + names.map(async (name) => { + await Promise.all( + [-1, 0.5, null, '0', undefined, Number.NaN, Number.POSITIVE_INFINITY].map( + async (invalid) => { + const summary = clean(); + const result = summary.results.find((entry) => entry.name === name); + assert.ok(result); + Object.assign(result, { findings: invalid }); + await assert.rejects(validate(summary), /Malformed/u); + }, + ), + ); + }), + ); + await Promise.all( + [ + { advisory: true }, + { diagnostic: 'analysis failed' }, + { coverage: {} }, + { coverage: { tokenEligibleFiles: -1 } }, + { coverage: { tokenEligibleFiles: 1 } }, + ].map(async (patch) => { + const summary = clean(); + const result = summary.results.find(({ name }) => name === 'jscpd'); + assert.ok(result); + Object.assign(result, patch); + await assert.rejects(validate(summary), /Malformed|inconsistent/u); + }), + ); +}); + +await test('six-result duplicate and inconsistent normalization fail closed', async () => { + const duplicate = clean(); + const [, repeated] = duplicate.results; + duplicate.results[0] = repeated; + await assert.rejects(validate(duplicate), /six unique/u); + await Promise.all( + [ + ['knip', { findingCounts: {}, nativeFindingCounts: {} }], + ['knip', { processed: 1 }], + ['knip', { total: 3 }], + ['knip', { workspaces: [] }], + ['knip', { findingCounts: { exports: -1, unlisted: 0 } }], + [FALLOW_HEALTH, { controlFlowFindings: 1 }], + [FALLOW_HEALTH, { analyzedFunctions: 0 }], + [FALLOW_HEALTH, { weightedFindings: 1 }], + [FALLOW_HEALTH, { uiOnlyFindings: -1 }], + [FALLOW_FILES, { discoveredFiles: 0 }], + ].map(async ([name, coverage]) => { + const summary = clean(); + const result = summary.results.find((entry) => entry.name === name); + assert.ok(result); + Object.assign(result.coverage, coverage); + await assert.rejects(validate(summary), /Malformed|inconsistent/u); + }), + ); +}); diff --git a/app/scripts/tests/quality-audit-model.test.mts b/app/scripts/tests/quality-audit-model.test.mts index 0a0ffc0f5..54ca1bab0 100644 --- a/app/scripts/tests/quality-audit-model.test.mts +++ b/app/scripts/tests/quality-audit-model.test.mts @@ -116,10 +116,11 @@ const fixture = async () => { root, 'verticals/remote/package.json', await stringify({ - dependencies: { effect: '4.0.0-beta.107' }, + dependencies: { 'drizzle-orm': '1.0.0-rc.4', effect: '4.0.0-beta.107' }, name: 'remote-controls', private: true, type: 'module', + 'zephyr:dependencies': { composed: 'drizzle-orm@workspace:*' }, }), ); write( @@ -258,6 +259,10 @@ await test('real pinned Knip models exact consumers and preserves neighboring fi assert.ok(findings('unlisted').includes('verticals/remote/src/index.ts#misspelledChild')); assert.ok(findings('unlisted').includes('verticals/remote/src/index.ts#declaredRemote')); assert.ok(findings('dependencies').includes('verticals/remote/package.json#effect')); + assert.ok( + !findings('dependencies').includes('verticals/remote/package.json#drizzle-orm'), + 'a zephyr:dependencies composition reference must count as a dependency consumer', + ); assert.deepEqual(model.config.workspaces['.']?.ignoreDependencies, []); assert.ok(findings('unlisted').includes('src/index.ts#shadowedRemote')); assert.ok(findings('unlisted').includes('src/direct.ts#@rspack/core')); diff --git a/app/scripts/ultramodern-api-boundary-rules.mts b/app/scripts/ultramodern-api-boundary-rules.mts index 3d95b8f19..79c8c0880 100644 --- a/app/scripts/ultramodern-api-boundary-rules.mts +++ b/app/scripts/ultramodern-api-boundary-rules.mts @@ -1,4 +1,8 @@ -/// +import { + matchingDelimiter, + separatedSource, + topLevelSeparators, +} from './boundary-source-structure.mts'; import path from 'node:path'; @@ -100,50 +104,11 @@ const withoutTerminalSatisfies = (expression: string): string => .replace(/\s+satisfies\s+[$A-Z_a-z][$\w]*(?:\.[$A-Z_a-z][$\w]*)*(?:<[^<>]*>)?$/u, '') .trim(); -// oxlint-disable-next-line complexity -- Balanced TypeScript declaration scanning owns each delimiter state explicitly. const assignmentStart = (source: string, declarationEnd: number): number | undefined => { - let roundDepth = 0; - let squareDepth = 0; - let curlyDepth = 0; - let angleDepth = 0; - for (let index = declarationEnd; index < source.length; index += 1) { - const character = source[index]; - if (character === '(') { - roundDepth += 1; - } else if (character === ')') { - roundDepth -= 1; - } else if (character === '[') { - squareDepth += 1; - } else if (character === ']') { - squareDepth -= 1; - } else if (character === '{') { - curlyDepth += 1; - } else if (character === '}') { - curlyDepth -= 1; - } else if (character === '<') { - angleDepth += 1; - } else if (character === '>' && source[index - 1] !== '=') { - angleDepth -= 1; - } else if ( - character === '=' && - source[index + 1] !== '>' && - roundDepth === 0 && - squareDepth === 0 && - curlyDepth === 0 && - angleDepth === 0 - ) { - return index + 1; - } else if ( - character === ';' && - roundDepth === 0 && - squareDepth === 0 && - curlyDepth === 0 && - angleDepth === 0 - ) { - return undefined; - } - } - return undefined; + const index = topLevelSeparators(source, '=;', declarationEnd, source.length, true).find( + (position) => source[position + 1] !== '>', + ); + return index === undefined || source[index] === ';' ? undefined : index + 1; }; const curlyAncestorsAt = (source: string, targetIndex: number): readonly number[] => { @@ -212,28 +177,8 @@ const initializerFor = ( return undefined; } - let roundDepth = 0; - let squareDepth = 0; - let curlyDepth = 0; - for (let index = start; index < source.length; index += 1) { - const character = source[index]; - if (character === '(') { - roundDepth += 1; - } else if (character === ')') { - roundDepth -= 1; - } else if (character === '[') { - squareDepth += 1; - } else if (character === ']') { - squareDepth -= 1; - } else if (character === '{') { - curlyDepth += 1; - } else if (character === '}') { - curlyDepth -= 1; - } else if (character === ';' && roundDepth === 0 && squareDepth === 0 && curlyDepth === 0) { - return withoutTerminalSatisfies(source.slice(start, index).trim()); - } - } - return undefined; + const [end] = topLevelSeparators(source, ';', start); + return end === undefined ? undefined : withoutTerminalSatisfies(source.slice(start, end).trim()); }; const callArguments = (expression: string, callee: string): readonly string[] | undefined => { @@ -243,51 +188,14 @@ const callArguments = (expression: string, callee: string): readonly string[] | if (prefix === null) { return undefined; } - - const openIndex = prefix[0].lastIndexOf('('); - let roundDepth = 0; - let squareDepth = 0; - let curlyDepth = 0; - let angleDepth = 0; - let argumentStart = openIndex + 1; - const argumentsList: string[] = []; - for (let index = openIndex; index < expression.length; index += 1) { - const character = expression[index]; - if (character === '(') { - roundDepth += 1; - } else if (character === ')') { - roundDepth -= 1; - if (roundDepth === 0) { - const finalArgument = expression.slice(argumentStart, index).trim(); - if (finalArgument.length > 0) { - argumentsList.push(finalArgument); - } - return expression.slice(index + 1).trim().length === 0 ? argumentsList : undefined; - } - } else if (character === '[') { - squareDepth += 1; - } else if (character === ']') { - squareDepth -= 1; - } else if (character === '{') { - curlyDepth += 1; - } else if (character === '}') { - curlyDepth -= 1; - } else if (character === '<') { - angleDepth += 1; - } else if (character === '>' && expression[index - 1] !== '=') { - angleDepth -= 1; - } else if ( - character === ',' && - roundDepth === 1 && - squareDepth === 0 && - curlyDepth === 0 && - angleDepth === 0 - ) { - argumentsList.push(expression.slice(argumentStart, index).trim()); - argumentStart = index + 1; - } + const open = prefix[0].lastIndexOf('('); + const close = matchingDelimiter(expression, open, '(', ')'); + if (close === undefined || expression.slice(close + 1).trim().length !== 0) { + return undefined; } - return undefined; + const separators = topLevelSeparators(expression, ',', open + 1, close, true); + const argumentsList = separatedSource(expression, separators, open + 1, close); + return argumentsList.at(-1) === '' ? argumentsList.slice(0, -1) : argumentsList; }; const safeLayerPipeArguments = (expression: string): readonly string[] | undefined => { @@ -362,7 +270,7 @@ const groupCallbackRegistersHandler = (groupArguments: readonly string[]): boole ); }; -export interface RuntimeTopologyModule { +interface RuntimeTopologyModule { readonly id: string; readonly resolveImport: RuntimeTopologyModuleResolver; readonly source: string; @@ -372,60 +280,72 @@ export type RuntimeTopologyModuleResolver = ( specifier: string, ) => RuntimeTopologyModule | undefined; -const importedBindingFromAnyModule = ( - source: string, +interface NamedBinding { + readonly imported: string; + readonly specifier: string; +} + +const namedBindingInDeclaration = ( + bindings: string, name: string, -): { readonly imported: string; readonly specifier: string } | undefined => { - const visibleSource = withoutComments(source); - const code = withoutCommentsOrLiterals(source); - for (const candidate of visibleSource.matchAll( - /^(?[\t ]*)(?:import|export)\s*\{(?[^}]*)\}\s*from\s*['"](?[^'"]+)['"]/gmu, - )) { - const indentLength = candidate.groups?.indent?.length ?? 0; - const specifier = candidate.groups?.specifier; - if ( - specifier === undefined || - !/^(?:import|export)\b/u.test(code.slice(candidate.index + indentLength)) - ) { - continue; - } - for (const binding of candidate.groups?.bindings?.split(',') ?? []) { - const [imported, local = imported] = binding.trim().split(/\s+as\s+/u); - if (local === name && imported !== undefined) { - return { imported, specifier }; - } + specifier: string, +): NamedBinding | undefined => { + for (const binding of bindings.split(',')) { + const [imported, local = imported] = binding.trim().split(/\s+as\s+/u); + if (local === name && imported !== undefined) { + return { imported, specifier }; } } return undefined; }; -const importedValueBindingFromAnyModule = ( +const namedModuleBinding = ( source: string, name: string, -): { readonly imported: string; readonly specifier: string } | undefined => { - const visibleSource = withoutComments(source); + allowExport: boolean, +): NamedBinding | undefined => { + const visible = withoutComments(source); const code = withoutCommentsOrLiterals(source); - for (const candidate of visibleSource.matchAll( - /^(?[\t ]*)import\s*\{(?[^}]*)\}\s*from\s*['"](?[^'"]+)['"]/gmu, + for (const candidate of visible.matchAll( + /^(?[\t ]*)(?import|export)\s*\{(?[^}]*)\}\s*from\s*['"](?[^'"]+)['"]/gmu, )) { - const indentLength = candidate.groups?.indent?.length ?? 0; - const specifier = candidate.groups?.specifier; + const { bindings = '', indent = '', keyword = '', specifier = '' } = candidate.groups ?? {}; if ( - specifier === undefined || - !code.slice(candidate.index + indentLength).startsWith('import') + (keyword === 'export' && !allowExport) || + !code.slice(candidate.index + indent.length).startsWith(keyword) ) { continue; } - for (const binding of candidate.groups?.bindings?.split(',') ?? []) { - const [imported, local = imported] = binding.trim().split(/\s+as\s+/u); - if (local === name && imported !== undefined) { - return { imported, specifier }; - } + const binding = namedBindingInDeclaration(bindings, name, specifier); + if (binding !== undefined) { + return binding; } } return undefined; }; +const importedBindingFromAnyModule = (source: string, name: string): NamedBinding | undefined => + namedModuleBinding(source, name, true); +const importedValueBindingFromAnyModule = ( + source: string, + name: string, +): NamedBinding | undefined => namedModuleBinding(source, name, false); + +const hasUnaliasedValueImport = (source: string, name: string, specifier: string): boolean => { + const binding = importedValueBindingFromAnyModule(source, name); + return binding?.imported === name && binding.specifier === specifier; +}; + +const initializerCalls = ( + code: string, + name: string, + callee: string, + index = code.length, +): boolean => { + const initializer = initializerFor(code, name, index); + return initializer !== undefined && callArguments(initializer, callee) !== undefined; +}; + const layerValueUsesCors = ( source: string, name: string, @@ -451,109 +371,21 @@ const layerValueUsesCors = ( : layerValueUsesCors(source, pipedLayer.groups.base, usageIndex, new Set([...seen, name])); }; -const matchingRoundClose = (source: string, openIndex: number): number | undefined => { - let depth = 0; - for (let index = openIndex; index < source.length; index += 1) { - if (source[index] === '(') { - depth += 1; - } else if (source[index] === ')') { - depth -= 1; - if (depth === 0) { - return index; - } - } - } - return undefined; -}; +const matchingRoundClose = (source: string, openIndex: number): number | undefined => + matchingDelimiter(source, openIndex, '(', ')'); -const matchingCurlyClose = (source: string, openIndex: number): number | undefined => { - let depth = 0; - for (let index = openIndex; index < source.length; index += 1) { - if (source[index] === '{') { - depth += 1; - } else if (source[index] === '}') { - depth -= 1; - if (depth === 0) { - return index; - } - } - } - return undefined; -}; +const matchingCurlyClose = (source: string, openIndex: number): number | undefined => + matchingDelimiter(source, openIndex, '{', '}'); -const parameterBinding = (parameter: string): string => { - let roundDepth = 0; - let squareDepth = 0; - let curlyDepth = 0; - for (let index = 0; index < parameter.length; index += 1) { - const character = parameter[index]; - if (character === '(') { - roundDepth += 1; - } else if (character === ')') { - roundDepth -= 1; - } else if (character === '[') { - squareDepth += 1; - } else if (character === ']') { - squareDepth -= 1; - } else if (character === '{') { - curlyDepth += 1; - } else if (character === '}') { - curlyDepth -= 1; - } else if ( - (character === ':' || character === '=') && - roundDepth === 0 && - squareDepth === 0 && - curlyDepth === 0 - ) { - return parameter.slice(0, index); - } - } - return parameter; -}; +const parameterBinding = (parameter: string): string => + parameter.slice(0, topLevelSeparators(parameter, ':=')[0]); const parameterListShadows = (parameters: string, name: string): boolean => { - const escapedName = escapesRegularExpression(name); - let roundDepth = 0; - let squareDepth = 0; - let curlyDepth = 0; - let angleDepth = 0; - let parameterStart = 0; - for (let index = 0; index <= parameters.length; index += 1) { - const character = parameters[index]; - if (character === '(') { - roundDepth += 1; - } else if (character === ')') { - roundDepth -= 1; - } else if (character === '[') { - squareDepth += 1; - } else if (character === ']') { - squareDepth -= 1; - } else if (character === '{') { - curlyDepth += 1; - } else if (character === '}') { - curlyDepth -= 1; - } else if (character === '<') { - angleDepth += 1; - } else if (character === '>' && parameters[index - 1] !== '=') { - angleDepth -= 1; - } else if ( - (character === ',' || index === parameters.length) && - roundDepth === 0 && - squareDepth === 0 && - curlyDepth === 0 && - angleDepth === 0 - ) { - if ( - new RegExp(String.raw`\b${escapedName}\b`, 'u').test( - parameterBinding(parameters.slice(parameterStart, index)), - ) - ) { - return true; - } - parameterStart = index + 1; - } - } - return false; + const pattern = new RegExp(String.raw`\b${escapesRegularExpression(name)}\b`, 'u'); + const separators = topLevelSeparators(parameters, ',', 0, parameters.length, true); + return separatedSource(parameters, separators).some((parameter) => + pattern.test(parameterBinding(parameter)), + ); }; const controlFlowParentheses = new Set(['for', 'if', 'switch', 'while', 'with']); @@ -720,7 +552,86 @@ const sameApiExport = ( return canonical !== undefined && canonical === canonicalApiExport(module.source, expected); }; -// oxlint-disable-next-line complexity -- Transitive handler provenance must fail closed across local groups, aggregates, and imported re-exports. +const composedLayerOperand = (rawArgument: string): string | undefined => { + const argument = withoutTerminalSatisfies(rawArgument); + const layer = new RegExp( + String.raw`^(?${identifierPattern})(?[\s\S]*)$`, + 'u', + ).exec(argument); + const { name, remainder } = layer?.groups ?? {}; + if (name === undefined || remainder === undefined) { + return undefined; + } + return remainder.trim().length === 0 || safeLayerPipeArguments(remainder.trim()) !== undefined + ? name + : undefined; +}; + +const groupUsesExpectedApi = ( + source: string, + code: string, + groupArguments: readonly string[], + expectedApiExport: string, + expectedApiModuleId: string | undefined, + usageIndex: number, + resolveImport: RuntimeTopologyModuleResolver | undefined, +): boolean => { + const [apiName] = groupArguments; + if (apiName === undefined) { + return false; + } + const apiBinding = importedValueBindingFromAnyModule(source, apiName); + if (apiBinding === undefined) { + return false; + } + const apiModule = resolveImport?.(apiBinding.specifier); + return ( + hasUnaliasedValueImport(source, 'HttpApiBuilder', effectEdgeSpecifier) && + !shadowsBinding(code, 'HttpApiBuilder', usageIndex) && + sameApiExport(apiModule, apiBinding.imported, expectedApiExport) && + (expectedApiModuleId === undefined + ? /(?:^|\/)shared\/api\.ts$/u.test(apiBinding.specifier) + : apiModule?.id === expectedApiModuleId) + ); +}; + +const resolvedHandlerBinding = ( + source: string, + code: string, + name: string, + usageIndex: number, + resolveImport: RuntimeTopologyModuleResolver | undefined, +): { readonly importedName: string; readonly resolved: RuntimeTopologyModule } | undefined => { + if (shadowsBinding(code, name, usageIndex)) { + return undefined; + } + const binding = importedBindingFromAnyModule(source, name); + if (binding === undefined) { + return undefined; + } + const resolved = resolveImport?.(binding.specifier); + return resolved === undefined ? undefined : { importedName: binding.imported, resolved }; +}; + +const safeHandlerGroupArguments = (initializer: string): readonly string[] | undefined => { + const args = leadingCallArguments(initializer, 'HttpApiBuilder.group'); + return hasSafeLayerConstructor(initializer, 'HttpApiBuilder.group') && + args !== undefined && + groupCallbackRegistersHandler(args) + ? args + : undefined; +}; + +const safeMergeOperands = (source: string, initializer: string): readonly string[] | undefined => { + const args = leadingCallArguments(initializer, layerMergeAllCallee); + return hasUnaliasedValueImport(source, 'Layer', effectEdgeSpecifier) && + hasSafeLayerConstructor(initializer, layerMergeAllCallee) && + args !== undefined && + args.length > 0 + ? args + : undefined; +}; + const handlerLayerDerivesFromHttpApiBuilder = ( source: string, code: string, @@ -739,53 +650,26 @@ const handlerLayerDerivesFromHttpApiBuilder = ( const nextSeen = new Set([...seen, key]); const initializer = initializerFor(code, name, usageIndex); if (initializer !== undefined) { - const groupArguments = leadingCallArguments(initializer, 'HttpApiBuilder.group'); - if ( - hasSafeLayerConstructor(initializer, 'HttpApiBuilder.group') && - groupArguments !== undefined && - groupCallbackRegistersHandler(groupArguments) - ) { - const [apiName] = groupArguments; - const apiBinding = - apiName === undefined ? undefined : importedValueBindingFromAnyModule(source, apiName); - const apiModule = - apiBinding === undefined ? undefined : resolveImport?.(apiBinding.specifier); - const apiModuleId = apiModule?.id; - const builderBinding = importedValueBindingFromAnyModule(source, 'HttpApiBuilder'); - return ( - builderBinding?.imported === 'HttpApiBuilder' && - builderBinding.specifier === effectEdgeSpecifier && - !shadowsBinding(code, 'HttpApiBuilder', usageIndex) && - apiBinding !== undefined && - sameApiExport(apiModule, apiBinding.imported, expectedApiExport) && - (expectedApiModuleId === undefined - ? /(?:^|\/)shared\/api\.ts$/u.test(apiBinding.specifier) - : apiModuleId === expectedApiModuleId) + const groupArguments = safeHandlerGroupArguments(initializer); + if (groupArguments !== undefined) { + return groupUsesExpectedApi( + source, + code, + groupArguments, + expectedApiExport, + expectedApiModuleId, + usageIndex, + resolveImport, ); } - const mergeArguments = leadingCallArguments(initializer, layerMergeAllCallee); - const layerBinding = importedValueBindingFromAnyModule(source, 'Layer'); - if ( - layerBinding?.imported !== 'Layer' || - layerBinding.specifier !== effectEdgeSpecifier || - !hasSafeLayerConstructor(initializer, layerMergeAllCallee) || - mergeArguments === undefined || - mergeArguments.length === 0 - ) { + const mergeArguments = safeMergeOperands(source, initializer); + if (mergeArguments === undefined) { return false; } return mergeArguments.every((rawArgument) => { - const argument = withoutTerminalSatisfies(rawArgument); - const layer = new RegExp( - String.raw`^(?${identifierPattern})(?[\s\S]*)$`, - 'u', - ).exec(argument); - const layerName = layer?.groups?.name; - const remainder = layer?.groups?.remainder?.trim(); + const layerName = composedLayerOperand(rawArgument); return ( layerName !== undefined && - remainder !== undefined && - (remainder.length === 0 || safeLayerPipeArguments(remainder) !== undefined) && handlerLayerDerivesFromHttpApiBuilder( source, code, @@ -801,20 +685,16 @@ const handlerLayerDerivesFromHttpApiBuilder = ( }); } - if (shadowsBinding(code, name, usageIndex)) { - return false; - } - const importedBinding = importedBindingFromAnyModule(source, name); - const resolved = - importedBinding === undefined ? undefined : resolveImport?.(importedBinding.specifier); - if (importedBinding === undefined || resolved === undefined) { + const imported = resolvedHandlerBinding(source, code, name, usageIndex, resolveImport); + if (imported === undefined) { return false; } + const { importedName, resolved } = imported; const resolvedCode = withoutCommentsOrLiterals(resolved.source); return handlerLayerDerivesFromHttpApiBuilder( resolved.source, resolvedCode, - importedBinding.imported, + importedName, expectedApiExport, expectedApiModuleId, resolvedCode.length, @@ -839,18 +719,8 @@ const composesHandlerLayers = ( argumentsList !== undefined && argumentsList.length > 0 && argumentsList.every((rawArgument) => { - const argument = withoutTerminalSatisfies(rawArgument); - const layer = new RegExp( - String.raw`^(?${identifierPattern})(?[\s\S]*)$`, - 'u', - ).exec(argument); - const layerName = layer?.groups?.name; - const remainder = layer?.groups?.remainder?.trim(); - if ( - layerName === undefined || - remainder === undefined || - (remainder.length > 0 && safeLayerPipeArguments(remainder) === undefined) - ) { + const layerName = composedLayerOperand(rawArgument); + if (layerName === undefined) { return false; } return handlerLayerDerivesFromHttpApiBuilder( @@ -904,26 +774,23 @@ const declaresLayerValue = ( return true; } - const pipedLayer = new RegExp( - String.raw`^(?${identifierPattern})(?\.pipe\s*\()`, - 'u', - ).exec(initializer); - return pipedLayer?.groups?.base === undefined || - pipedLayer.groups.pipe === undefined || - safeLayerPipeArguments(initializer.slice(pipedLayer.groups.base.length)) === undefined - ? false - : declaresLayerValue( - source, - code, - pipedLayer.groups.base, - allowCors, - requireHandlerOperands, - expectedApiExport, - expectedApiModuleId, - resolveImport, - usageIndex, - new Set([...seen, name]), - ); + const base = composedLayerOperand(initializer); + return ( + base !== undefined && + initializer.startsWith(`${base}.pipe`) && + declaresLayerValue( + source, + code, + base, + allowCors, + requireHandlerOperands, + expectedApiExport, + expectedApiModuleId, + resolveImport, + usageIndex, + new Set([...seen, name]), + ) + ); }; const curlyDepthAt = (code: string, targetIndex: number): number => { @@ -1047,12 +914,9 @@ const exportedFactoryOwnsCall = (code: string, callIndex: number, callEnd: numbe ), ), ].find(({ index }) => curlyDepthAt(code, index) === 0)?.groups?.runtime; - const defaultRuntimeInitializer = - defaultRuntime === undefined ? undefined : initializerFor(code, defaultRuntime, code.length); return ( defaultRuntime !== undefined && - defaultRuntimeInitializer !== undefined && - callArguments(defaultRuntimeInitializer, factory) !== undefined && + initializerCalls(code, defaultRuntime, factory) && new RegExp( String.raw`\bexport\s+default\s+${escapesRegularExpression(defaultRuntime)}\s*;`, 'u', @@ -1109,122 +973,137 @@ const usesImportedCorsTransport = ( importedValueBindingFromAnyModule(source, 'HttpRouter')?.specifier === effectEdgeSpecifier && !shadowsBinding(code, 'HttpRouter', callIndex)); +const hasRpcGroupContract = ( + source: string, + group: string, + resolveImport: RuntimeTopologyModuleResolver | undefined, +): boolean => { + if (!hasUnaliasedValueImport(source, group, '../shared/rpc.ts')) { + return false; + } + const module = resolveImport?.('../shared/rpc.ts'); + if (module === undefined) { + return false; + } + const code = withoutCommentsOrLiterals(module.source); + return ( + hasUnaliasedValueImport(module.source, 'RpcGroup', 'effect/unstable/rpc') && + initializerCalls(code, group, 'RpcGroup.make') && + !shadowsBinding(code, 'RpcGroup', code.length) + ); +}; + +const hasRpcRuntimeLayers = ( + source: string, + code: string, + call: RegExpExecArray, + helper: string, +): boolean => { + const { api, group, layer = 'layer', rpcLayer } = call.groups ?? {}; + if (api === undefined || group === undefined || rpcLayer === undefined) { + return false; + } + return ( + ['HttpApi', 'Layer'].every((name) => + hasUnaliasedValueImport(source, name, effectEdgeSpecifier), + ) && + [helper, 'HttpApi', 'Layer', group].every((name) => !shadowsBinding(code, name, call.index)) && + initializerCalls(code, api, 'HttpApi.make', call.index) && + initializerFor(code, layer, call.index) === 'Layer.empty' && + initializerCalls(code, rpcLayer, `${group}.toLayer`, call.index) + ); +}; + /** Keeps genuinely different generated RPC assembly outside the REST-only helper contract. */ -// oxlint-disable-next-line complexity -- The RPC exception is deliberately an exact, fail-closed topology proof. export const usesStrictRpcRuntimeTopology = ( source: string, resolveImport?: RuntimeTopologyModuleResolver, ): boolean => { const code = withoutCommentsOrLiterals(source); - const defineEffectBff = importedLocalNameMatchingSpecifier( + const helper = importedLocalNameMatchingSpecifier( source, 'defineEffectBff', escapesRegularExpression(effectEdgeSpecifier), ); if ( - defineEffectBff === undefined || + helper === undefined || !/\bfrom\s+['"]\.\.\/shared\/rpc\.ts['"]/u.test(withoutComments(source)) ) { return false; } const call = new RegExp( - String.raw`\b${escapesRegularExpression(defineEffectBff)}\s*\(\s*\{\s*api:\s*(?${identifierPattern})\s*,\s*layer(?:\s*:\s*(?${identifierPattern}))?\s*,\s*rpc:\s*\{\s*group:\s*(?${identifierPattern})\s*,\s*layer:\s*(?${identifierPattern})\s*,\s*path:\s*,\s*serialization:\s*,?\s*\}\s*,?\s*\}\s*,?\s*\)`, + String.raw`\b${escapesRegularExpression(helper)}\s*\(\s*\{\s*api:\s*(?${identifierPattern})\s*,\s*layer(?:\s*:\s*(?${identifierPattern}))?\s*,\s*rpc:\s*\{\s*group:\s*(?${identifierPattern})\s*,\s*layer:\s*(?${identifierPattern})\s*,\s*path:\s*,\s*serialization:\s*,?\s*\}\s*,?\s*\}\s*,?\s*\)`, 'u', ).exec(code); - const api = call?.groups?.api; - const layer = call === null ? undefined : (call.groups?.layer ?? 'layer'); - const group = call?.groups?.group; - const rpcLayer = call?.groups?.rpcLayer; - const callIndex = call?.index ?? code.length; - const apiInitializer = api === undefined ? undefined : initializerFor(code, api, callIndex); - const layerInitializer = layer === undefined ? undefined : initializerFor(code, layer, callIndex); - const rpcLayerInitializer = - rpcLayer === undefined ? undefined : initializerFor(code, rpcLayer, callIndex); - const httpApiBinding = importedValueBindingFromAnyModule(source, 'HttpApi'); - const layerBinding = importedValueBindingFromAnyModule(source, 'Layer'); - const groupBinding = - group === undefined ? undefined : importedValueBindingFromAnyModule(source, group); - const rpcModule = - groupBinding === undefined ? undefined : resolveImport?.(groupBinding.specifier); - const rpcModuleCode = - rpcModule === undefined ? undefined : withoutCommentsOrLiterals(rpcModule.source); - const rpcGroupInitializer = - rpcModuleCode === undefined || groupBinding === undefined - ? undefined - : initializerFor(rpcModuleCode, groupBinding.imported, rpcModuleCode.length); - const rpcGroupConstructor = - rpcModule === undefined - ? undefined - : importedValueBindingFromAnyModule(rpcModule.source, 'RpcGroup'); + if (call === null) { + return false; + } + const group = call.groups?.group; return ( - call !== null && - api !== undefined && - layer !== undefined && group !== undefined && - rpcLayer !== undefined && - httpApiBinding?.imported === 'HttpApi' && - httpApiBinding.specifier === effectEdgeSpecifier && - layerBinding?.imported === 'Layer' && - layerBinding.specifier === effectEdgeSpecifier && - groupBinding?.imported === group && - groupBinding.specifier === '../shared/rpc.ts' && - rpcModuleCode !== undefined && - rpcGroupConstructor?.imported === 'RpcGroup' && - rpcGroupConstructor.specifier === 'effect/unstable/rpc' && - rpcGroupInitializer !== undefined && - callArguments(rpcGroupInitializer, 'RpcGroup.make') !== undefined && - !shadowsBinding(rpcModuleCode, 'RpcGroup', rpcModuleCode.length) && - !shadowsBinding(code, defineEffectBff, call.index) && - !shadowsBinding(code, 'HttpApi', call.index) && - !shadowsBinding(code, 'Layer', call.index) && - !shadowsBinding(code, group, call.index) && - apiInitializer !== undefined && - callArguments(apiInitializer, 'HttpApi.make') !== undefined && - layerInitializer === 'Layer.empty' && - rpcLayerInitializer !== undefined && - callArguments(rpcLayerInitializer, `${group}.toLayer`) !== undefined && + hasRpcGroupContract(source, group, resolveImport) && + hasRpcRuntimeLayers(source, code, call, helper) && isRuntimeRootCall(code, call.index, call.index + call[0].length) ); }; -/** Proves the shared helper's concrete API/Layer topology. */ -// oxlint-disable-next-line complexity -- One fail-closed decision keeps the helper import, call, layers, and runtime-root proof atomic. -export const strictEffectRuntimeTopologyViolation = ( +interface AssemblyBindings { + readonly api: string; + readonly handlers: string; + readonly transport: string | undefined; +} + +const assemblyTransportViolation = ( source: string, - resolveImport?: RuntimeTopologyModuleResolver, + code: string, + transport: string | undefined, + apiExport: string, + expectedApiModuleId: string | undefined, + resolveImport: RuntimeTopologyModuleResolver | undefined, + index: number, ): string | undefined => { - const code = withoutCommentsOrLiterals(source); - const helper = importedLocalNameMatchingSpecifier( - source, - 'assembleEffectBffRuntime', - String.raw`@[a-z0-9-]+\/shared-contracts\/server\/effect-bff-runtime`, - ); - if (helper === undefined) { - return usesStrictRpcRuntimeTopology(source, resolveImport) - ? undefined - : 'must import the server-only shared Effect BFF assembly helper'; + if (transport === undefined) { + return undefined; } - const call = new RegExp( - String.raw`\b${escapesRegularExpression(helper)}\s*\(\s*\{\s*api:\s*(?${identifierPattern})\s*,\s*handlers:\s*(?${identifierPattern})(?:\s*,\s*transport:\s*(?${identifierPattern}))?\s*,?\s*\}\s*\)`, - 'u', - ).exec(code); - const api = call?.groups?.api; - const handlers = call?.groups?.handlers; - const transport = call?.groups?.transport; - if (call === null || api === undefined || handlers === undefined) { - return 'must pass a concrete api and composed handlers directly to assembleEffectBffRuntime'; + if ( + !declaresLayerValue( + source, + code, + transport, + true, + false, + apiExport, + expectedApiModuleId, + resolveImport, + index, + ) + ) { + return 'must pass an explicitly composed Layer as assembleEffectBffRuntime transport'; } + return usesImportedCorsTransport(source, code, transport, index) + ? undefined + : 'must use the imported HttpRouter for the transport Layer'; +}; + +const assembledRuntimeViolation = ( + source: string, + code: string, + helper: string, + call: RegExpExecArray, + bindings: AssemblyBindings, + resolveImport: RuntimeTopologyModuleResolver | undefined, +): string | undefined => { + const { api, handlers, transport } = bindings; if (!importsNamedValueFromSharedApi(source, api)) { return 'must pass the API imported from ../shared/api.ts to assembleEffectBffRuntime'; } const apiBinding = importedValueBindingFromAnyModule(source, api); - const apiExport = apiBinding?.imported; - const expectedApiModuleId = - apiBinding === undefined ? undefined : resolveImport?.(apiBinding.specifier)?.id; - if ( - apiExport === undefined || - (resolveImport !== undefined && expectedApiModuleId === undefined) - ) { + if (apiBinding === undefined) { + return 'must prove the exact shared API export used by assembleEffectBffRuntime'; + } + const apiExport = apiBinding.imported; + const expectedApiModuleId = resolveImport?.(apiBinding.specifier)?.id; + if (resolveImport !== undefined && expectedApiModuleId === undefined) { return 'must prove the exact shared API export used by assembleEffectBffRuntime'; } if (!usesUnshadowedHelperImports(source, code, api, helper, call.index)) { @@ -1245,24 +1124,17 @@ export const strictEffectRuntimeTopologyViolation = ( ) { return 'must pass an explicitly composed Layer as assembleEffectBffRuntime handlers'; } - if ( - transport !== undefined && - !declaresLayerValue( - source, - code, - transport, - true, - false, - apiExport, - expectedApiModuleId, - resolveImport, - call.index, - ) - ) { - return 'must pass an explicitly composed Layer as assembleEffectBffRuntime transport'; - } - if (transport !== undefined && !usesImportedCorsTransport(source, code, transport, call.index)) { - return 'must use the imported HttpRouter for the transport Layer'; + const transportViolation = assemblyTransportViolation( + source, + code, + transport, + apiExport, + expectedApiModuleId, + resolveImport, + call.index, + ); + if (transportViolation !== undefined) { + return transportViolation; } if (!isRuntimeRootCall(code, call.index, call.index + call[0].length)) { return 'must return or export the assembled strict Effect BFF runtime'; @@ -1270,6 +1142,40 @@ export const strictEffectRuntimeTopologyViolation = ( return undefined; }; +/** Proves the shared helper's concrete API/Layer topology. */ +export const strictEffectRuntimeTopologyViolation = ( + source: string, + resolveImport?: RuntimeTopologyModuleResolver, +): string | undefined => { + const code = withoutCommentsOrLiterals(source); + const helper = importedLocalNameMatchingSpecifier( + source, + 'assembleEffectBffRuntime', + String.raw`@[a-z0-9-]+\/shared-contracts\/server\/effect-bff-runtime`, + ); + if (helper === undefined) { + return usesStrictRpcRuntimeTopology(source, resolveImport) + ? undefined + : 'must import the server-only shared Effect BFF assembly helper'; + } + const call = new RegExp( + String.raw`\b${escapesRegularExpression(helper)}\s*\(\s*\{\s*api:\s*(?${identifierPattern})\s*,\s*handlers:\s*(?${identifierPattern})(?:\s*,\s*transport:\s*(?${identifierPattern}))?\s*,?\s*\}\s*\)`, + 'u', + ).exec(code); + const { api, handlers, transport } = call?.groups ?? {}; + if (call === null || api === undefined || handlers === undefined) { + return 'must pass a concrete api and composed handlers directly to assembleEffectBffRuntime'; + } + return assembledRuntimeViolation( + source, + code, + helper, + call, + { api, handlers, transport }, + resolveImport, + ); +}; + export const privateOwnerImportViolation = ( root: string, file: string, diff --git a/app/scripts/ultramodern-command-failure.mts b/app/scripts/ultramodern-command-failure.mts new file mode 100644 index 000000000..6ad64e22c --- /dev/null +++ b/app/scripts/ultramodern-command-failure.mts @@ -0,0 +1,9 @@ +import { Schema } from 'effect'; + +class UltramodernCommandError extends Schema.TaggedError()( + 'UltramodernCommandError', + { reason: Schema.String }, +) {} + +export const ultramodernCommandFailure = (reason: string): UltramodernCommandError => + new UltramodernCommandError({ reason }); diff --git a/app/scripts/ultramodern-performance-readiness.mts b/app/scripts/ultramodern-performance-readiness.mts index a89db14a8..69686bcc5 100644 --- a/app/scripts/ultramodern-performance-readiness.mts +++ b/app/scripts/ultramodern-performance-readiness.mts @@ -1,21 +1,14 @@ #!/usr/bin/env node import { NodeServices } from '@effect/platform-node'; -import { Effect, Schema } from 'effect'; +import { Effect } from 'effect'; import { runUltramodernScript, ultramodernExitCode } from './shared/ultramodern-command.mts'; - -class PerformanceReadinessError extends Schema.TaggedError()( - 'PerformanceReadinessError', - { reason: Schema.String }, -) {} - -const failure = (reason: string): PerformanceReadinessError => - new PerformanceReadinessError({ reason }); +import { ultramodernCommandFailure } from './ultramodern-command-failure.mts'; const exit = await Effect.runPromiseExit( runUltramodernScript({ command: 'performance-readiness', directoryFailure: 'Unable to resolve the performance-readiness directory', - failure, + failure: ultramodernCommandFailure, moduleUrl: import.meta.url, }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); diff --git a/app/scripts/ultramodern-typecheck.mts b/app/scripts/ultramodern-typecheck.mts index 6eb8ab144..1bcf00057 100644 --- a/app/scripts/ultramodern-typecheck.mts +++ b/app/scripts/ultramodern-typecheck.mts @@ -1,21 +1,14 @@ #!/usr/bin/env node import { NodeServices } from '@effect/platform-node'; -import { Effect, Schema } from 'effect'; +import { Effect } from 'effect'; import { runUltramodernScript, ultramodernExitCode } from './shared/ultramodern-command.mts'; - -class UltramodernTypecheckError extends Schema.TaggedError()( - 'UltramodernTypecheckError', - { reason: Schema.String }, -) {} - -const failure = (reason: string): UltramodernTypecheckError => - new UltramodernTypecheckError({ reason }); +import { ultramodernCommandFailure } from './ultramodern-command-failure.mts'; const exit = await Effect.runPromiseExit( runUltramodernScript({ command: 'typecheck', directoryFailure: 'Unable to resolve the typecheck wrapper directory', - failure, + failure: ultramodernCommandFailure, moduleUrl: import.meta.url, }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); diff --git a/app/scripts/validate-ultramodern-workspace.mts b/app/scripts/validate-ultramodern-workspace.mts index 0f6ef47c6..f5f0b3009 100644 --- a/app/scripts/validate-ultramodern-workspace.mts +++ b/app/scripts/validate-ultramodern-workspace.mts @@ -2336,20 +2336,6 @@ const assertAnyOf = (relativePaths: readonly string[]): void => { `Missing one of: ${relativePaths.join(', ')}`, ); }; -const requiredShellWorkerCompositionPath = (shellPath: string): string => { - const workerCompositionPath = `${shellPath}/src/routes/vertical-components.worker.tsx`; - if (fs.existsSync(path.join(root, workerCompositionPath))) { - return workerCompositionPath; - } - - const browserComposition = readText(`${shellPath}/src/routes/vertical-components.tsx`); - const frameworkGeneratedComposition = - browserComposition.includes('const createRemoteComponent =') && - browserComposition.includes('export const VerticalShowcase ='); - return frameworkGeneratedComposition - ? workerCompositionPath - : `${shellPath}/src/federated-components.worker.tsx`; -}; const sortedCopy = ( values: readonly Value[], compare: (left: Value, right: Value) => number, @@ -3965,7 +3951,12 @@ const requiredMicroVerticalPaths = (vertical: FullStackVertical): string[] => [ `${vertical.path}/src/routes/layout.tsx`, `${vertical.path}/src/routes/ultramodern-route-head.tsx`, `${vertical.path}/src/routes/ultramodern-route-metadata.ts`, - ...(vertical.hasOwnerPage ? [`${vertical.path}/src/routes/[lang]/page.tsx`] : []), + ...(vertical.hasOwnerPage + ? [ + `${vertical.path}/src/routes/[lang]/page.tsx`, + `${vertical.path}/src/routes/ultramodern-jsonld.ts`, + ] + : []), ...(vertical.exposes.includes('./Widget') ? [`${vertical.path}/src/routes/[lang]/_mf/fragment/widget/page.tsx`] : []), @@ -3986,6 +3977,9 @@ const requiredMicroVerticalPaths = (vertical: FullStackVertical): string[] => [ // UI/MF artifacts an `api-only` unit must NOT emit (headless invariant), and // API/BFF artifacts a `ui-only`/Horizontal Remote unit must NOT emit. const forbiddenMicroVerticalPaths = (vertical: FullStackVertical): string[] => [ + // Structured data is owner-page-only: a unit that renders no owner page emits no + // `application/ld+json`, so it must not ship the JSON-LD helper module either. + ...(vertical.hasOwnerPage ? [] : [`${vertical.path}/src/routes/ultramodern-jsonld.ts`]), ...(vertical.emitsUi ? [] : [ @@ -5014,10 +5008,6 @@ const assertPublicHeadContract = ( !publicHead.structuredData.inference, `${appId} structured data inference must stay disabled`, ); - assert( - publicHead.structuredData.helperModule === './src/routes/ultramodern-jsonld', - `${appId} structured data helper module is incorrect`, - ); assert( publicHead.structuredData.sanitizesHtmlOpenBracket, `${appId} structured data must sanitize HTML open brackets`, @@ -5044,6 +5034,10 @@ const assertPublicHeadContract = ( } return; } + assert( + publicHead.structuredData.helperModule === './src/routes/ultramodern-jsonld', + `${appId} structured data helper module is incorrect`, + ); for (const snippet of [ "from '@modern-js/runtime/head'", '{title}', @@ -5322,10 +5316,9 @@ const requiredPaths = [ 'apps/shell-super-app/src/routes/index.css', 'apps/shell-super-app/src/routes/layout.tsx', 'apps/shell-super-app/src/routes/shell-frame.tsx', + 'apps/shell-super-app/src/routes/ultramodern-jsonld.ts', 'apps/shell-super-app/src/routes/ultramodern-route-head.tsx', 'apps/shell-super-app/src/routes/ultramodern-route-metadata.ts', - 'apps/shell-super-app/src/routes/vertical-components.tsx', - requiredShellWorkerCompositionPath(SHARED_VALIDATOR_STRING_047), 'apps/shell-super-app/src/routes/[lang]/page.tsx', ...shellRouteMetaPaths, SHARED_VALIDATOR_STRING_093, @@ -5387,8 +5380,6 @@ for (const shell of expectedAdditionalShells) { `${shell.path}/src/routes/index.css`, `${shell.path}/src/routes/layout.tsx`, `${shell.path}/src/routes/shell-frame.tsx`, - `${shell.path}/src/routes/vertical-components.tsx`, - requiredShellWorkerCompositionPath(shell.path), `${shell.path}/src/routes/ultramodern-route-head.tsx`, `${shell.path}/src/routes/ultramodern-route-metadata.ts`, `${shell.path}/src/routes/[lang]/page.tsx`, @@ -6163,8 +6154,6 @@ const assertAdditionalShellSources = (shell: (typeof expectedAdditionalShells)[n const styles = readText(`${shell.path}/src/routes/index.css`); const shellFrame = readText(`${shell.path}/src/routes/shell-frame.tsx`); const routePage = readText(`${shell.path}/src/routes/[lang]/page.tsx`); - const remoteComponents = readText(`${shell.path}/src/routes/vertical-components.tsx`); - const workerRemoteComponents = readText(requiredShellWorkerCompositionPath(shell.path)); assert( modernConfig.includes(`const appId = '${shell.id}';`), `${shell.id} modern.config.ts appId is incorrect`, @@ -6188,18 +6177,9 @@ const assertAdditionalShellSources = (shell: (typeof expectedAdditionalShells)[n `${shell.id} runtime boundary metadata must identify its own shell`, ); assert( - routePage.includes('ShellFrame') && routePage.includes('VerticalShowcase'), + routePage.includes('ShellFrame'), `${shell.id} route page must use its own shell composition host`, ); - assert( - remoteComponents.includes(`data-modern-boundary-id="${shell.mfName}"`), - `${shell.id} remote composition boundary must use its own MF identity`, - ); - assert( - !workerRemoteComponents.includes('@module-federation') && - !workerRemoteComponents.includes('import('), - `${shell.id} Worker SSR must not include native Module Federation runtime or remote imports`, - ); if (tailwindEnabled) { assert( styles.includes(`prefix(${shell.tailwindPrefix})`), @@ -6211,26 +6191,10 @@ const assertAdditionalShellSources = (shell: (typeof expectedAdditionalShells)[n `${shell.id} shell-frame must use its shell-specific Tailwind prefix`, ); if ((shell.verticalRefs ?? []).length > 0) { - assert( - workerRemoteComponents.includes('DistributedSsrBoundary'), - `${shell.id} Worker SSR must use distributed fragment boundaries`, - ); assert( shell.degradedState?.required ?? false, `${shell.id} degraded-state contract must be required for remote consumption`, ); - assert( - remoteComponents.includes(`${shell.tailwindPrefix}:text-red-900`), - `${shell.id} degraded fallback must report its own shell identity`, - ); - assert( - remoteComponents.includes('fallback: '), - `${shell.id} consumption points must have a degraded fallback`, - ); - assert( - !remoteComponents.includes('data-modern-boundary-id="shellSuperApp"'), - `${shell.id} degraded fallback must not report the primary shell`, - ); } }; diff --git a/app/scripts/verify-cloudflare-output.mts b/app/scripts/verify-cloudflare-output.mts index c45c5b24c..8a31a17fb 100644 --- a/app/scripts/verify-cloudflare-output.mts +++ b/app/scripts/verify-cloudflare-output.mts @@ -1,21 +1,14 @@ #!/usr/bin/env node import { NodeServices } from '@effect/platform-node'; -import { Effect, Schema } from 'effect'; +import { Effect } from 'effect'; import { runUltramodernScript, ultramodernExitCode } from './shared/ultramodern-command.mts'; - -class CloudflareOutputVerificationLaunchError extends Schema.TaggedError()( - 'CloudflareOutputVerificationLaunchError', - { reason: Schema.String }, -) {} - -const failure = (reason: string): CloudflareOutputVerificationLaunchError => - new CloudflareOutputVerificationLaunchError({ reason }); +import { ultramodernCommandFailure } from './ultramodern-command-failure.mts'; const exit = await Effect.runPromiseExit( runUltramodernScript({ command: 'cloudflare-output-verify', directoryFailure: 'Unable to resolve the Cloudflare output verifier directory', - failure, + failure: ultramodernCommandFailure, moduleUrl: import.meta.url, nodeExecutable: process.execPath, }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), diff --git a/app/specs/feature-universal-module-state-gate.md b/app/specs/feature-universal-module-state-gate.md index 2b32b784a..444a97e95 100644 --- a/app/specs/feature-universal-module-state-gate.md +++ b/app/specs/feature-universal-module-state-gate.md @@ -171,8 +171,7 @@ Use these files to implement the feature: - `apps/shell-super-app/api/index.ts` — Shell strict Effect BFF composition where a trusted request-scoped batch/snapshot layer may be provided without making the browser authoritative. - `apps/shell-super-app/api/verticals/installed-verticals.ts` — authoritative topology-derived installed business-module inventory used by Shell gateway decisions. - `apps/shell-super-app/src/routes/[lang]/page.data.ts` — existing Shell request-loader boundary and reference point for request-scoped state acquisition rather than per-component calls. -- `apps/shell-super-app/src/routes/vertical-components.tsx` — generated Shell browser composition surface where future remote loads must be lazy and gateway-owned. -- `apps/shell-super-app/src/routes/vertical-components.worker.tsx` — Worker SSR composition surface that must not bypass the same structured load contract. +- `apps/shell-super-app/src/routes/shell-frame.tsx` — Shell composition surface where future remote loads must be lazy and gateway-owned in both the browser and Worker SSR renders. - `scripts/scaffolding/action/scaffold.mts` — must emit tenant `write` entrypoints and explicit Core system entrypoints. - `scripts/scaffolding/microvertical-page/scaffold.mts` — must emit governed `read` route metadata rather than an owner id alone. - `scripts/scaffolding/outbox-worker/scaffold.mts` — must emit structured `background` descriptors and consistent generated catalogs/registries. diff --git a/app/tools/oxlint/effect-native/rules/no-duplicate-literal-vocabulary.ts b/app/tools/oxlint/effect-native/rules/no-duplicate-literal-vocabulary.ts index 95908d911..b56fe9737 100644 --- a/app/tools/oxlint/effect-native/rules/no-duplicate-literal-vocabulary.ts +++ b/app/tools/oxlint/effect-native/rules/no-duplicate-literal-vocabulary.ts @@ -114,17 +114,9 @@ const MAX_NAME_DEPTH = 8; /** Hops allowed when following `const` aliases (`const Literals = Schema.Literals`). */ const MAX_ALIAS_HOPS = 2; -interface RuleOptions { - readonly include: readonly string[]; - readonly ignore: readonly string[]; - readonly ignoreTests: boolean; - readonly reportSubsets: boolean; - readonly minMembers: number; - readonly factories: readonly string[]; - readonly reexportModules: readonly string[]; -} +type RuleOptions = Readonly>; -function readOptions(context: Context): RuleOptions { +function readOptions(context: Context) { const record = optionRecord(context.options?.[0]); return { include: stringArray(record.include, DEFAULT_INCLUDE), diff --git a/app/tools/oxlint/effect-native/rules/no-hand-parsed-environment-value.ts b/app/tools/oxlint/effect-native/rules/no-hand-parsed-environment-value.ts index 1cc4b0d8a..860e09ef3 100644 --- a/app/tools/oxlint/effect-native/rules/no-hand-parsed-environment-value.ts +++ b/app/tools/oxlint/effect-native/rules/no-hand-parsed-environment-value.ts @@ -79,6 +79,7 @@ * Report-only: no fixers, no suggestions. */ import { defineRule } from '@oxlint/plugins'; +import { optionRecord, stringArray } from '../shared/options.ts'; import { importedName } from '../shared/imports.ts'; import type { Context, ESTree, Scope, Variable } from '@oxlint/plugins'; @@ -218,28 +219,19 @@ interface RuleOptions { readonly environmentReaders: readonly string[]; } -function stringList(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - function readOptions(raw: unknown): RuleOptions { - const given: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; - const includePaths = stringList(given.includePaths, DEFAULT_INCLUDE_PATHS); + const given = optionRecord(raw); + const includePaths = stringArray(given.includePaths, DEFAULT_INCLUDE_PATHS); const identifiers = given.environmentIdentifiers; return { - allowPaths: stringList(given.allowPaths, []), + allowPaths: stringArray(given.allowPaths, []), ignoreTestFiles: given.ignoreTestFiles === true, includePaths: includePaths.length > 0 ? includePaths : DEFAULT_INCLUDE_PATHS, environmentIdentifiers: typeof identifiers === 'string' && identifiers.length > 0 ? identifiers : DEFAULT_ENVIRONMENT_IDENTIFIERS, - environmentReaders: stringList(given.environmentReaders, DEFAULT_ENVIRONMENT_READERS), + environmentReaders: stringArray(given.environmentReaders, DEFAULT_ENVIRONMENT_READERS), }; } diff --git a/app/tools/oxlint/effect-native/rules/no-hand-rolled-tagged-union.ts b/app/tools/oxlint/effect-native/rules/no-hand-rolled-tagged-union.ts index 202bcc488..d892bccf7 100644 --- a/app/tools/oxlint/effect-native/rules/no-hand-rolled-tagged-union.ts +++ b/app/tools/oxlint/effect-native/rules/no-hand-rolled-tagged-union.ts @@ -112,18 +112,9 @@ const DEFAULT_WRAPPER_TYPES: readonly string[] = [ /** Depth guard for the ancestor walk; real type nesting never approaches this. */ const MAX_ANCESTOR_DEPTH = 64; -interface RuleOptions { - readonly discriminantKeys: readonly string[]; - readonly include: readonly string[]; - readonly ignore: readonly string[]; - readonly ignoreTests: boolean; - readonly wrapperTypes: readonly string[]; - readonly includeNestedTypes: boolean; - readonly includeClassFields: boolean; - readonly ignoreAmbient: boolean; -} +type RuleOptions = Readonly>; -function readOptions(context: Context): RuleOptions { +function readOptions(context: Context) { const record = optionRecord(context.options?.[0]); return { discriminantKeys: stringArray(record.discriminantKeys, DEFAULT_DISCRIMINANT_KEYS), diff --git a/app/tools/oxlint/effect-native/rules/no-imperative-loop-in-effect-gen.ts b/app/tools/oxlint/effect-native/rules/no-imperative-loop-in-effect-gen.ts index b68bcd1a8..2a477cbed 100644 --- a/app/tools/oxlint/effect-native/rules/no-imperative-loop-in-effect-gen.ts +++ b/app/tools/oxlint/effect-native/rules/no-imperative-loop-in-effect-gen.ts @@ -116,18 +116,9 @@ const LOOP_LABELS: Record = { WhileStatement: 'while', }; -interface RuleOptions { - readonly include: readonly string[]; - readonly ignore: readonly string[]; - readonly includeTests: boolean; - readonly includeScripts: boolean; - readonly allowForOfWithoutMutation: boolean; - readonly flagCounters: boolean; - readonly genMembers: readonly string[]; - readonly effectModules: readonly string[]; -} +type RuleOptions = Readonly>; -function readOptions(context: Context): RuleOptions { +function readOptions(context: Context) { const record = optionRecord(context.options?.[0]); return { include: stringArray(record.include, DEFAULT_INCLUDE), diff --git a/app/tools/oxlint/effect-native/rules/no-interface-first-codec.ts b/app/tools/oxlint/effect-native/rules/no-interface-first-codec.ts index 03a6da8d0..a49d34b57 100644 --- a/app/tools/oxlint/effect-native/rules/no-interface-first-codec.ts +++ b/app/tools/oxlint/effect-native/rules/no-interface-first-codec.ts @@ -95,23 +95,7 @@ const DEFAULT_REEXPORT_MODULES = [ const SUSPEND_MEMBER = 'suspend'; const PIPE_MEMBER = 'pipe'; -interface RuleOptions { - readonly include: readonly string[]; - readonly ignore: readonly string[]; - readonly ignoreTests: boolean; - readonly allowSuspend: boolean; - readonly codecTypes: readonly string[]; - readonly ignoreTypeArguments: readonly string[]; - readonly requireTypeArguments: boolean; - readonly allowDerivedTypeArguments: boolean; - readonly requireSchemaInitializer: boolean; - readonly checkSatisfies: boolean; - readonly checkAsExpressions: boolean; - readonly checkClassProperties: boolean; - readonly reexportModules: readonly string[]; -} - -function readOptions(context: Context): RuleOptions { +function readOptions(context: Context) { const record = optionRecord(context.options?.[0]); return { include: stringArray(record.include, DEFAULT_INCLUDE), diff --git a/app/tools/oxlint/effect-native/rules/no-json-schema-as-document-contract.ts b/app/tools/oxlint/effect-native/rules/no-json-schema-as-document-contract.ts index 6599a6df2..a492b619d 100644 --- a/app/tools/oxlint/effect-native/rules/no-json-schema-as-document-contract.ts +++ b/app/tools/oxlint/effect-native/rules/no-json-schema-as-document-contract.ts @@ -1,4 +1,3 @@ -import { optionRecord } from '../shared/options.ts'; /** * Audit finding: **A7** — "Give topology, composition, and authorization evidence shared Schemas" * (`docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`). @@ -70,17 +69,20 @@ import { optionRecord } from '../shared/options.ts'; */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Variable } from '@oxlint/plugins'; +import type { Context, ESTree } from '@oxlint/plugins'; import { collectEffectBindings } from '../shared/effect-imports.ts'; import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; -import { booleanOption as boolean, stringArray } from '../shared/options.ts'; -import { keyName, memberName as staticMemberName, unwrapNode } from '../shared/ast.ts'; +import { booleanOption as boolean, optionRecord, stringArray } from '../shared/options.ts'; +import { unwrapNode } from '../shared/ast.ts'; import { lookupVariable, resolvesToImport } from '../shared/bindings.ts'; import { collectSchemaLocals, importedName } from '../shared/imports.ts'; +import { + constSchemaAlias as constantInitializer, + schemaIdentity, +} from '../shared/schema-identity.ts'; const SCHEMA_NAMESPACE = 'Schema'; -const EFFECT_SCHEMA_MODULE = /^effect\/(?:.*\/)?Schema$/u; /** Shape-free JSON codecs on Effect's `Schema` namespace. */ const DEFAULT_JSON_MEMBERS = ['Json', 'JsonValue']; @@ -185,13 +187,6 @@ function unwrapExpression(node: ESTree.Node): ESTree.Node { return unwrapNode(node, { wrappers: EXPRESSION_WRAPPERS, maxDepth: MAX_RESOLUTION_DEPTH }); } -function memberName(node: ESTree.MemberExpression): string | null { - return staticMemberName(node, { - templates: true, - unwrap: { wrappers: EXPRESSION_WRAPPERS, maxDepth: MAX_RESOLUTION_DEPTH }, - }); -} - function recordValue(args: ESTree.CallExpression['arguments']): ESTree.Node | null { if (args.length >= 2) { const second = args[1]; @@ -210,108 +205,6 @@ function recordObjectValue(first: ESTree.Node | undefined): ESTree.Node | null { return value; } -type Definition = Variable['defs'][number]; - -function importedSchemaIdentity(def: Definition, reexports: readonly string[]): string | null { - const specifier = def.node; - const declaration = def.parent; - if (declaration?.type !== 'ImportDeclaration' || declaration.importKind === 'type') return null; - if (specifier.type === 'ImportSpecifier' && specifier.importKind === 'type') return null; - return schemaImportSpecifierIdentity(specifier, declaration.source.value, reexports); -} - -function schemaImportSpecifierIdentity( - specifier: ESTree.Node, - source: string, - reexports: readonly string[], -): string | null { - if (EFFECT_SCHEMA_MODULE.test(source)) { - if (specifier.type === 'ImportNamespaceSpecifier') return '@schema'; - return specifier.type === 'ImportSpecifier' ? importedName(specifier) : null; - } - if (source !== 'effect' && !matchesGlobs(source, reexports)) return null; - if (specifier.type === 'ImportNamespaceSpecifier') return '@effect'; - return specifier.type === 'ImportSpecifier' && importedName(specifier) === 'Schema' - ? '@schema' - : null; -} - -function constantInitializer(def: Definition): ESTree.VariableDeclarator | null { - if (def.type !== 'Variable' || def.node.type !== 'VariableDeclarator' || def.node.init === null) - return null; - return def.node.parent?.type === 'VariableDeclaration' && def.node.parent.kind === 'const' - ? def.node - : null; -} - -function selectedSchemaMember(host: string | null, key: string | null): string | null { - if (host === '@schema') return key; - return host === '@effect' && key === 'Schema' ? '@schema' : null; -} - -function destructuredSchemaMember( - pattern: ESTree.ObjectPattern, - name: string, - host: string | null, -): string | null | undefined { - for (const property of pattern.properties) { - if ( - property.type !== 'Property' || - property.value.type !== 'Identifier' || - property.value.name !== name - ) - continue; - const key = keyName(property.key, property.computed, { templates: true }); - const result = selectedSchemaMember(host, key); - if (host === '@schema' || result !== null) return result; - } - return undefined; -} - -function identifierSchemaIdentity( - context: Context, - node: Extract, - reexports: readonly string[], - depth: number, -): string | null { - const variable = lookupVariable(context, node); - if (!variable) return null; - for (const def of variable.defs) { - if (def.type === 'ImportBinding') { - const identity = importedSchemaIdentity(def, reexports); - if (identity !== null) return identity; - } - const declarator = constantInitializer(def); - if (!declarator?.init) continue; - if (declarator.id.type === 'Identifier') - return schemaIdentity(context, declarator.init, reexports, depth + 1); - if (declarator.id.type !== 'ObjectPattern') continue; - const host = schemaIdentity(context, declarator.init, reexports, depth + 1); - const identity = destructuredSchemaMember(declarator.id, node.name, host); - if (identity !== undefined) return identity; - } - return null; -} - -/** Resolve only lexical imports and immutable same-file aliases; no cross-file or mutation inference. */ -function schemaIdentity( - context: Context, - input: ESTree.Node, - reexports: readonly string[] = [], - depth = 0, -): string | null { - if (depth > 16) return null; - const node = unwrapExpression(input); - if (node.type === 'MemberExpression') - return selectedSchemaMember( - schemaIdentity(context, node.object, reexports, depth + 1), - memberName(node), - ); - return node.type === 'Identifier' - ? identifierSchemaIdentity(context, node, reexports, depth) - : null; -} - export const rule = defineRule({ meta: { type: 'problem', @@ -390,7 +283,11 @@ export const rule = defineRule({ * namespace binding, or a bare identifier bound by `import { Record as SchemaRecord } from * "effect/Schema"`. `null` for anything that is not Effect's `Schema`. */ - const schemaReference = (node: ESTree.Node): string | null => schemaIdentity(context, node); + const schemaReference = (node: ESTree.Node): string | null => + schemaIdentity(context, node, [], 0, { + templates: true, + unwrap: { wrappers: EXPRESSION_WRAPPERS, maxDepth: MAX_RESOLUTION_DEPTH }, + }); /** `Schema.Json` / `S.Json` / `Schema["Json"]` / a bare `Json` imported from `effect/Schema`. */ const isBareJson = (node: ESTree.Node): boolean => { diff --git a/app/tools/oxlint/effect-native/rules/no-layer-or-die-outside-root.ts b/app/tools/oxlint/effect-native/rules/no-layer-or-die-outside-root.ts index 98e6249c3..af680b00f 100644 --- a/app/tools/oxlint/effect-native/rules/no-layer-or-die-outside-root.ts +++ b/app/tools/oxlint/effect-native/rules/no-layer-or-die-outside-root.ts @@ -1,4 +1,4 @@ -import { optionRecord } from '../shared/options.ts'; +import { optionRecord, positiveInteger, stringArray } from '../shared/options.ts'; /** * Audit finding: **A1** — "Establish one process-level Layer and ManagedRuntime composition model" * (`docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`). A1 counts 12 `Layer.orDie` sites while the @@ -44,7 +44,6 @@ import type { Context, ESTree } from '@oxlint/plugins'; import { collectEffectBindings } from '../shared/effect-imports.ts'; import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; -import { stringArray } from '../shared/options.ts'; import { importedName } from '../shared/imports.ts'; import { lookupVariable } from '../shared/bindings.ts'; import { asNode, keyName as staticKeyName, memberName } from '../shared/ast.ts'; @@ -96,15 +95,11 @@ interface RuleOptions { function readOptions(context: Context): RuleOptions { const record = optionRecord(context.options?.[0]); - const maxPerRoot = record.maxPerRoot; return { include: stringArray(record.include, DEFAULT_INCLUDE), exclude: stringArray(record.exclude, DEFAULT_EXCLUDE), rootFiles: stringArray(record.rootFiles, DEFAULT_ROOT_FILES), - maxPerRoot: - typeof maxPerRoot === 'number' && Number.isInteger(maxPerRoot) && maxPerRoot >= 0 - ? maxPerRoot - : 1, + maxPerRoot: positiveInteger(record.maxPerRoot, 1, 0), members: stringArray(record.members, DEFAULT_MEMBERS), reexportModules: stringArray(record.reexportModules, DEFAULT_REEXPORT_MODULES), allowTestFiles: record.allowTestFiles === true, diff --git a/app/tools/oxlint/effect-native/rules/no-literal-union-type-alias.ts b/app/tools/oxlint/effect-native/rules/no-literal-union-type-alias.ts index 81a5e01b7..d5efe47ff 100644 --- a/app/tools/oxlint/effect-native/rules/no-literal-union-type-alias.ts +++ b/app/tools/oxlint/effect-native/rules/no-literal-union-type-alias.ts @@ -101,18 +101,9 @@ const SCHEMA_LITERAL_MEMBERS = new Set(['Literals', 'Literal']); const DECLARATION_FILE = /\.d\.[cm]?ts$/u; -interface RuleOptions { - readonly minMembers: number; - readonly include: readonly string[]; - readonly ignore: readonly string[]; - readonly ignoreTests: boolean; - readonly ignoreAmbient: boolean; - readonly ignoreNullishMembers: boolean; - readonly includeEnums: boolean; - readonly allowedNames: readonly string[]; -} +type RuleOptions = Readonly>; -function readOptions(context: Context): RuleOptions { +function readOptions(context: Context) { const record = optionRecord(context.options?.[0]); return { minMembers: positiveInteger(record.minMembers, DEFAULT_MIN_MEMBERS), diff --git a/app/tools/oxlint/effect-native/rules/no-local-defect-seam.ts b/app/tools/oxlint/effect-native/rules/no-local-defect-seam.ts index bb1820477..287c7f57e 100644 --- a/app/tools/oxlint/effect-native/rules/no-local-defect-seam.ts +++ b/app/tools/oxlint/effect-native/rules/no-local-defect-seam.ts @@ -117,17 +117,9 @@ const DEFAULT_MEMBERS = [ /** Barrels that re-export Effect namespaces verbatim; `Effect` from them is Effect's `Effect`. */ const DEFAULT_REEXPORT_MODULES = ['@modern-js/plugin-bff/effect-edge']; -interface RuleOptions { - readonly include: readonly string[]; - readonly ignore: readonly string[]; - readonly seamPaths: readonly string[]; - readonly members: readonly string[]; - readonly reexportModules: readonly string[]; - readonly includeTests: boolean; - readonly includeScripts: boolean; -} +type RuleOptions = Readonly>; -function readOptions(context: Context): RuleOptions { +function readOptions(context: Context) { const record = optionRecord(context.options?.[0]); return { include: stringArray(record.include, DEFAULT_INCLUDE), diff --git a/app/tools/oxlint/effect-native/rules/no-manual-error-handling-in-scaffold-templates.ts b/app/tools/oxlint/effect-native/rules/no-manual-error-handling-in-scaffold-templates.ts index b7635f51f..f5e836077 100644 --- a/app/tools/oxlint/effect-native/rules/no-manual-error-handling-in-scaffold-templates.ts +++ b/app/tools/oxlint/effect-native/rules/no-manual-error-handling-in-scaffold-templates.ts @@ -78,6 +78,7 @@ import { maskText, driverText, emittedText, reportNode } from '../shared/scaffol * Report-only: no fixers, no suggestions. */ import { defineRule } from '@oxlint/plugins'; +import { optionRecord, stringArray } from '../shared/options.ts'; import type { Context, ESTree } from '@oxlint/plugins'; @@ -122,17 +123,8 @@ interface Match { readonly text: string; } -function stringArray(value: unknown, fallback: readonly string[]): readonly string[] { - if (!Array.isArray(value)) return fallback; - const entries = value.filter((entry): entry is string => typeof entry === 'string'); - return entries.length === value.length ? entries : fallback; -} - function readOptions(raw: unknown): RuleOptions { - const record: Record = - typeof raw === 'object' && raw !== null && !Array.isArray(raw) - ? (raw as Record) - : {}; + const record = optionRecord(raw); return { templatePaths: stringArray(record.templatePaths, DEFAULT_TEMPLATE_PATHS), patterns: stringArray(record.patterns, DEFAULT_PATTERNS), diff --git a/app/tools/oxlint/effect-native/rules/no-manual-identity-annotations.ts b/app/tools/oxlint/effect-native/rules/no-manual-identity-annotations.ts index 201972423..792cd10f8 100644 --- a/app/tools/oxlint/effect-native/rules/no-manual-identity-annotations.ts +++ b/app/tools/oxlint/effect-native/rules/no-manual-identity-annotations.ts @@ -22,7 +22,7 @@ import { fileURLToPath } from 'node:url'; import type { Context, ESTree, Variable } from '@oxlint/plugins'; import { collectEffectBindings } from '../shared/effect-imports.ts'; -import { isTestFile, matchesGlobs, scopePath as legacyScopePath } from '../shared/paths.ts'; +import { isTestFile, matchesGlobs, rootedScopePath } from '../shared/paths.ts'; import { stringArray } from '../shared/options.ts'; import { unwrapNode as unwrap, @@ -91,20 +91,9 @@ const DEFAULT_SPAN_MEMBERS = ['Effect.withSpan', 'Effect.withLogSpan']; /** Barrels that re-export Effect namespaces verbatim; `Effect` from them is Effect's `Effect`. */ const DEFAULT_REEXPORT_MODULES = ['@modern-js/plugin-bff/effect-edge']; -interface RuleOptions { - readonly include: readonly string[]; - readonly ignore: readonly string[]; - readonly seamFiles: readonly string[]; - readonly identityKeys: readonly string[]; - readonly annotationMembers: readonly string[]; - readonly spanMembers: readonly string[]; - readonly reexportModules: readonly string[]; - readonly flagSpreadHelpers: boolean; - readonly includeTests: boolean; - readonly includeScripts: boolean; -} +type RuleOptions = Readonly>; -function readOptions(context: Context): RuleOptions { +function readOptions(context: Context) { const record = optionRecord(context.options?.[0]); return { include: stringArray(record.include, DEFAULT_INCLUDE), @@ -122,12 +111,7 @@ function readOptions(context: Context): RuleOptions { /** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ function scopePath(filename: string): string { - const unified = filename.replaceAll('\\', '/'); - const fixture = - /(?:^|\/)tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\/(.*)$/u.exec(unified); - if (fixture?.[1]) return fixture[1]; - const root = fileURLToPath(new URL('../../../../', import.meta.url)).replaceAll('\\', '/'); - return unified.startsWith(root) ? unified.slice(root.length) : legacyScopePath(unified); + return rootedScopePath(filename, fileURLToPath(new URL('../../../../', import.meta.url))); } /** `x-correlation-id`, `correlation_id` and `correlationId` all collapse to `correlationid`. */ diff --git a/app/tools/oxlint/effect-native/rules/no-manual-route-param-parsing.ts b/app/tools/oxlint/effect-native/rules/no-manual-route-param-parsing.ts index efb06e9a4..4054c64bc 100644 --- a/app/tools/oxlint/effect-native/rules/no-manual-route-param-parsing.ts +++ b/app/tools/oxlint/effect-native/rules/no-manual-route-param-parsing.ts @@ -86,18 +86,7 @@ const URL_STATIC_FACTORIES = new Set(['parse']); /** Alias hops followed when resolving a binding to a global / options object. Guards cyclic writes. */ const MAX_ALIAS_DEPTH = 6; -interface RuleOptions { - readonly routeGlobs: readonly string[]; - readonly exclude: readonly string[]; - readonly untypedHooks: readonly string[]; - readonly routerModules: readonly string[]; - readonly manualConstructors: readonly string[]; - readonly flagStrictFalseOnly: boolean; - readonly flagUrlSearchParams: boolean; - readonly allowTestFiles: boolean; -} - -function readOptions(context: Context): RuleOptions { +function readOptions(context: Context) { const record = optionRecord(context.options?.[0]); return { routeGlobs: stringArray(record.routeGlobs, DEFAULT_ROUTE_GLOBS), diff --git a/app/tools/oxlint/effect-native/rules/no-nullable-service-outcome.ts b/app/tools/oxlint/effect-native/rules/no-nullable-service-outcome.ts index 3b8026ef8..7899f486a 100644 --- a/app/tools/oxlint/effect-native/rules/no-nullable-service-outcome.ts +++ b/app/tools/oxlint/effect-native/rules/no-nullable-service-outcome.ts @@ -112,18 +112,7 @@ const VOID_LIKE_TYPES = new Set([ 'TSUnknownKeyword', ]); -interface RuleOptions { - readonly include: readonly string[]; - readonly ignore: readonly string[]; - readonly ignoreTests: boolean; - readonly includeAsyncFunctions: boolean; - readonly promiseTypes: readonly string[]; - readonly checkEffect: boolean; - readonly resolveAliases: boolean; - readonly aliasDepth: number; -} - -function readOptions(context: Context): RuleOptions { +function readOptions(context: Context) { const record = optionRecord(context.options?.[0]); return { include: stringArray(record.include, DEFAULT_INCLUDE), diff --git a/app/tools/oxlint/effect-native/rules/no-per-request-key-material.ts b/app/tools/oxlint/effect-native/rules/no-per-request-key-material.ts index 0d96f3491..bd26ad35f 100644 --- a/app/tools/oxlint/effect-native/rules/no-per-request-key-material.ts +++ b/app/tools/oxlint/effect-native/rules/no-per-request-key-material.ts @@ -157,24 +157,9 @@ const FUNCTION_TYPES = new Set([ const TEMPLATE_WRAPPER_MARKER = /\b(?:Layer\.(?:effect|scoped|sync|unwrap|unwrapScoped|succeed)|Effect\.(?:cached|cachedWithTTL|cachedFunction|cachedInvalidateWithTTL|once))\s*\(/u; -interface RuleOptions { - readonly include: readonly string[]; - readonly ignore: readonly string[]; - readonly joseModules: readonly string[]; - readonly joseMembers: readonly string[]; - readonly nodeCryptoModules: readonly string[]; - readonly nodeCryptoMembers: readonly string[]; - readonly subtleMembers: readonly string[]; - readonly layerWrappers: readonly string[]; - readonly layerBuilderWrappers: readonly string[]; - readonly effectWrappers: readonly string[]; - readonly reexportModules: readonly string[]; - readonly includeTests: boolean; - readonly scanGeneratorTemplates: boolean; - readonly generatorFiles: readonly string[]; -} +type RuleOptions = Readonly>; -function readOptions(context: Context): RuleOptions { +function readOptions(context: Context) { const record = optionRecord(context.options?.[0]); return { include: stringArray(record.include, DEFAULT_INCLUDE), diff --git a/app/tools/oxlint/effect-native/rules/no-promise-shaped-port.ts b/app/tools/oxlint/effect-native/rules/no-promise-shaped-port.ts index d582ca39e..91b403b31 100644 --- a/app/tools/oxlint/effect-native/rules/no-promise-shaped-port.ts +++ b/app/tools/oxlint/effect-native/rules/no-promise-shaped-port.ts @@ -55,20 +55,7 @@ const TSX_FILE = /\.[cm]?[jt]sx$/u; type AnyNode = ESTree.Node & { readonly parent?: ESTree.Node | null }; -interface RuleOptions { - readonly include: readonly string[]; - readonly ignore: readonly string[]; - readonly includeTests: boolean; - readonly includeTsx: boolean; - readonly allowPaths: readonly string[]; - readonly driverCallbacks: readonly string[]; - readonly allowNames: readonly string[]; - readonly effectModules: readonly string[]; - readonly promiseTypes: readonly string[]; - readonly includeFunctionDeclarations: boolean; -} - -function readOptions(context: Context): RuleOptions { +function readOptions(context: Context) { const record = optionRecord(context.options?.[0]); return { include: stringArray(record.include, DEFAULT_INCLUDE), diff --git a/app/tools/oxlint/effect-native/rules/no-runtime-construction-outside-root.ts b/app/tools/oxlint/effect-native/rules/no-runtime-construction-outside-root.ts index 00488252e..055b6b227 100644 --- a/app/tools/oxlint/effect-native/rules/no-runtime-construction-outside-root.ts +++ b/app/tools/oxlint/effect-native/rules/no-runtime-construction-outside-root.ts @@ -1,4 +1,4 @@ -import { optionRecord } from '../shared/options.ts'; +import { optionRecord, positiveInteger, stringArray } from '../shared/options.ts'; /** * Audit finding: **A1** — "Establish one process-level Layer and ManagedRuntime composition model" * (`docs/architecture/EFFECT_V4_ANTIPATTERN_AUDIT.md`). A1 records "four runtime roots, 15+ manually @@ -72,7 +72,6 @@ import type { Context, ESTree } from '@oxlint/plugins'; import { collectEffectBindings, type EffectBindings } from '../shared/effect-imports.ts'; import { isTestFile, scopePath, matchesGlobs } from '../shared/paths.ts'; -import { stringArray } from '../shared/options.ts'; import { lookupVariable, resolvesToImport } from '../shared/bindings.ts'; import { unwrapNode, keyName, memberName as sharedMemberName } from '../shared/ast.ts'; import { @@ -141,16 +140,12 @@ interface RuleOptions { function readOptions(context: Context): RuleOptions { const record = optionRecord(context.options?.[0]); - const maxPerRoot = record.maxPerRoot; return { include: stringArray(record.include, DEFAULT_INCLUDE), exclude: stringArray(record.exclude, DEFAULT_EXCLUDE), rootFiles: stringArray(record.rootFiles, DEFAULT_ROOT_FILES), members: stringArray(record.members, DEFAULT_MEMBERS), - maxPerRoot: - typeof maxPerRoot === 'number' && Number.isInteger(maxPerRoot) && maxPerRoot >= 0 - ? maxPerRoot - : 1, + maxPerRoot: positiveInteger(record.maxPerRoot, 1, 0), reexportModules: stringArray(record.reexportModules, DEFAULT_REEXPORT_MODULES), includeTests: record.includeTests === true, }; diff --git a/app/tools/oxlint/effect-native/rules/no-sequential-independent-yields.ts b/app/tools/oxlint/effect-native/rules/no-sequential-independent-yields.ts index 509ce2286..8a60d9e8d 100644 --- a/app/tools/oxlint/effect-native/rules/no-sequential-independent-yields.ts +++ b/app/tools/oxlint/effect-native/rules/no-sequential-independent-yields.ts @@ -132,18 +132,9 @@ const WRAPPER_TYPES: ReadonlySet = new Set([ 'TSSatisfiesExpression', ]); -interface RuleOptions { - readonly include: readonly string[]; - readonly ignore: readonly string[]; - readonly includeTests: boolean; - readonly includeScripts: boolean; - readonly includeFunctionCallees: boolean; - readonly orderingCalleePattern: string; - readonly genMembers: readonly string[]; - readonly effectModules: readonly string[]; -} +type RuleOptions = Readonly>; -function readOptions(context: Context): RuleOptions { +function readOptions(context: Context) { const record = optionRecord(context.options?.[0]); return { include: stringArray(record.include, DEFAULT_INCLUDE), diff --git a/app/tools/oxlint/effect-native/rules/no-sync-schema-codec.ts b/app/tools/oxlint/effect-native/rules/no-sync-schema-codec.ts index dcfcb2b73..e580d001c 100644 --- a/app/tools/oxlint/effect-native/rules/no-sync-schema-codec.ts +++ b/app/tools/oxlint/effect-native/rules/no-sync-schema-codec.ts @@ -1,8 +1,3 @@ -import { - constSchemaAlias as constAlias, - destructuredSchemaIdentity as destructuredIdentity, -} from '../shared/schema-identity.ts'; -import { optionRecord } from '../shared/options.ts'; /** * Audit findings: **A3** — "Replace ambient configuration with Config, ConfigProvider, and Redacted" * and **A7** — "Give topology, composition, and authorization evidence shared Schemas" @@ -65,13 +60,13 @@ import { optionRecord } from '../shared/options.ts'; */ import { defineRule } from '@oxlint/plugins'; -import type { Context, ESTree, Variable } from '@oxlint/plugins'; +import type { Context, ESTree } from '@oxlint/plugins'; import { isTestFile, matchesGlobs, scopePath } from '../shared/paths.ts'; -import { booleanOption, stringArray } from '../shared/options.ts'; -import { keyName, memberName, unwrapNode } from '../shared/ast.ts'; +import { booleanOption, optionRecord, stringArray } from '../shared/options.ts'; +import { keyName } from '../shared/ast.ts'; import { lookupVariable } from '../shared/bindings.ts'; -import { importedName } from '../shared/imports.ts'; +import { schemaIdentity } from '../shared/schema-identity.ts'; import { isNonReferencePosition, isInErasedTypePosition } from '../shared/reference-positions.ts'; const EFFECT_SCHEMA_MODULE = /^effect\/(?:.*\/)?Schema$/u; @@ -125,80 +120,6 @@ function isDeclarationPosition(node: ESTree.Node): boolean { return isNonReferencePosition(node, { variableBindings: true }) || isInErasedTypePosition(node); } -type Definition = Variable['defs'][number]; - -function schemaMember(host: string | null, member: string | null): string | null { - if (host === '@schema') return member; - return host === '@effect' && member === 'Schema' ? '@schema' : null; -} - -function importIdentity(def: Definition, reexports: readonly string[]): string | null { - const specifier = def.node; - const declaration = def.parent; - if (declaration?.type !== 'ImportDeclaration' || declaration.importKind === 'type') return null; - if (specifier.type === 'ImportSpecifier' && specifier.importKind === 'type') return null; - if (EFFECT_SCHEMA_MODULE.test(declaration.source.value)) { - return submoduleImportIdentity(specifier); - } - if (declaration.source.value !== 'effect' && !matchesGlobs(declaration.source.value, reexports)) - return null; - return rootImportIdentity(specifier); -} - -function submoduleImportIdentity(specifier: ESTree.Node): string | null { - if (specifier.type === 'ImportNamespaceSpecifier') return '@schema'; - return specifier.type === 'ImportSpecifier' ? importedName(specifier) : null; -} - -function rootImportIdentity(specifier: ESTree.Node): string | null { - if (specifier.type === 'ImportNamespaceSpecifier') return '@effect'; - return specifier.type === 'ImportSpecifier' && importedName(specifier) === 'Schema' - ? '@schema' - : null; -} - -function identifierIdentity( - context: Context, - node: Extract, - reexports: readonly string[], - depth: number, -): string | null { - const variable = lookupVariable(context, node); - if (!variable) return null; - for (const def of variable.defs) { - if (def.type === 'ImportBinding') { - const identity = importIdentity(def, reexports); - if (identity !== null) return identity; - } - const alias = constAlias(def); - if (!alias?.init) continue; - if (alias.id.type === 'Identifier') - return schemaIdentity(context, alias.init, reexports, depth + 1); - if (alias.id.type !== 'ObjectPattern') continue; - const host = schemaIdentity(context, alias.init, reexports, depth + 1); - const identity = destructuredIdentity(alias.id, node.name, host); - if (identity !== undefined) return identity; - } - return null; -} - -/** Local until shared schemaIdentity preserves cooked template and wrapped computed keys. */ -function schemaIdentity( - context: Context, - input: ESTree.Node, - reexports: readonly string[] = [], - depth = 0, -): string | null { - if (depth > 16) return null; - const node: ESTree.Node = unwrapNode(input); - if (node.type === 'MemberExpression') - return schemaMember( - schemaIdentity(context, node.object, reexports, depth + 1), - memberName(node, { templates: true, unwrap: {} }), - ); - return node.type === 'Identifier' ? identifierIdentity(context, node, reexports, depth) : null; -} - export const rule = defineRule({ meta: { type: 'problem', @@ -211,14 +132,6 @@ export const rule = defineRule({ 'so the failure stays in a typed channel.', }, messages: { - syncCodec: - '`{{namespace}}.{{member}}` throws instead of failing typed: the `SchemaError` escapes as a defect ' + - 'or gets caught and collapsed, discarding the `ParseIssue` (audit A3 — ambient configuration parsed ' + - 'with synchronous Schema decoding and throws; audit A7 — topology/authorization evidence decoded ' + - 'with `JSON.parse` + sync Schema + casts). Use `{{namespace}}.{{effectful}}` (or ' + - '`{{namespace}}.{{result}}` where no Effect context exists) so the decode failure stays in the ' + - 'error channel, and decode configuration through `Config.schema` with a root `ConfigProvider` ' + - 'instead of parsing it inline. Framework config roots and tests are already allowed by this rule.', syncCodecBare: '`{{member}}` (imported from `effect/Schema`) throws instead of failing typed: the `SchemaError` ' + 'escapes as a defect or gets caught and collapsed, discarding the `ParseIssue` (audit A3/A7). ' + @@ -271,7 +184,10 @@ export const rule = defineRule({ }; return { MemberExpression(node) { - const member = schemaIdentity(context, node, options.reexportModules); + const member = schemaIdentity(context, node, options.reexportModules, 0, { + templates: true, + unwrap: {}, + }); if (member !== null && members.has(member)) report(node, member); }, Identifier(node) { @@ -279,12 +195,21 @@ export const rule = defineRule({ // Destructured aliases report at capture, not at every subsequent use. const variable = lookupVariable(context, node); if (!variable?.defs.some((def) => def.type === 'ImportBinding')) return; - const member = schemaIdentity(context, node, options.reexportModules); + const member = schemaIdentity(context, node, options.reexportModules, 0, { + templates: true, + unwrap: {}, + }); if (member !== null && members.has(member)) report(node, member); }, VariableDeclarator(node) { if (node.id.type !== 'ObjectPattern' || node.init === null) return; - if (schemaIdentity(context, node.init, options.reexportModules) !== '@schema') return; + if ( + schemaIdentity(context, node.init, options.reexportModules, 0, { + templates: true, + unwrap: {}, + }) !== '@schema' + ) + return; for (const property of node.id.properties) { if (property.type !== 'Property') continue; const member = keyName(property.key, property.computed); diff --git a/app/tools/oxlint/effect-native/rules/prefer-match-over-tag-switch.ts b/app/tools/oxlint/effect-native/rules/prefer-match-over-tag-switch.ts index c85b8784a..a2a8c8dc8 100644 --- a/app/tools/oxlint/effect-native/rules/prefer-match-over-tag-switch.ts +++ b/app/tools/oxlint/effect-native/rules/prefer-match-over-tag-switch.ts @@ -115,19 +115,9 @@ const MAX_UNWRAP_DEPTH = 32; /** Longest discriminant rendering embedded in a diagnostic message. */ const MAX_DISCRIMINANT_LENGTH = 60; -interface RuleOptions { - readonly tagProperties: readonly string[]; - readonly discriminantProperties: readonly string[]; - readonly minLiteralCases: number; - readonly allowExhaustive: boolean; - readonly exhaustiveHelpers: readonly string[]; - readonly adtTags: readonly string[]; - readonly include: readonly string[]; - readonly ignore: readonly string[]; - readonly ignoreTests: boolean; -} +type RuleOptions = Readonly>; -function readOptions(context: Context): RuleOptions { +function readOptions(context: Context) { const record = optionRecord(context.options?.[0]); return { tagProperties: stringArray(record.tagProperties, DEFAULT_TAG_PROPERTIES), diff --git a/app/tools/oxlint/effect-native/rules/require-context-service-for-service-interface.ts b/app/tools/oxlint/effect-native/rules/require-context-service-for-service-interface.ts index 183409bb8..5ddda0662 100644 --- a/app/tools/oxlint/effect-native/rules/require-context-service-for-service-interface.ts +++ b/app/tools/oxlint/effect-native/rules/require-context-service-for-service-interface.ts @@ -63,27 +63,7 @@ const MAX_TYPE_DEPTH = 12; type AnyNode = ESTree.Node & { readonly parent?: ESTree.Node | null }; -interface RuleOptions { - readonly include: readonly string[]; - readonly ignore: readonly string[]; - readonly includeTests: boolean; - readonly includeTsx: boolean; - readonly exportedOnly: boolean; - readonly includeReturnTypeAliases: boolean; - readonly includePromiseMembers: boolean; - readonly allowLayerConstruction: boolean; - readonly requireTagPerContract: boolean; - readonly serviceNamePattern: string; - readonly dataTypePattern: string; - readonly effectTypes: readonly string[]; - readonly promiseTypes: readonly string[]; - readonly tagMembers: readonly string[]; - readonly tagNamespaces: readonly string[]; - readonly layerMembers: readonly string[]; - readonly allowNames: readonly string[]; -} - -function readOptions(context: Context): RuleOptions { +function readOptions(context: Context) { const record = optionRecord(context.options?.[0]); return { include: stringArray(record.include, DEFAULT_INCLUDE), diff --git a/app/tools/oxlint/effect-native/rules/require-observability-layers-at-runtime-root.ts b/app/tools/oxlint/effect-native/rules/require-observability-layers-at-runtime-root.ts index b2760ea2c..ebcd41fb0 100644 --- a/app/tools/oxlint/effect-native/rules/require-observability-layers-at-runtime-root.ts +++ b/app/tools/oxlint/effect-native/rules/require-observability-layers-at-runtime-root.ts @@ -25,7 +25,7 @@ import { fileURLToPath } from 'node:url'; import type { Context, ESTree, Variable } from '@oxlint/plugins'; -import { isTestFile, normalisePath, matchesGlobs } from '../shared/paths.ts'; +import { isTestFile, rootedScopePath, matchesGlobs } from '../shared/paths.ts'; import { stringArray, booleanOption as boolOption } from '../shared/options.ts'; import { unwrapNode as unwrap, memberName as sharedMemberName, keyName } from '../shared/ast.ts'; import { lookupVariable, resolvesToImport } from '../shared/bindings.ts'; @@ -35,12 +35,6 @@ import { isNonReferencePosition as sharedNonReferencePosition } from '../shared/ const EFFECT_MODULE = /^effect(?:\/.*)?$/u; const EFFECT_ROOT_MODULE = 'effect'; -/** - * Fixture files live at `tools/oxlint//tests/fixtures//{valid,invalid}/`. - * Stripping that prefix lets fixtures exercise the real production `include`/`rootFiles` defaults. - */ -const FIXTURE_PREFIX = /^tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\//u; - /** A6 targets the deployed hosts; `scripts/**` joins only through `includeScripts`. */ const DEFAULT_INCLUDE = ['apps/**', 'verticals/**', 'packages/**']; @@ -87,33 +81,11 @@ const DEFAULT_MINIMUM_LOG_LEVEL_MEMBERS = [ const LOGGER_NAMESPACE = 'Logger'; const TRACER_NAMESPACE = 'Tracer'; -interface RequireOptions { - readonly logger: boolean; - readonly tracer: boolean; - readonly minimumLogLevel: boolean; -} - -interface RuleOptions { - readonly include: readonly string[]; - readonly ignore: readonly string[]; - readonly rootFiles: readonly string[]; - readonly runtimeMembers: readonly string[]; - readonly runtimeTypeNames: readonly string[]; - readonly otelModules: readonly string[]; - readonly reexportModules: readonly string[]; - readonly minimumLogLevelMembers: readonly string[]; - readonly includeScripts: boolean; - readonly includeTests: boolean; - readonly require: RequireOptions; -} +type RuleOptions = Readonly>; -function readOptions(context: Context): RuleOptions { +function readOptions(context: Context) { const record = optionRecord(context.options?.[0]); - const rawRequire = record.require; - const requireRecord: Record = - typeof rawRequire === 'object' && rawRequire !== null && !Array.isArray(rawRequire) - ? (rawRequire as Record) - : {}; + const requireRecord = optionRecord(record.require); return { include: stringArray(record.include, DEFAULT_INCLUDE), ignore: stringArray(record.ignore, DEFAULT_IGNORE), @@ -138,14 +110,7 @@ function readOptions(context: Context): RuleOptions { /** Repo-relative path with the fixture prefix removed, so fixtures behave like real source paths. */ function scopePath(filename: string): string { - const unified = filename.replaceAll('\\', '/'); - const fixture = - /(?:^|\/)tools\/oxlint\/[^/]+\/tests\/fixtures\/[^/]+\/(?:valid|invalid)\/(.*)$/u.exec(unified); - if (fixture?.[1]) return fixture[1]; - const root = fileURLToPath(new URL('../../../../', import.meta.url)).replaceAll('\\', '/'); - return unified.startsWith(root) - ? unified.slice(root.length) - : normalisePath(unified).replace(FIXTURE_PREFIX, ''); + return rootedScopePath(filename, fileURLToPath(new URL('../../../../', import.meta.url))); } /** `["ManagedRuntime.make"]` → `Set{"ManagedRuntime.make"}`, ignoring malformed entries. */ diff --git a/app/tools/oxlint/effect-native/shared/schema-identity.ts b/app/tools/oxlint/effect-native/shared/schema-identity.ts index 0fbb0f336..0e4aaa9fb 100644 --- a/app/tools/oxlint/effect-native/shared/schema-identity.ts +++ b/app/tools/oxlint/effect-native/shared/schema-identity.ts @@ -1,4 +1,5 @@ import type { Context, ESTree, Variable } from '@oxlint/plugins'; +import type { StringOptions } from './ast.ts'; import { keyName, memberName, unwrapNode } from './ast.ts'; import { lookupVariable } from './bindings.ts'; import { importedName } from './imports.ts'; @@ -54,7 +55,7 @@ export function constSchemaAlias(definition: Definition): ESTree.VariableDeclara ? declarator : null; } -export function destructuredSchemaIdentity( +function destructuredSchemaIdentity( pattern: ESTree.ObjectPattern, name: string, host: string | null, @@ -77,6 +78,7 @@ function identifierIdentity( node: ESTree.IdentifierReference | ESTree.IdentifierName | ESTree.BindingIdentifier, reexports: readonly string[], depth: number, + syntax: StringOptions, ): string | null { const variable = lookupVariable(context, node); if (!variable) return null; @@ -88,9 +90,9 @@ function identifierIdentity( const alias = constSchemaAlias(definition); if (!alias?.init) continue; if (alias.id.type === 'Identifier') - return schemaIdentity(context, alias.init, reexports, depth + 1); + return schemaIdentity(context, alias.init, reexports, depth + 1, syntax); if (alias.id.type !== 'ObjectPattern') continue; - const host = schemaIdentity(context, alias.init, reexports, depth + 1); + const host = schemaIdentity(context, alias.init, reexports, depth + 1, syntax); const identity = destructuredSchemaIdentity(alias.id, node.name, host); if (identity !== undefined) return identity; } @@ -99,19 +101,23 @@ function identifierIdentity( /** Schema lexical identity: @effect root, @schema namespace, or direct member; const-only aliases, * glob barrels, no type-only imports, no write/typechecker inference, bounded to 16 hops. + * Member syntax is caller-owned; defaults remain literal-only. Alias recursion retains that policy. */ export function schemaIdentity( context: Context, input: ESTree.Node, reexports: readonly string[] = [], depth = 0, + syntax: StringOptions = { templates: false }, ): string | null { if (depth > 16) return null; - const node = unwrapNode(input); + const node = unwrapNode(input, syntax.unwrap); if (node.type === 'MemberExpression') return schemaMember( - schemaIdentity(context, node.object, reexports, depth + 1), - memberName(node), + schemaIdentity(context, node.object, reexports, depth + 1, syntax), + memberName(node, syntax), ); - return node.type === 'Identifier' ? identifierIdentity(context, node, reexports, depth) : null; + return node.type === 'Identifier' + ? identifierIdentity(context, node, reexports, depth, syntax) + : null; } diff --git a/app/tools/oxlint/effect-native/tests/shared-helpers.test.mts b/app/tools/oxlint/effect-native/tests/shared-helpers.test.mts index 1dcbde79a..3b7e9e73e 100644 --- a/app/tools/oxlint/effect-native/tests/shared-helpers.test.mts +++ b/app/tools/oxlint/effect-native/tests/shared-helpers.test.mts @@ -286,3 +286,27 @@ test('shared reference policies preserve declaration keys and TS expression edge assert.equal(isInTypePosition(schema[0]!, new Set(['TSAsExpression'])), false); assert.equal(isInTypePosition(schema[1]!, new Set(['TSAsExpression'])), true); }); + +test('Schema identity syntax policy survives aliases without widening default members', () => { + for (const key of ['`decodeUnknownSync`', '("decodeUnknownSync" as const)']) { + const program = parse( + `import { Schema } from "effect"; const S = Schema; const codec = S[${key}]; codec;`, + ); + const { context } = contextFor(program); + const node = lastExpression(program); + assert.equal(schemaIdentity(context, node), null); + assert.equal( + schemaIdentity(context, node, [], 0, { templates: true, unwrap: {} }), + 'decodeUnknownSync', + ); + } +}); + +test('Schema identity preserves rule-specific expression wrapper limits', () => { + const program = parse('import { Schema } from "effect"; const S = Schema as unknown; S.Json;'); + const { context } = contextFor(program); + const node = lastExpression(program); + assert.equal(schemaIdentity(context, node), 'Json'); + assert.equal(schemaIdentity(context, node, [], 0, { unwrap: { wrappers: new Set() } }), null); + assert.equal(schemaIdentity(context, node, [], 0, { unwrap: { maxDepth: 0 } }), null); +}); diff --git a/app/verticals/party-registry/api/engagement-profile-problems.ts b/app/verticals/party-registry/api/engagement-profile-problems.ts index 1de34e484..2a35e7707 100644 --- a/app/verticals/party-registry/api/engagement-profile-problems.ts +++ b/app/verticals/party-registry/api/engagement-profile-problems.ts @@ -1,5 +1,5 @@ +import { failAuthenticatedProblem } from './fail-authenticated-problem.ts'; import type { ActionCoreError } from '@app/core-runtime'; -import { Effect, HttpEffect, HttpServerResponse } from '@modern-js/plugin-bff/effect-edge'; import { Match, Result, Schema } from 'effect'; import { @@ -118,14 +118,9 @@ export const engagementProblem = { ), }; -const bearerChallenge = HttpEffect.appendPreResponseHandler((_request, response) => - Effect.succeed(HttpServerResponse.setHeader(response, 'www-authenticate', 'Bearer')), -); export const isEngagementAuthenticationProblem = Schema.is(ContactsAuthenticationProblemSchema); export const failEngagementProblem = (mapped: Problem) => - (isEngagementAuthenticationProblem(mapped) ? bearerChallenge : Effect.void).pipe( - Effect.andThen(Effect.fail(mapped)), - ); + failAuthenticatedProblem(mapped, isEngagementAuthenticationProblem); export const mapEngagementActionProblem = (error: EngagementActionError): ContactsProblem => Match.value(error).pipe( diff --git a/app/verticals/party-registry/api/engagement-profile-server.ts b/app/verticals/party-registry/api/engagement-profile-server.ts index de80eb44e..674b656f5 100644 --- a/app/verticals/party-registry/api/engagement-profile-server.ts +++ b/app/verticals/party-registry/api/engagement-profile-server.ts @@ -42,77 +42,71 @@ const attachActionProblem = (error: EngagementActionError): EngagementAttachProb return mapEngagementAttachProblem(mapped); }; -const runEngagementAction = < - PayloadSchema extends Schema.ConstraintDecoder & Schema.ConstraintEncoder, - ResultSchema extends Schema.ConstraintDecoder, - DomainErrorSchema extends Schema.ConstraintDecoder, - DomainEvents extends DomainEventContractMap, - Owner extends string, - Services, - Requirements, - PublicProblem extends ContactsProblem, ->( - registration: ActionRegistration< - PayloadSchema, - ResultSchema, - DomainErrorSchema, - DomainEvents, - Owner, +const engagementActionHandler = + < + PayloadSchema extends Schema.ConstraintDecoder & Schema.ConstraintEncoder, + ResultSchema extends Schema.ConstraintDecoder, + DomainErrorSchema extends Schema.ConstraintDecoder, + DomainEvents extends DomainEventContractMap, + Owner extends string, Services, - Requirements - >, - payload: Schema.Schema.Type, - headers: ContactsMutationHeaders, - requestHeaders: RequestHeaders, - mapError: (error: EngagementActionError) => PublicProblem, -) => - runActionHttp({ - endpointHeaders: { - idempotencyKey: headers['idempotency-key'], - traceId: requestHeaders['x-trace-id'], - }, - internalProblem: engagementProblem.internal, - invalidCorrelationProblem: engagementProblem.invalid, - mapError, + Requirements, + PublicProblem extends ContactsProblem, + >( + registration: ActionRegistration< + PayloadSchema, + ResultSchema, + DomainErrorSchema, + DomainEvents, + Owner, + Services, + Requirements + >, + mapError: (error: EngagementActionError) => PublicProblem, + ) => + ({ payload, - registration, - requestHeaders: { - authorization: Redacted.make(requestHeaders['authorization']), - 'x-correlation-id': requestHeaders['x-correlation-id'], - }, - }).pipe(Effect.catchIf(isEngagementAuthenticationProblem, failEngagementProblem)); + headers, + request, + }: { + readonly payload: Schema.Schema.Type; + readonly headers: ContactsMutationHeaders; + readonly request: { readonly headers: RequestHeaders }; + }) => { + const requestHeaders = request.headers; + return runActionHttp({ + endpointHeaders: { + idempotencyKey: headers['idempotency-key'], + traceId: requestHeaders['x-trace-id'], + }, + internalProblem: engagementProblem.internal, + invalidCorrelationProblem: engagementProblem.invalid, + mapError, + payload, + registration, + requestHeaders: { + authorization: Redacted.make(requestHeaders['authorization']), + 'x-correlation-id': requestHeaders['x-correlation-id'], + }, + }).pipe(Effect.catchIf(isEngagementAuthenticationProblem, failEngagementProblem)); + }; export const organizationEngagementMutationsLive = HttpApiBuilder.group( partyRegistryApi, 'organizationEngagementMutations', (handlers) => handlers - .handle('attach', ({ headers, payload, request }) => - runEngagementAction( - attachOrganizationEngagementAction, - payload, - headers, - request.headers, - attachActionProblem, - ), + .handle( + 'attach', + engagementActionHandler(attachOrganizationEngagementAction, attachActionProblem), ) - .handle('archive', ({ headers, payload, request }) => - runEngagementAction( - archiveOrganizationEngagementAction, - payload, - headers, - request.headers, - mapEngagementActionProblem, - ), + .handle( + 'archive', + engagementActionHandler(archiveOrganizationEngagementAction, mapEngagementActionProblem), ) - .handle('unarchive', ({ headers, payload, request }) => - runEngagementAction( - unarchiveOrganizationEngagementAction, - payload, - headers, - request.headers, - mapEngagementActionProblem, - ), + .handle( + 'unarchive', + engagementActionHandler(unarchiveOrganizationEngagementAction, mapEngagementActionProblem), ), ); @@ -121,32 +115,14 @@ const personEngagementMutationsLive = HttpApiBuilder.group( 'personEngagementMutations', (handlers) => handlers - .handle('attach', ({ headers, payload, request }) => - runEngagementAction( - attachPersonEngagementAction, - payload, - headers, - request.headers, - attachActionProblem, - ), + .handle('attach', engagementActionHandler(attachPersonEngagementAction, attachActionProblem)) + .handle( + 'archive', + engagementActionHandler(archivePersonEngagementAction, mapEngagementActionProblem), ) - .handle('archive', ({ headers, payload, request }) => - runEngagementAction( - archivePersonEngagementAction, - payload, - headers, - request.headers, - mapEngagementActionProblem, - ), - ) - .handle('unarchive', ({ headers, payload, request }) => - runEngagementAction( - unarchivePersonEngagementAction, - payload, - headers, - request.headers, - mapEngagementActionProblem, - ), + .handle( + 'unarchive', + engagementActionHandler(unarchivePersonEngagementAction, mapEngagementActionProblem), ), ); diff --git a/app/verticals/party-registry/api/fail-authenticated-problem.ts b/app/verticals/party-registry/api/fail-authenticated-problem.ts new file mode 100644 index 000000000..659acc5ad --- /dev/null +++ b/app/verticals/party-registry/api/fail-authenticated-problem.ts @@ -0,0 +1,13 @@ +import { Effect, HttpEffect, HttpServerResponse } from '@modern-js/plugin-bff/effect-edge'; + +const bearerChallenge = HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed(HttpServerResponse.setHeader(response, 'www-authenticate', 'Bearer')), +); + +export const failAuthenticatedProblem = ( + mapped: Problem, + isAuthentication: (problem: Problem) => boolean, +) => + (isAuthentication(mapped) ? bearerChallenge : Effect.void).pipe( + Effect.andThen(Effect.fail(mapped)), + ); diff --git a/app/verticals/party-registry/api/party-command-problems.ts b/app/verticals/party-registry/api/party-command-problems.ts index 3020e9e11..7ee787a01 100644 --- a/app/verticals/party-registry/api/party-command-problems.ts +++ b/app/verticals/party-registry/api/party-command-problems.ts @@ -1,10 +1,6 @@ +import { failAuthenticatedProblem } from './fail-authenticated-problem.ts'; import type { ActionCoreError } from '@app/core-runtime'; -import { - Effect, - HttpApiMiddleware, - HttpEffect, - HttpServerResponse, -} from '@modern-js/plugin-bff/effect-edge'; +import { Effect, HttpApiMiddleware } from '@modern-js/plugin-bff/effect-edge'; import { Match, Schema } from 'effect'; import { @@ -131,16 +127,11 @@ export const partyCommandProblem = { }), }; -const bearerChallenge = HttpEffect.appendPreResponseHandler((_request, response) => - Effect.succeed(HttpServerResponse.setHeader(response, 'www-authenticate', 'Bearer')), -); export const isPartyCommandAuthenticationProblem = Schema.is( PartyCommandAuthenticationProblemSchema, ); export const failPartyCommandProblem = (mapped: Problem) => - (isPartyCommandAuthenticationProblem(mapped) ? bearerChallenge : Effect.void).pipe( - Effect.andThen(Effect.fail(mapped)), - ); + failAuthenticatedProblem(mapped, isPartyCommandAuthenticationProblem); export const partyCommandSchemaErrorLive = HttpApiMiddleware.layerSchemaErrorTransform( PartyCommandSchemaErrorMiddleware, diff --git a/app/verticals/party-registry/shared/command-api.ts b/app/verticals/party-registry/shared/command-api.ts index 6e3fbd527..1ebbcada4 100644 --- a/app/verticals/party-registry/shared/command-api.ts +++ b/app/verticals/party-registry/shared/command-api.ts @@ -103,145 +103,117 @@ export { AddContactPointResultSchema, } from './actions/add-contact-point.ts'; export type AddContactPointPayload = typeof AddContactPointPayloadSchema.Type; -export type AddContactPointResult = typeof AddContactPointResultSchema.Type; export { AddPartyOfficialIdentifierPayloadSchema, AddPartyOfficialIdentifierResultSchema, } from './actions/add-party-official-identifier.ts'; export type AddPartyOfficialIdentifierPayload = typeof AddPartyOfficialIdentifierPayloadSchema.Type; -export type AddPartyOfficialIdentifierResult = typeof AddPartyOfficialIdentifierResultSchema.Type; export { ArchivePartyPayloadSchema, ArchivePartyResultSchema } from './actions/archive-party.ts'; export type ArchivePartyPayload = typeof ArchivePartyPayloadSchema.Type; -export type ArchivePartyResult = typeof ArchivePartyResultSchema.Type; export { ConfirmDuplicatePartiesPayloadSchema, ConfirmDuplicatePartiesResultSchema, } from './actions/confirm-duplicate-parties.ts'; export type ConfirmDuplicatePartiesPayload = typeof ConfirmDuplicatePartiesPayloadSchema.Type; -export type ConfirmDuplicatePartiesResult = typeof ConfirmDuplicatePartiesResultSchema.Type; export { CorrectPartyFactPayloadSchema, CorrectPartyFactResultSchema, } from './actions/correct-party-fact.ts'; export type CorrectPartyFactPayload = typeof CorrectPartyFactPayloadSchema.Type; -export type CorrectPartyFactResult = typeof CorrectPartyFactResultSchema.Type; export { CounterpartyCreatePayloadSchema, CounterpartyCreateResultSchema, } from './actions/counterparty-create.ts'; export type CounterpartyCreatePayload = typeof CounterpartyCreatePayloadSchema.Type; -export type CounterpartyCreateResult = typeof CounterpartyCreateResultSchema.Type; export { CounterpartyRoleAddPayloadSchema, CounterpartyRoleAddResultSchema, } from './actions/counterparty-role-add.ts'; export type CounterpartyRoleAddPayload = typeof CounterpartyRoleAddPayloadSchema.Type; -export type CounterpartyRoleAddResult = typeof CounterpartyRoleAddResultSchema.Type; export { CounterpartyRoleEndPayloadSchema, CounterpartyRoleEndResultSchema, } from './actions/counterparty-role-end.ts'; export type CounterpartyRoleEndPayload = typeof CounterpartyRoleEndPayloadSchema.Type; -export type CounterpartyRoleEndResult = typeof CounterpartyRoleEndResultSchema.Type; export { CreatePartyRelationshipPayloadSchema, CreatePartyRelationshipResultSchema, } from './domain/relationship-contract.ts'; export type CreatePartyRelationshipPayload = typeof CreatePartyRelationshipPayloadSchema.Type; -export type CreatePartyRelationshipResult = typeof CreatePartyRelationshipResultSchema.Type; export { CreatePartyPayloadJsonSchema, CreatePartyPayloadSchema, CreatePartyResultSchema, } from './actions/create-party.ts'; export type CreatePartyPayload = typeof CreatePartyPayloadSchema.Type; -export type CreatePartyResult = typeof CreatePartyResultSchema.Type; export { DismissDuplicateCandidatePayloadSchema, DismissDuplicateCandidateResultSchema, } from './actions/dismiss-duplicate-candidate.ts'; export type DismissDuplicateCandidatePayload = typeof DismissDuplicateCandidatePayloadSchema.Type; -export type DismissDuplicateCandidateResult = typeof DismissDuplicateCandidateResultSchema.Type; export { EndContactPointPayloadSchema, EndContactPointResultSchema, } from './actions/end-contact-point.ts'; export type EndContactPointPayload = typeof EndContactPointPayloadSchema.Type; -export type EndContactPointResult = typeof EndContactPointResultSchema.Type; export { EndPartyOfficialIdentifierPayloadSchema, EndPartyOfficialIdentifierResultSchema, } from './actions/end-party-official-identifier.ts'; export type EndPartyOfficialIdentifierPayload = typeof EndPartyOfficialIdentifierPayloadSchema.Type; -export type EndPartyOfficialIdentifierResult = typeof EndPartyOfficialIdentifierResultSchema.Type; export { EndPartyRelationshipPayloadSchema, ChangePartyRelationshipResultSchema as EndPartyRelationshipResultSchema, } from './domain/relationship-contract.ts'; export type EndPartyRelationshipPayload = typeof EndPartyRelationshipPayloadSchema.Type; -export type EndPartyRelationshipResult = typeof EndPartyRelationshipResultSchema.Type; export { MarkDuplicateCandidateNeedsEvidencePayloadSchema, MarkDuplicateCandidateNeedsEvidenceResultSchema, } from './actions/mark-duplicate-candidate-needs-evidence.ts'; export type MarkDuplicateCandidateNeedsEvidencePayload = typeof MarkDuplicateCandidateNeedsEvidencePayloadSchema.Type; -export type MarkDuplicateCandidateNeedsEvidenceResult = - typeof MarkDuplicateCandidateNeedsEvidenceResultSchema.Type; export { MatchPartyPayloadSchema, MatchPartyResultSchema } from './actions/match-party.ts'; export type MatchPartyPayload = typeof MatchPartyPayloadSchema.Type; -export type MatchPartyResult = typeof MatchPartyResultSchema.Type; export { RequestSearchRebuildPayloadSchema, RequestSearchRebuildResultSchema, } from './actions/request-search-rebuild.ts'; export type RequestSearchRebuildPayload = typeof RequestSearchRebuildPayloadSchema.Type; -export type RequestSearchRebuildResult = typeof RequestSearchRebuildResultSchema.Type; export { ResolveDuplicateCandidateCreatePayloadSchema, ResolveDuplicateCandidateCreateResultSchema, } from './actions/resolve-duplicate-candidate-create.ts'; export type ResolveDuplicateCandidateCreatePayload = typeof ResolveDuplicateCandidateCreatePayloadSchema.Type; -export type ResolveDuplicateCandidateCreateResult = - typeof ResolveDuplicateCandidateCreateResultSchema.Type; export { ResolveDuplicateCandidateMatchPayloadSchema, ResolveDuplicateCandidateMatchResultSchema, } from './actions/resolve-duplicate-candidate-match.ts'; export type ResolveDuplicateCandidateMatchPayload = typeof ResolveDuplicateCandidateMatchPayloadSchema.Type; -export type ResolveDuplicateCandidateMatchResult = - typeof ResolveDuplicateCandidateMatchResultSchema.Type; export { UnarchivePartyPayloadSchema, UnarchivePartyResultSchema, } from './actions/unarchive-party.ts'; export type UnarchivePartyPayload = typeof UnarchivePartyPayloadSchema.Type; -export type UnarchivePartyResult = typeof UnarchivePartyResultSchema.Type; export { UpdateContactPointPayloadSchema, UpdateContactPointResultSchema, } from './actions/update-contact-point.ts'; export type UpdateContactPointPayload = typeof UpdateContactPointPayloadSchema.Type; -export type UpdateContactPointResult = typeof UpdateContactPointResultSchema.Type; export { UpdatePartyOfficialIdentifierPayloadSchema, UpdatePartyOfficialIdentifierResultSchema, } from './actions/update-party-official-identifier.ts'; export type UpdatePartyOfficialIdentifierPayload = typeof UpdatePartyOfficialIdentifierPayloadSchema.Type; -export type UpdatePartyOfficialIdentifierResult = - typeof UpdatePartyOfficialIdentifierResultSchema.Type; export { UpdatePartyRelationshipPayloadSchema, ChangePartyRelationshipResultSchema as UpdatePartyRelationshipResultSchema, } from './domain/relationship-contract.ts'; export type UpdatePartyRelationshipPayload = typeof UpdatePartyRelationshipPayloadSchema.Type; -export type UpdatePartyRelationshipResult = typeof UpdatePartyRelationshipResultSchema.Type; export { UpdatePartyPayloadSchema, UpdatePartyResultSchema } from './actions/update-party.ts'; export type UpdatePartyPayload = typeof UpdatePartyPayloadSchema.Type; -export type UpdatePartyResult = typeof UpdatePartyResultSchema.Type; // Absence reaches the explicit 428 mapping; every typed command client requires a key. export const PartyCommandHeadersSchema = Schema.Struct({ @@ -333,14 +305,12 @@ export const PartyCommandUnavailableProblemSchema = makeRetryableProblemDetailsS 503, ); -const PartyCommandInvocationIdSchema = ActionInvocationIdSchema; - export const PartyCommandAlreadyCommittedProblemSchema = makeProblemDetailsSchema( 'PartyCommandAlreadyCommittedProblem', 409, { code: Schema.Literal('action_already_committed'), - invocationId: PartyCommandInvocationIdSchema, + invocationId: ActionInvocationIdSchema, resolution: Schema.Literal('REFRESH_GOVERNED_READS'), retryCommand: Schema.Literal(false), }, @@ -351,21 +321,21 @@ export const PartyCommandCommitIndeterminateProblemSchema = makeProblemDetailsSc 'PartyCommandCommitIndeterminateProblem', 503, { - invocationId: PartyCommandInvocationIdSchema, + invocationId: ActionInvocationIdSchema, resolution: Schema.Literal('RESOLVE_COMMIT'), retryCommand: Schema.Literal(false), }, ); export const ResolvePartyCommandCommitPayloadSchema = Schema.Struct({ - invocationId: PartyCommandInvocationIdSchema, + invocationId: ActionInvocationIdSchema, }); export type ResolvePartyCommandCommitPayload = typeof ResolvePartyCommandCommitPayloadSchema.Type; export const ResolvePartyCommandCommitResultSchema = Schema.TaggedStruct( 'PartyCommandCommitResolution', { - invocationId: PartyCommandInvocationIdSchema, + invocationId: ActionInvocationIdSchema, retryCommand: Schema.Literal(false), state: Schema.Literals(['OPEN', 'COMMITTED']), }, diff --git a/app/verticals/party-registry/shared/resources/duplicate-candidate-case.ts b/app/verticals/party-registry/shared/resources/duplicate-candidate-case.ts index 057dfc195..a32fa9502 100644 --- a/app/verticals/party-registry/shared/resources/duplicate-candidate-case.ts +++ b/app/verticals/party-registry/shared/resources/duplicate-candidate-case.ts @@ -1,40 +1,11 @@ // @generated by OntOS Codesmith Resource v1 // @ontos-resource-owner party.registry // @ontos-resource-slug duplicate-candidate-case -import type { OntosResourceType } from '@app/core-runtime'; -import { Schema } from 'effect'; -import { - PartyRegistryResourceIdJsonSchema, - PartyRegistryTenantIdJsonSchema, -} from './resource-ref-identifiers.ts'; +import { timelineResource } from './timeline-resource.ts'; -export const DuplicateCandidateCaseRefSchema = Schema.Struct({ - moduleId: Schema.Literal('party.registry'), - resourceId: PartyRegistryResourceIdJsonSchema, - resourceType: Schema.Literal('party.registry.duplicate-candidate-case'), - tenantId: PartyRegistryTenantIdJsonSchema, -}); +export const { + descriptor: duplicateCandidateCaseResourceDescriptor, + makeRef: makeDuplicateCandidateCaseRef, + refSchema: DuplicateCandidateCaseRefSchema, +} = timelineResource('duplicate-candidate-case', 'Duplicate Candidate Case'); export type DuplicateCandidateCaseRef = typeof DuplicateCandidateCaseRefSchema.Type; -export const makeDuplicateCandidateCaseRef = ( - tenantId: string, - resourceId: string, -): DuplicateCandidateCaseRef => ({ - moduleId: 'party.registry', - resourceId, - resourceType: 'party.registry.duplicate-candidate-case', - tenantId, -}); - -export const duplicateCandidateCaseResourceDescriptor = { - capabilities: { - graphVisible: false, - linkable: false, - mediaAttachable: false, - searchable: false, - timelineVisible: true, - }, - description: 'Duplicate Candidate Case resource.', - key: 'party.registry.duplicate-candidate-case', - label: 'Duplicate Candidate Case', - owningModuleId: 'party.registry', -} as const satisfies OntosResourceType; diff --git a/app/verticals/party-registry/shared/resources/party-correction.ts b/app/verticals/party-registry/shared/resources/party-correction.ts index 1921694ec..bb9b0a2f2 100644 --- a/app/verticals/party-registry/shared/resources/party-correction.ts +++ b/app/verticals/party-registry/shared/resources/party-correction.ts @@ -1,40 +1,11 @@ // @generated by OntOS Codesmith Resource v1 // @ontos-resource-owner party.registry // @ontos-resource-slug party-correction -import type { OntosResourceType } from '@app/core-runtime'; -import { Schema } from 'effect'; -import { - PartyRegistryResourceIdJsonSchema, - PartyRegistryTenantIdJsonSchema, -} from './resource-ref-identifiers.ts'; +import { timelineResource } from './timeline-resource.ts'; -export const PartyCorrectionRefSchema = Schema.Struct({ - moduleId: Schema.Literal('party.registry'), - resourceId: PartyRegistryResourceIdJsonSchema, - resourceType: Schema.Literal('party.registry.party-correction'), - tenantId: PartyRegistryTenantIdJsonSchema, -}); +export const { + descriptor: partyCorrectionResourceDescriptor, + makeRef: makePartyCorrectionRef, + refSchema: PartyCorrectionRefSchema, +} = timelineResource('party-correction', 'Party Correction'); export type PartyCorrectionRef = typeof PartyCorrectionRefSchema.Type; -export const makePartyCorrectionRef = ( - tenantId: string, - resourceId: string, -): PartyCorrectionRef => ({ - moduleId: 'party.registry', - resourceId, - resourceType: 'party.registry.party-correction', - tenantId, -}); - -export const partyCorrectionResourceDescriptor = { - capabilities: { - graphVisible: false, - linkable: false, - mediaAttachable: false, - searchable: false, - timelineVisible: true, - }, - description: 'Party Correction resource.', - key: 'party.registry.party-correction', - label: 'Party Correction', - owningModuleId: 'party.registry', -} as const satisfies OntosResourceType; diff --git a/app/verticals/party-registry/shared/resources/party-match-decision.ts b/app/verticals/party-registry/shared/resources/party-match-decision.ts index 7033d4e0a..df75b64e6 100644 --- a/app/verticals/party-registry/shared/resources/party-match-decision.ts +++ b/app/verticals/party-registry/shared/resources/party-match-decision.ts @@ -1,40 +1,11 @@ // @generated by OntOS Codesmith Resource v1 // @ontos-resource-owner party.registry // @ontos-resource-slug party-match-decision -import type { OntosResourceType } from '@app/core-runtime'; -import { Schema } from 'effect'; -import { - PartyRegistryResourceIdJsonSchema, - PartyRegistryTenantIdJsonSchema, -} from './resource-ref-identifiers.ts'; +import { timelineResource } from './timeline-resource.ts'; -export const PartyMatchDecisionRefSchema = Schema.Struct({ - moduleId: Schema.Literal('party.registry'), - resourceId: PartyRegistryResourceIdJsonSchema, - resourceType: Schema.Literal('party.registry.party-match-decision'), - tenantId: PartyRegistryTenantIdJsonSchema, -}); +export const { + descriptor: partyMatchDecisionResourceDescriptor, + makeRef: makePartyMatchDecisionRef, + refSchema: PartyMatchDecisionRefSchema, +} = timelineResource('party-match-decision', 'Party Match Decision'); export type PartyMatchDecisionRef = typeof PartyMatchDecisionRefSchema.Type; -export const makePartyMatchDecisionRef = ( - tenantId: string, - resourceId: string, -): PartyMatchDecisionRef => ({ - moduleId: 'party.registry', - resourceId, - resourceType: 'party.registry.party-match-decision', - tenantId, -}); - -export const partyMatchDecisionResourceDescriptor = { - capabilities: { - graphVisible: false, - linkable: false, - mediaAttachable: false, - searchable: false, - timelineVisible: true, - }, - description: 'Party Match Decision resource.', - key: 'party.registry.party-match-decision', - label: 'Party Match Decision', - owningModuleId: 'party.registry', -} as const satisfies OntosResourceType; diff --git a/app/verticals/party-registry/shared/resources/party-official-identifier.ts b/app/verticals/party-registry/shared/resources/party-official-identifier.ts index 7319d9474..54c34f175 100644 --- a/app/verticals/party-registry/shared/resources/party-official-identifier.ts +++ b/app/verticals/party-registry/shared/resources/party-official-identifier.ts @@ -1,40 +1,11 @@ // @generated by OntOS Codesmith Resource v1 // @ontos-resource-owner party.registry // @ontos-resource-slug party-official-identifier -import type { OntosResourceType } from '@app/core-runtime'; -import { Schema } from 'effect'; -import { - PartyRegistryResourceIdJsonSchema, - PartyRegistryTenantIdJsonSchema, -} from './resource-ref-identifiers.ts'; +import { timelineResource } from './timeline-resource.ts'; -export const PartyOfficialIdentifierRefSchema = Schema.Struct({ - moduleId: Schema.Literal('party.registry'), - resourceId: PartyRegistryResourceIdJsonSchema, - resourceType: Schema.Literal('party.registry.party-official-identifier'), - tenantId: PartyRegistryTenantIdJsonSchema, -}); +export const { + descriptor: partyOfficialIdentifierResourceDescriptor, + makeRef: makePartyOfficialIdentifierRef, + refSchema: PartyOfficialIdentifierRefSchema, +} = timelineResource('party-official-identifier', 'Party Official Identifier'); export type PartyOfficialIdentifierRef = typeof PartyOfficialIdentifierRefSchema.Type; -export const makePartyOfficialIdentifierRef = ( - tenantId: string, - resourceId: string, -): PartyOfficialIdentifierRef => ({ - moduleId: 'party.registry', - resourceId, - resourceType: 'party.registry.party-official-identifier', - tenantId, -}); - -export const partyOfficialIdentifierResourceDescriptor = { - capabilities: { - graphVisible: false, - linkable: false, - mediaAttachable: false, - searchable: false, - timelineVisible: true, - }, - description: 'Party Official Identifier resource.', - key: 'party.registry.party-official-identifier', - label: 'Party Official Identifier', - owningModuleId: 'party.registry', -} as const satisfies OntosResourceType; diff --git a/app/verticals/party-registry/shared/resources/timeline-resource.ts b/app/verticals/party-registry/shared/resources/timeline-resource.ts new file mode 100644 index 000000000..6fdb3ec47 --- /dev/null +++ b/app/verticals/party-registry/shared/resources/timeline-resource.ts @@ -0,0 +1,39 @@ +import type { OntosResourceType } from '@app/core-runtime'; +import { Schema } from 'effect'; +import { + PartyRegistryResourceIdJsonSchema, + PartyRegistryTenantIdJsonSchema, +} from './resource-ref-identifiers.ts'; + +export const timelineResource = ( + slug: Slug, + label: Label, +) => { + const resourceType = `party.registry.${slug}` as const; + const refSchema = Schema.Struct({ + moduleId: Schema.Literal('party.registry'), + resourceId: PartyRegistryResourceIdJsonSchema, + resourceType: Schema.Literal(resourceType), + tenantId: PartyRegistryTenantIdJsonSchema, + }); + const makeRef = (tenantId: string, resourceId: string): typeof refSchema.Type => ({ + moduleId: 'party.registry', + resourceId, + resourceType, + tenantId, + }); + const descriptor = { + capabilities: { + graphVisible: false, + linkable: false, + mediaAttachable: false, + searchable: false, + timelineVisible: true, + }, + description: `${label} resource.`, + key: resourceType, + label, + owningModuleId: 'party.registry', + } as const satisfies OntosResourceType; + return { descriptor, makeRef, refSchema }; +}; diff --git a/app/verticals/party-registry/src/actions/archive-organization-engagement.action.ts b/app/verticals/party-registry/src/actions/archive-organization-engagement.action.ts index 0d938ee96..21a2b9e7d 100644 --- a/app/verticals/party-registry/src/actions/archive-organization-engagement.action.ts +++ b/app/verticals/party-registry/src/actions/archive-organization-engagement.action.ts @@ -7,12 +7,8 @@ import { defineTenantModuleEntrypoint, OperationContextUnavailable, } from '@app/core-runtime'; -import type { ActionHandlerContext } from '@app/core-runtime'; -import { Effect, Schema } from 'effect'; +import { Effect } from 'effect'; import { - EngagementProfileConflict, - EngagementProfileNotFound, - EngagementProfilePersistenceUnavailable, OrganizationEngagementLifecyclePayloadSchema, OrganizationEngagementProfileSchema, } from '../../shared/domain/engagement-profile.ts'; @@ -21,37 +17,10 @@ import type { OrganizationEngagementProfile, } from '../../shared/domain/engagement-profile.ts'; import { transitionOrganizationEngagementProfile } from '../services/engagement-profile-persistence.service.ts'; -import type { LifecycleResult } from '../services/engagement-profile-persistence.service.ts'; -import { resolveEngagementLifecycle } from './engagement-lifecycle.ts'; - -const ArchiveOrganizationEngagementPayload = OrganizationEngagementLifecyclePayloadSchema; -const ArchiveOrganizationEngagementResult = OrganizationEngagementProfileSchema; -const ArchiveOrganizationEngagementError = Schema.Union([ - EngagementProfileConflict, - EngagementProfileNotFound, - EngagementProfilePersistenceUnavailable, -]); - -interface Services { - readonly archive: ( - profileId: string, - ) => Effect.Effect< - LifecycleResult, - EngagementProfilePersistenceUnavailable - >; -} - -const handleArchiveOrganizationEngagement = ( - payload: OrganizationEngagementLifecyclePayload, - context: ActionHandlerContext>, Services>, -) => - context.services - .archive(payload.profileRef.resourceId) - .pipe( - Effect.flatMap((result) => - resolveEngagementLifecycle(result, payload.profileRef.resourceId, 'archived'), - ), - ); +import { + EngagementLifecycleErrorSchema, + handleEngagementLifecycle, +} from './engagement-lifecycle-handler.ts'; export const archiveOrganizationEngagementAction = defineAction( { @@ -61,7 +30,7 @@ export const archiveOrganizationEngagementAction = defineAction( }, actionKey: 'party.registry.archive-organization-engagement', auditProfile: 'standard', - domainErrorSchema: ArchiveOrganizationEngagementError, + domainErrorSchema: EngagementLifecycleErrorSchema, domainEvents: {}, entrypoint: defineTenantModuleEntrypoint({ access: 'write', @@ -73,7 +42,7 @@ export const archiveOrganizationEngagementAction = defineAction( idempotency: 'required', legalEntityScope: 'required', owningModuleKey: 'party.registry', - payloadSchema: ArchiveOrganizationEngagementPayload, + payloadSchema: OrganizationEngagementLifecyclePayloadSchema, policies: [], resourcePermission: defineActionResourcePermission( (payload) => ({ @@ -85,10 +54,12 @@ export const archiveOrganizationEngagementAction = defineAction( }, }), ), - resultSchema: ArchiveOrganizationEngagementResult, + resultSchema: OrganizationEngagementProfileSchema, schemaVersion: '1', }, - handleArchiveOrganizationEngagement, + handleEngagementLifecycle( + 'archived', + ), (transaction, scope) => { if (scope.legalEntityId === undefined) { return Effect.fail( @@ -99,7 +70,7 @@ export const archiveOrganizationEngagementAction = defineAction( ); } return Effect.succeed({ - archive: (profileId) => + transition: (profileId) => transitionOrganizationEngagementProfile(transaction, scope.tenantId, profileId, 'archived'), }); }, diff --git a/app/verticals/party-registry/src/actions/archive-person-engagement.action.ts b/app/verticals/party-registry/src/actions/archive-person-engagement.action.ts index 09716f826..91b5b4155 100644 --- a/app/verticals/party-registry/src/actions/archive-person-engagement.action.ts +++ b/app/verticals/party-registry/src/actions/archive-person-engagement.action.ts @@ -7,12 +7,8 @@ import { defineTenantModuleEntrypoint, OperationContextUnavailable, } from '@app/core-runtime'; -import type { ActionHandlerContext } from '@app/core-runtime'; -import { Effect, Schema } from 'effect'; +import { Effect } from 'effect'; import { - EngagementProfileConflict, - EngagementProfileNotFound, - EngagementProfilePersistenceUnavailable, PersonEngagementLifecyclePayloadSchema, PersonEngagementProfileSchema, } from '../../shared/domain/engagement-profile.ts'; @@ -21,37 +17,10 @@ import type { PersonEngagementProfile, } from '../../shared/domain/engagement-profile.ts'; import { transitionPersonEngagementProfile } from '../services/engagement-profile-persistence.service.ts'; -import type { LifecycleResult } from '../services/engagement-profile-persistence.service.ts'; -import { resolveEngagementLifecycle } from './engagement-lifecycle.ts'; - -const ArchivePersonEngagementPayload = PersonEngagementLifecyclePayloadSchema; -const ArchivePersonEngagementResult = PersonEngagementProfileSchema; -const ArchivePersonEngagementError = Schema.Union([ - EngagementProfileConflict, - EngagementProfileNotFound, - EngagementProfilePersistenceUnavailable, -]); - -interface Services { - readonly archive: ( - profileId: string, - ) => Effect.Effect< - LifecycleResult, - EngagementProfilePersistenceUnavailable - >; -} - -const handleArchivePersonEngagement = ( - payload: PersonEngagementLifecyclePayload, - context: ActionHandlerContext>, Services>, -) => - context.services - .archive(payload.profileRef.resourceId) - .pipe( - Effect.flatMap((result) => - resolveEngagementLifecycle(result, payload.profileRef.resourceId, 'archived'), - ), - ); +import { + EngagementLifecycleErrorSchema, + handleEngagementLifecycle, +} from './engagement-lifecycle-handler.ts'; export const archivePersonEngagementAction = defineAction( { @@ -61,7 +30,7 @@ export const archivePersonEngagementAction = defineAction( }, actionKey: 'party.registry.archive-person-engagement', auditProfile: 'standard', - domainErrorSchema: ArchivePersonEngagementError, + domainErrorSchema: EngagementLifecycleErrorSchema, domainEvents: {}, entrypoint: defineTenantModuleEntrypoint({ access: 'write', @@ -73,7 +42,7 @@ export const archivePersonEngagementAction = defineAction( idempotency: 'required', legalEntityScope: 'required', owningModuleKey: 'party.registry', - payloadSchema: ArchivePersonEngagementPayload, + payloadSchema: PersonEngagementLifecyclePayloadSchema, policies: [], resourcePermission: defineActionResourcePermission( (payload) => ({ @@ -85,10 +54,10 @@ export const archivePersonEngagementAction = defineAction( }, }), ), - resultSchema: ArchivePersonEngagementResult, + resultSchema: PersonEngagementProfileSchema, schemaVersion: '1', }, - handleArchivePersonEngagement, + handleEngagementLifecycle('archived'), (transaction, scope) => { if (scope.legalEntityId === undefined) { return Effect.fail( @@ -99,7 +68,7 @@ export const archivePersonEngagementAction = defineAction( ); } return Effect.succeed({ - archive: (profileId) => + transition: (profileId) => transitionPersonEngagementProfile(transaction, scope.tenantId, profileId, 'archived'), }); }, diff --git a/app/verticals/party-registry/src/actions/attach-engagement-handler.ts b/app/verticals/party-registry/src/actions/attach-engagement-handler.ts new file mode 100644 index 000000000..60f6a768e --- /dev/null +++ b/app/verticals/party-registry/src/actions/attach-engagement-handler.ts @@ -0,0 +1,31 @@ +import { Effect, Schema } from 'effect'; +import { + EngagementProfileConflict, + EngagementProfilePersistenceUnavailable, + PartyRegistryReferenceUnavailable, +} from '../../shared/domain/engagement-profile.ts'; + +export const AttachEngagementError = Schema.Union([ + EngagementProfileConflict, + EngagementProfilePersistenceUnavailable, + PartyRegistryReferenceUnavailable, +]); + +interface EngagementServices { + readonly create: ( + payload: Payload, + ) => Effect.Effect; + readonly validate: ( + payload: Payload, + ) => Effect.Effect; +} + +export const handleAttachEngagement = Effect.fn('AttachEngagementAction.handle')( + function* handleAttachEngagement( + payload: Payload, + context: { readonly services: EngagementServices }, + ) { + yield* context.services.validate(payload); + return yield* context.services.create(payload); + }, +); diff --git a/app/verticals/party-registry/src/actions/attach-organization-engagement.action.ts b/app/verticals/party-registry/src/actions/attach-organization-engagement.action.ts index f0cc09c2d..de4f8d26d 100644 --- a/app/verticals/party-registry/src/actions/attach-organization-engagement.action.ts +++ b/app/verticals/party-registry/src/actions/attach-organization-engagement.action.ts @@ -6,13 +6,9 @@ import { defineTenantModuleEntrypoint, OperationContextUnavailable, } from '@app/core-runtime'; -import type { ActionHandlerContext } from '@app/core-runtime'; -import { Effect, Schema } from 'effect'; +import { Effect } from 'effect'; import { AttachOrganizationEngagementPayloadSchema, - EngagementProfileConflict, - EngagementProfilePersistenceUnavailable, - PartyRegistryReferenceUnavailable, OrganizationEngagementProfileSchema, } from '../../shared/domain/engagement-profile.ts'; import type { @@ -25,35 +21,7 @@ import { validatePartyRegistryReferences, } from '../services/engagement-reference-validation.service.ts'; -const AttachOrganizationEngagementPayload = AttachOrganizationEngagementPayloadSchema; -const AttachOrganizationEngagementResult = OrganizationEngagementProfileSchema; -const AttachOrganizationEngagementError = Schema.Union([ - EngagementProfileConflict, - EngagementProfilePersistenceUnavailable, - PartyRegistryReferenceUnavailable, -]); - -interface Services { - readonly create: ( - payload: AttachOrganizationEngagementInput, - ) => Effect.Effect< - OrganizationEngagementProfile, - EngagementProfileConflict | EngagementProfilePersistenceUnavailable - >; - readonly validate: ( - payload: AttachOrganizationEngagementInput, - ) => Effect.Effect; -} - -const handleAttachOrganizationEngagement = Effect.fn( - 'AttachOrganizationEngagementAction.handleAttachOrganizationEngagement', -)(function* handleAttachOrganizationEngagementEffect( - payload: AttachOrganizationEngagementInput, - context: ActionHandlerContext>, Services>, -) { - yield* context.services.validate(payload); - return yield* context.services.create(payload); -}); +import { AttachEngagementError, handleAttachEngagement } from './attach-engagement-handler.ts'; export const attachOrganizationEngagementAction = defineAction( { @@ -63,7 +31,7 @@ export const attachOrganizationEngagementAction = defineAction( }, actionKey: 'party.registry.attach-organization-engagement', auditProfile: 'standard', - domainErrorSchema: AttachOrganizationEngagementError, + domainErrorSchema: AttachEngagementError, domainEvents: {}, entrypoint: defineTenantModuleEntrypoint({ access: 'write', @@ -75,12 +43,12 @@ export const attachOrganizationEngagementAction = defineAction( idempotency: 'required', legalEntityScope: 'required', owningModuleKey: 'party.registry', - payloadSchema: AttachOrganizationEngagementPayload, + payloadSchema: AttachOrganizationEngagementPayloadSchema, policies: [], - resultSchema: AttachOrganizationEngagementResult, + resultSchema: OrganizationEngagementProfileSchema, schemaVersion: '1', }, - handleAttachOrganizationEngagement, + handleAttachEngagement, (transaction, scope) => { if (scope.legalEntityId === undefined) { return Effect.fail( diff --git a/app/verticals/party-registry/src/actions/attach-person-engagement.action.ts b/app/verticals/party-registry/src/actions/attach-person-engagement.action.ts index 5149d08de..c74d1234f 100644 --- a/app/verticals/party-registry/src/actions/attach-person-engagement.action.ts +++ b/app/verticals/party-registry/src/actions/attach-person-engagement.action.ts @@ -6,13 +6,9 @@ import { defineTenantModuleEntrypoint, OperationContextUnavailable, } from '@app/core-runtime'; -import type { ActionHandlerContext } from '@app/core-runtime'; -import { Effect, Schema } from 'effect'; +import { Effect } from 'effect'; import { AttachPersonEngagementPayloadSchema, - EngagementProfileConflict, - EngagementProfilePersistenceUnavailable, - PartyRegistryReferenceUnavailable, PersonEngagementProfileSchema, } from '../../shared/domain/engagement-profile.ts'; import type { @@ -25,35 +21,7 @@ import { validatePartyRegistryReferences, } from '../services/engagement-reference-validation.service.ts'; -const AttachPersonEngagementPayload = AttachPersonEngagementPayloadSchema; -const AttachPersonEngagementResult = PersonEngagementProfileSchema; -const AttachPersonEngagementError = Schema.Union([ - EngagementProfileConflict, - EngagementProfilePersistenceUnavailable, - PartyRegistryReferenceUnavailable, -]); - -interface Services { - readonly create: ( - payload: AttachPersonEngagementInput, - ) => Effect.Effect< - PersonEngagementProfile, - EngagementProfileConflict | EngagementProfilePersistenceUnavailable - >; - readonly validate: ( - payload: AttachPersonEngagementInput, - ) => Effect.Effect; -} - -const handleAttachPersonEngagement = Effect.fn( - 'AttachPersonEngagementAction.handleAttachPersonEngagement', -)(function* handleAttachPersonEngagementEffect( - payload: AttachPersonEngagementInput, - context: ActionHandlerContext>, Services>, -) { - yield* context.services.validate(payload); - return yield* context.services.create(payload); -}); +import { AttachEngagementError, handleAttachEngagement } from './attach-engagement-handler.ts'; export const attachPersonEngagementAction = defineAction( { @@ -63,7 +31,7 @@ export const attachPersonEngagementAction = defineAction( }, actionKey: 'party.registry.attach-person-engagement', auditProfile: 'standard', - domainErrorSchema: AttachPersonEngagementError, + domainErrorSchema: AttachEngagementError, domainEvents: {}, entrypoint: defineTenantModuleEntrypoint({ access: 'write', @@ -75,12 +43,12 @@ export const attachPersonEngagementAction = defineAction( idempotency: 'required', legalEntityScope: 'required', owningModuleKey: 'party.registry', - payloadSchema: AttachPersonEngagementPayload, + payloadSchema: AttachPersonEngagementPayloadSchema, policies: [], - resultSchema: AttachPersonEngagementResult, + resultSchema: PersonEngagementProfileSchema, schemaVersion: '1', }, - handleAttachPersonEngagement, + handleAttachEngagement, (transaction, scope) => { if (scope.legalEntityId === undefined) { return Effect.fail( diff --git a/app/verticals/party-registry/src/actions/attached-official-identifier-events.ts b/app/verticals/party-registry/src/actions/attached-official-identifier-events.ts new file mode 100644 index 000000000..896612fc4 --- /dev/null +++ b/app/verticals/party-registry/src/actions/attached-official-identifier-events.ts @@ -0,0 +1,39 @@ +import type { ActionHandlerContext } from '@app/core-runtime'; +import { Effect } from 'effect'; +import type { + AddPartyOfficialIdentifierResult, + AddPartyOfficialIdentifierResultSchema, +} from '../../shared/actions/add-party-official-identifier.ts'; +import { createAddPartyOfficialIdentifierPartyRegistryOfficialIdentifierAddedV1OutboxMessage } from './add-party-official-identifier.party-registry-official-identifier-added-v1.outbox-message.ts'; + +export const publishAttachedOfficialIdentifiers = ( + context: Pick< + ActionHandlerContext<{ + readonly 'party.registry.official-identifier-added.v1': typeof AddPartyOfficialIdentifierResultSchema; + }>, + 'addDomainEvent' | 'addOutboxMessage' + >, + partyRef: AddPartyOfficialIdentifierResult['partyRef'], + identifiers: readonly AddPartyOfficialIdentifierResult['officialIdentifierRef'][], +) => + Effect.forEach( + identifiers, + Effect.fn(function* publishIdentifier(officialIdentifierRef) { + const payload = { officialIdentifierRef, partyRef }; + const event = yield* context.addDomainEvent({ + eventType: 'party.registry.official-identifier-added.v1', + payloadJson: payload, + producerModuleKey: 'party.registry', + subjectModuleKey: 'party.registry', + subjectResourceId: officialIdentifierRef.resourceId, + subjectResourceType: officialIdentifierRef.resourceType, + }); + yield* context.addOutboxMessage( + event, + createAddPartyOfficialIdentifierPartyRegistryOfficialIdentifierAddedV1OutboxMessage( + payload, + ), + ); + }), + { concurrency: 1, discard: true }, + ); diff --git a/app/verticals/party-registry/src/actions/confirm-duplicate-parties.action.ts b/app/verticals/party-registry/src/actions/confirm-duplicate-parties.action.ts index 4a0504c27..44621de69 100644 --- a/app/verticals/party-registry/src/actions/confirm-duplicate-parties.action.ts +++ b/app/verticals/party-registry/src/actions/confirm-duplicate-parties.action.ts @@ -2,20 +2,18 @@ // @ontos-action-owner party.registry // @ontos-action-slug confirm-duplicate-parties import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; -import { Effect, Schema } from 'effect'; -import { PartyPersistenceUnavailable } from '../../shared/domain/identity-contracts.ts'; -import { DuplicateCandidateConflict } from '../../shared/domain/matching-contracts.ts'; -import { transitionDuplicateCandidateCase } from '../services/party-matching-persistence.service.ts'; import { ConfirmDuplicatePartiesPayloadSchema, ConfirmDuplicatePartiesResultSchema, } from '../../shared/actions/confirm-duplicate-parties.ts'; -import type { ConfirmDuplicatePartiesPayload } from '../../shared/actions/confirm-duplicate-parties.ts'; +import { + DuplicateCaseResolutionErrorSchema, + duplicateCaseResolutionService, +} from './duplicate-case-resolution-service.ts'; import { handleDuplicateCaseResolution } from './duplicate-case-resolution-handler.ts'; export type { ConfirmDuplicatePartiesPayload } from '../../shared/actions/confirm-duplicate-parties.ts'; -const ErrorSchema = Schema.Union([DuplicateCandidateConflict, PartyPersistenceUnavailable]); export const confirmDuplicatePartiesAction = defineAction( { accessEvidencePolicy: { @@ -24,7 +22,7 @@ export const confirmDuplicatePartiesAction = defineAction( }, actionKey: 'party.registry.confirm-duplicate-parties', auditProfile: 'sensitive', - domainErrorSchema: ErrorSchema, + domainErrorSchema: DuplicateCaseResolutionErrorSchema, domainEvents: {}, entrypoint: defineTenantModuleEntrypoint({ access: 'write', @@ -44,17 +42,7 @@ export const confirmDuplicatePartiesAction = defineAction( }, handleDuplicateCaseResolution, (transaction, scope) => - Effect.succeed({ - resolve: (payload: ConfirmDuplicatePartiesPayload, invocationId: string) => - transitionDuplicateCandidateCase(transaction, { - actionInvocationId: invocationId, - candidateCaseId: payload.caseRef.resourceId, - expectedRevision: payload.expectedRevision, - outcome: 'CONFIRMED_DUPLICATE_PARTIES', - reason: payload.reason, - tenantId: scope.tenantId, - }), - }), + duplicateCaseResolutionService(transaction, scope.tenantId, 'CONFIRMED_DUPLICATE_PARTIES'), ); // Production merge remains deliberately absent: this Action records reviewed readiness only. // diff --git a/app/verticals/party-registry/src/actions/counterparty-role-action-support.ts b/app/verticals/party-registry/src/actions/counterparty-role-action-support.ts new file mode 100644 index 000000000..296144311 --- /dev/null +++ b/app/verticals/party-registry/src/actions/counterparty-role-action-support.ts @@ -0,0 +1,25 @@ +import { Effect } from 'effect'; +import type { CounterpartyRoleAddPayload } from '../../shared/actions/counterparty-role-add.ts'; +import { CounterpartyNotFound } from '../../shared/domain/counterparty-errors.ts'; + +export const counterpartyRoleWritePermission = ( + payload: Pick, +) => ({ + permission: 'write' as const, + resource: { + moduleId: payload.counterpartyRef.moduleId, + resourceId: payload.counterpartyRef.resourceId, + resourceType: payload.counterpartyRef.resourceType, + }, +}); + +export const failCounterpartyNotFound = ({ + counterpartyId, +}: Pick) => + Effect.fail( + new CounterpartyNotFound({ + code: 'counterparty_not_found', + counterpartyId, + reason: 'The Counterparty does not exist in the selected Legal Entity', + }), + ); diff --git a/app/verticals/party-registry/src/actions/counterparty-role-add.action.ts b/app/verticals/party-registry/src/actions/counterparty-role-add.action.ts index ddbf6e738..8fe9a64bd 100644 --- a/app/verticals/party-registry/src/actions/counterparty-role-add.action.ts +++ b/app/verticals/party-registry/src/actions/counterparty-role-add.action.ts @@ -9,6 +9,10 @@ import { OperationContextUnavailable, } from '@app/core-runtime'; import { Effect, Match, Schema } from 'effect'; +import { + counterpartyRoleWritePermission, + failCounterpartyNotFound, +} from './counterparty-role-action-support.ts'; import { CounterpartyAuditEvidenceSchema } from '../../shared/domain/counterparty-contract.ts'; import { CounterpartyEvidenceInsufficient, @@ -78,15 +82,7 @@ const handleCounterpartyRoleAdd = Effect.fn('CounterpartyRoleAddAction.handleCou } const persistenceResult = yield* context.services.add(payload, context); const result = yield* Match.value(persistenceResult).pipe( - Match.tag('counterparty_not_found', ({ counterpartyId }) => - Effect.fail( - new CounterpartyNotFound({ - code: 'counterparty_not_found', - counterpartyId, - reason: 'The Counterparty does not exist in the selected Legal Entity', - }), - ), - ), + Match.tag('counterparty_not_found', failCounterpartyNotFound), Match.tag('overlap', ({ roleType }) => Effect.fail( new CounterpartyRoleOverlap({ @@ -171,14 +167,9 @@ export const counterpartyRoleAddAction = defineAction( owningModuleKey: 'party.registry', payloadSchema: CounterpartyRoleAddPayloadSchema, policies: [], - resourcePermission: defineActionResourcePermission((payload) => ({ - permission: 'write', - resource: { - moduleId: payload.counterpartyRef.moduleId, - resourceId: payload.counterpartyRef.resourceId, - resourceType: payload.counterpartyRef.resourceType, - }, - })), + resourcePermission: defineActionResourcePermission( + counterpartyRoleWritePermission, + ), resultSchema: CounterpartyRoleAddResultSchema, schemaVersion: '1', }, diff --git a/app/verticals/party-registry/src/actions/counterparty-role-end.action.ts b/app/verticals/party-registry/src/actions/counterparty-role-end.action.ts index 316766006..22e7e48ea 100644 --- a/app/verticals/party-registry/src/actions/counterparty-role-end.action.ts +++ b/app/verticals/party-registry/src/actions/counterparty-role-end.action.ts @@ -9,6 +9,10 @@ import { OperationContextUnavailable, } from '@app/core-runtime'; import { Effect, Match, Schema } from 'effect'; +import { + counterpartyRoleWritePermission, + failCounterpartyNotFound, +} from './counterparty-role-action-support.ts'; import { CounterpartyAuditEvidenceSchema } from '../../shared/domain/counterparty-contract.ts'; import { CounterpartyNotFound, @@ -73,15 +77,7 @@ const handleCounterpartyRoleEnd = Effect.fn('CounterpartyRoleEndAction.handleCou } const persistenceResult = yield* context.services.end(payload, context); const result = yield* Match.value(persistenceResult).pipe( - Match.tag('counterparty_not_found', ({ counterpartyId }) => - Effect.fail( - new CounterpartyNotFound({ - code: 'counterparty_not_found', - counterpartyId, - reason: 'The Counterparty does not exist in the selected Legal Entity', - }), - ), - ), + Match.tag('counterparty_not_found', failCounterpartyNotFound), Match.tag('evidence_insufficient', ({ method, roleType }) => Effect.fail( new CounterpartyEvidenceInsufficient({ @@ -191,14 +187,9 @@ export const counterpartyRoleEndAction = defineAction( owningModuleKey: 'party.registry', payloadSchema: CounterpartyRoleEndPayloadSchema, policies: [], - resourcePermission: defineActionResourcePermission((payload) => ({ - permission: 'write', - resource: { - moduleId: payload.counterpartyRef.moduleId, - resourceId: payload.counterpartyRef.resourceId, - resourceType: payload.counterpartyRef.resourceType, - }, - })), + resourcePermission: defineActionResourcePermission( + counterpartyRoleWritePermission, + ), resultSchema: CounterpartyRoleEndResultSchema, schemaVersion: '1', }, diff --git a/app/verticals/party-registry/src/actions/create-party-relationship.action.ts b/app/verticals/party-registry/src/actions/create-party-relationship.action.ts index 921a4d59c..865d3e90f 100644 --- a/app/verticals/party-registry/src/actions/create-party-relationship.action.ts +++ b/app/verticals/party-registry/src/actions/create-party-relationship.action.ts @@ -1,20 +1,16 @@ // @generated by OntOS Codesmith Action v1 // @ontos-action-owner party.registry // @ontos-action-slug create-party-relationship -import { Effect, Schema } from 'effect'; +import { Effect } from 'effect'; import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ActionHandlerContext } from '@app/core-runtime'; import { CreatePartyRelationshipPayloadSchema, CreatePartyRelationshipResultSchema, - PartyRelationshipLifecycleEventPayloadSchema, PartyRelationshipLifecycleEventPayloadJsonSchema, PartyRelationshipMutationErrorSchema, } from '../../shared/domain/relationship-contract.ts'; -import type { - CreatePartyRelationshipPayload as Payload, - PartyRelationshipLifecycleEventPayload, -} from '../../shared/domain/relationship-contract.ts'; +import type { CreatePartyRelationshipPayload as Payload } from '../../shared/domain/relationship-contract.ts'; import { createPartyRelationshipRecord } from '../services/party-relationship-persistence.service.ts'; import type { RelationshipCreateResult, @@ -22,17 +18,7 @@ import type { } from '../services/party-relationship-persistence.service.ts'; import { createCreatePartyRelationshipPartyRegistryRelationshipCreatedV1OutboxMessage } from './create-party-relationship.party-registry-relationship-created-v1.outbox-message.ts'; -const eventPayload = ( - result: RelationshipCreateResult, -): PartyRelationshipLifecycleEventPayload => ({ - fromPartyRef: result.relationship.from.canonicalPartyRef, - relationshipRef: result.relationship.relationshipRef, - relationshipType: result.relationship.relationshipType, - revision: result.relationship.revision, - toPartyRef: result.relationship.to.canonicalPartyRef, - validFrom: result.relationship.validFrom, - validTo: result.relationship.validTo, -}); +import { encodeRelationshipEventPayload } from './relationship-event-payload.ts'; interface Services { readonly create: ( @@ -68,9 +54,7 @@ const handleCreatePartyRelationship = Effect.fn( targetResourceType: result.relationship.relationshipRef.resourceType, }); if (result.outcome === 'CREATED') { - const payloadJson = yield* Schema.encodeEffect(PartyRelationshipLifecycleEventPayloadSchema)( - eventPayload(result), - ).pipe(Effect.orDie); + const payloadJson = yield* encodeRelationshipEventPayload(result.relationship); const domainEvent = yield* context.addDomainEvent({ eventType: 'party.registry.relationship-created.v1', payloadJson, diff --git a/app/verticals/party-registry/src/actions/create-party.action.ts b/app/verticals/party-registry/src/actions/create-party.action.ts index fd6142f5a..935709c9e 100644 --- a/app/verticals/party-registry/src/actions/create-party.action.ts +++ b/app/verticals/party-registry/src/actions/create-party.action.ts @@ -16,7 +16,7 @@ import { createOrMatchParty, } from '../services/party-matching-persistence.service.ts'; import { createCreatePartyPartyRegistryPartyCreatedV1OutboxMessage } from './create-party.party-registry-party-created-v1.outbox-message.ts'; -import { createAddPartyOfficialIdentifierPartyRegistryOfficialIdentifierAddedV1OutboxMessage } from './add-party-official-identifier.party-registry-official-identifier-added-v1.outbox-message.ts'; +import { publishAttachedOfficialIdentifiers } from './attached-official-identifier-events.ts'; import { CreatePartyPayloadSchema, @@ -75,31 +75,10 @@ const handleCreateParty = Effect.fn('CreatePartyAction.handleCreateParty')(funct ); } if (result.outcome === 'MATCHED_EXISTING') { - yield* Effect.forEach( + yield* publishAttachedOfficialIdentifiers( + context, + result.partyRef, addedOfficialIdentifierRefs, - (officialIdentifierRef) => { - const addedIdentifier = { officialIdentifierRef, partyRef: result.partyRef }; - return context - .addDomainEvent({ - eventType: 'party.registry.official-identifier-added.v1', - payloadJson: addedIdentifier, - producerModuleKey: 'party.registry', - subjectModuleKey: 'party.registry', - subjectResourceId: officialIdentifierRef.resourceId, - subjectResourceType: officialIdentifierRef.resourceType, - }) - .pipe( - Effect.flatMap((event) => - context.addOutboxMessage( - event, - createAddPartyOfficialIdentifierPartyRegistryOfficialIdentifierAddedV1OutboxMessage( - addedIdentifier, - ), - ), - ), - ); - }, - { concurrency: 1, discard: true }, ); } return result; diff --git a/app/verticals/party-registry/src/actions/dismiss-duplicate-candidate.action.ts b/app/verticals/party-registry/src/actions/dismiss-duplicate-candidate.action.ts index d8ed35484..9b65dc936 100644 --- a/app/verticals/party-registry/src/actions/dismiss-duplicate-candidate.action.ts +++ b/app/verticals/party-registry/src/actions/dismiss-duplicate-candidate.action.ts @@ -2,20 +2,18 @@ // @ontos-action-owner party.registry // @ontos-action-slug dismiss-duplicate-candidate import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; -import { Effect, Schema } from 'effect'; -import { PartyPersistenceUnavailable } from '../../shared/domain/identity-contracts.ts'; -import { DuplicateCandidateConflict } from '../../shared/domain/matching-contracts.ts'; -import { transitionDuplicateCandidateCase } from '../services/party-matching-persistence.service.ts'; import { DismissDuplicateCandidatePayloadSchema, DismissDuplicateCandidateResultSchema, } from '../../shared/actions/dismiss-duplicate-candidate.ts'; -import type { DismissDuplicateCandidatePayload } from '../../shared/actions/dismiss-duplicate-candidate.ts'; +import { + DuplicateCaseResolutionErrorSchema, + duplicateCaseResolutionService, +} from './duplicate-case-resolution-service.ts'; import { handleDuplicateCaseResolution } from './duplicate-case-resolution-handler.ts'; export type { DismissDuplicateCandidatePayload } from '../../shared/actions/dismiss-duplicate-candidate.ts'; -const ErrorSchema = Schema.Union([DuplicateCandidateConflict, PartyPersistenceUnavailable]); export const dismissDuplicateCandidateAction = defineAction( { accessEvidencePolicy: { @@ -24,7 +22,7 @@ export const dismissDuplicateCandidateAction = defineAction( }, actionKey: 'party.registry.dismiss-duplicate-candidate', auditProfile: 'standard', - domainErrorSchema: ErrorSchema, + domainErrorSchema: DuplicateCaseResolutionErrorSchema, domainEvents: {}, entrypoint: defineTenantModuleEntrypoint({ access: 'write', @@ -44,17 +42,7 @@ export const dismissDuplicateCandidateAction = defineAction( }, handleDuplicateCaseResolution, (transaction, scope) => - Effect.succeed({ - resolve: (payload: DismissDuplicateCandidatePayload, invocationId: string) => - transitionDuplicateCandidateCase(transaction, { - actionInvocationId: invocationId, - candidateCaseId: payload.caseRef.resourceId, - expectedRevision: payload.expectedRevision, - outcome: 'DISMISSED_AS_NON_SUBJECT', - reason: payload.reason, - tenantId: scope.tenantId, - }), - }), + duplicateCaseResolutionService(transaction, scope.tenantId, 'DISMISSED_AS_NON_SUBJECT'), ); // // diff --git a/app/verticals/party-registry/src/actions/duplicate-case-resolution-service.ts b/app/verticals/party-registry/src/actions/duplicate-case-resolution-service.ts new file mode 100644 index 000000000..2085c0967 --- /dev/null +++ b/app/verticals/party-registry/src/actions/duplicate-case-resolution-service.ts @@ -0,0 +1,27 @@ +import { Effect, Schema } from 'effect'; +import { PartyPersistenceUnavailable } from '../../shared/domain/identity-contracts.ts'; +import { DuplicateCandidateConflict } from '../../shared/domain/matching-contracts.ts'; +import type { ConfirmDuplicatePartiesPayload } from '../../shared/actions/confirm-duplicate-parties.ts'; +import { transitionDuplicateCandidateCase } from '../services/party-matching-persistence.service.ts'; + +export const DuplicateCaseResolutionErrorSchema = Schema.Union([ + DuplicateCandidateConflict, + PartyPersistenceUnavailable, +]); + +export const duplicateCaseResolutionService = ( + transaction: Parameters[0], + tenantId: string, + outcome: Parameters[1]['outcome'], +) => + Effect.succeed({ + resolve: (payload: ConfirmDuplicatePartiesPayload, invocationId: string) => + transitionDuplicateCandidateCase(transaction, { + actionInvocationId: invocationId, + candidateCaseId: payload.caseRef.resourceId, + expectedRevision: payload.expectedRevision, + outcome, + reason: payload.reason, + tenantId, + }), + }); diff --git a/app/verticals/party-registry/src/actions/end-party-relationship.action.ts b/app/verticals/party-registry/src/actions/end-party-relationship.action.ts index 7c0fe10f2..ba53158ea 100644 --- a/app/verticals/party-registry/src/actions/end-party-relationship.action.ts +++ b/app/verticals/party-registry/src/actions/end-party-relationship.action.ts @@ -7,16 +7,12 @@ import type { ActionHandlerContext } from '@app/core-runtime'; import { ChangePartyRelationshipResultSchema, EndPartyRelationshipPayloadSchema, - PartyRelationshipLifecycleEventPayloadSchema, PartyRelationshipLifecycleEventPayloadJsonSchema, EndRelationshipAuditEvidenceSchema, EndRelationshipAuditEvidenceJsonSchema, PartyRelationshipMutationErrorSchema, } from '../../shared/domain/relationship-contract.ts'; -import type { - EndPartyRelationshipPayload as Payload, - PartyRelationshipLifecycleEventPayload, -} from '../../shared/domain/relationship-contract.ts'; +import type { EndPartyRelationshipPayload as Payload } from '../../shared/domain/relationship-contract.ts'; import { endPartyRelationshipRecord } from '../services/party-relationship-persistence.service.ts'; import type { RelationshipChangeResult, @@ -24,19 +20,7 @@ import type { } from '../services/party-relationship-persistence.service.ts'; import { createEndPartyRelationshipPartyRegistryRelationshipEndedV1OutboxMessage } from './end-party-relationship.party-registry-relationship-ended-v1.outbox-message.ts'; -const EndPartyRelationshipResultSchema = ChangePartyRelationshipResultSchema; - -const eventPayload = ( - result: RelationshipChangeResult, -): PartyRelationshipLifecycleEventPayload => ({ - fromPartyRef: result.relationship.from.canonicalPartyRef, - relationshipRef: result.relationship.relationshipRef, - relationshipType: result.relationship.relationshipType, - revision: result.relationship.revision, - toPartyRef: result.relationship.to.canonicalPartyRef, - validFrom: result.relationship.validFrom, - validTo: result.relationship.validTo, -}); +import { encodeRelationshipEventPayload } from './relationship-event-payload.ts'; interface Services { readonly end: ( @@ -80,9 +64,7 @@ const handleEndPartyRelationship = Effect.fn( relationshipRef: payload.relationshipRef, }).pipe(Effect.orDie); yield* context.recordAuditEvidence(auditEvidence); - const payloadJson = yield* Schema.encodeEffect(PartyRelationshipLifecycleEventPayloadSchema)( - eventPayload(result), - ).pipe(Effect.orDie); + const payloadJson = yield* encodeRelationshipEventPayload(result.relationship); const domainEvent = yield* context.addDomainEvent({ eventType: 'party.registry.relationship-ended.v1', payloadJson, @@ -124,7 +106,7 @@ export const endPartyRelationshipAction = defineAction( owningModuleKey: 'party.registry', payloadSchema: EndPartyRelationshipPayloadSchema, policies: [], - resultSchema: EndPartyRelationshipResultSchema, + resultSchema: ChangePartyRelationshipResultSchema, schemaVersion: '1', tenantPermission: () => 'manage_party_relationships', }, diff --git a/app/verticals/party-registry/src/actions/engagement-lifecycle-handler.ts b/app/verticals/party-registry/src/actions/engagement-lifecycle-handler.ts new file mode 100644 index 000000000..c09307e90 --- /dev/null +++ b/app/verticals/party-registry/src/actions/engagement-lifecycle-handler.ts @@ -0,0 +1,40 @@ +import type { ActionHandlerContext } from '@app/core-runtime'; +import { Effect, Schema } from 'effect'; +import { + EngagementProfileConflict, + EngagementProfileNotFound, + EngagementProfilePersistenceUnavailable, +} from '../../shared/domain/engagement-profile.ts'; +import type { LifecycleResult } from '../services/engagement-profile-persistence.service.ts'; +import { resolveEngagementLifecycle } from './engagement-lifecycle.ts'; + +export const EngagementLifecycleErrorSchema = Schema.Union([ + EngagementProfileConflict, + EngagementProfileNotFound, + EngagementProfilePersistenceUnavailable, +]); + +interface LifecycleServices { + readonly transition: ( + profileId: string, + ) => Effect.Effect, EngagementProfilePersistenceUnavailable>; +} + +export const handleEngagementLifecycle = + }>, Value>( + requestedState: 'active' | 'archived', + ) => + ( + payload: Payload, + context: Pick< + ActionHandlerContext>, LifecycleServices>, + 'services' + >, + ) => + context.services + .transition(payload.profileRef.resourceId) + .pipe( + Effect.flatMap((result) => + resolveEngagementLifecycle(result, payload.profileRef.resourceId, requestedState), + ), + ); diff --git a/app/verticals/party-registry/src/actions/mark-duplicate-candidate-needs-evidence.action.ts b/app/verticals/party-registry/src/actions/mark-duplicate-candidate-needs-evidence.action.ts index 6a08da97a..6dfa3beda 100644 --- a/app/verticals/party-registry/src/actions/mark-duplicate-candidate-needs-evidence.action.ts +++ b/app/verticals/party-registry/src/actions/mark-duplicate-candidate-needs-evidence.action.ts @@ -2,20 +2,18 @@ // @ontos-action-owner party.registry // @ontos-action-slug mark-duplicate-candidate-needs-evidence import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; -import { Effect, Schema } from 'effect'; -import { PartyPersistenceUnavailable } from '../../shared/domain/identity-contracts.ts'; -import { DuplicateCandidateConflict } from '../../shared/domain/matching-contracts.ts'; -import { transitionDuplicateCandidateCase } from '../services/party-matching-persistence.service.ts'; import { MarkDuplicateCandidateNeedsEvidencePayloadSchema, MarkDuplicateCandidateNeedsEvidenceResultSchema, } from '../../shared/actions/mark-duplicate-candidate-needs-evidence.ts'; -import type { MarkDuplicateCandidateNeedsEvidencePayload } from '../../shared/actions/mark-duplicate-candidate-needs-evidence.ts'; +import { + DuplicateCaseResolutionErrorSchema, + duplicateCaseResolutionService, +} from './duplicate-case-resolution-service.ts'; import { handleDuplicateCaseResolution } from './duplicate-case-resolution-handler.ts'; export type { MarkDuplicateCandidateNeedsEvidencePayload } from '../../shared/actions/mark-duplicate-candidate-needs-evidence.ts'; -const ErrorSchema = Schema.Union([DuplicateCandidateConflict, PartyPersistenceUnavailable]); export const markDuplicateCandidateNeedsEvidenceAction = defineAction( { accessEvidencePolicy: { @@ -24,7 +22,7 @@ export const markDuplicateCandidateNeedsEvidenceAction = defineAction( }, actionKey: 'party.registry.mark-duplicate-candidate-needs-evidence', auditProfile: 'standard', - domainErrorSchema: ErrorSchema, + domainErrorSchema: DuplicateCaseResolutionErrorSchema, domainEvents: {}, entrypoint: defineTenantModuleEntrypoint({ access: 'write', @@ -44,17 +42,7 @@ export const markDuplicateCandidateNeedsEvidenceAction = defineAction( }, handleDuplicateCaseResolution, (transaction, scope) => - Effect.succeed({ - resolve: (payload: MarkDuplicateCandidateNeedsEvidencePayload, invocationId: string) => - transitionDuplicateCandidateCase(transaction, { - actionInvocationId: invocationId, - candidateCaseId: payload.caseRef.resourceId, - expectedRevision: payload.expectedRevision, - outcome: 'NEEDS_EVIDENCE', - reason: payload.reason, - tenantId: scope.tenantId, - }), - }), + duplicateCaseResolutionService(transaction, scope.tenantId, 'NEEDS_EVIDENCE'), ); // // diff --git a/app/verticals/party-registry/src/actions/relationship-event-payload.ts b/app/verticals/party-registry/src/actions/relationship-event-payload.ts new file mode 100644 index 000000000..740fe8dc7 --- /dev/null +++ b/app/verticals/party-registry/src/actions/relationship-event-payload.ts @@ -0,0 +1,14 @@ +import { Effect, Schema } from 'effect'; +import { PartyRelationshipLifecycleEventPayloadSchema } from '../../shared/domain/relationship-contract.ts'; +import type { PartyRelationshipDetail } from '../../shared/domain/relationship-contract.ts'; + +export const encodeRelationshipEventPayload = (relationship: PartyRelationshipDetail) => + Schema.encodeEffect(PartyRelationshipLifecycleEventPayloadSchema)({ + fromPartyRef: relationship.from.canonicalPartyRef, + relationshipRef: relationship.relationshipRef, + relationshipType: relationship.relationshipType, + revision: relationship.revision, + toPartyRef: relationship.to.canonicalPartyRef, + validFrom: relationship.validFrom, + validTo: relationship.validTo, + }).pipe(Effect.orDie); diff --git a/app/verticals/party-registry/src/actions/resolve-duplicate-candidate-match.action.ts b/app/verticals/party-registry/src/actions/resolve-duplicate-candidate-match.action.ts index c42e2cda8..126bcc9e6 100644 --- a/app/verticals/party-registry/src/actions/resolve-duplicate-candidate-match.action.ts +++ b/app/verticals/party-registry/src/actions/resolve-duplicate-candidate-match.action.ts @@ -6,7 +6,7 @@ import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ActionHandlerContext } from '@app/core-runtime'; import { Effect, Schema } from 'effect'; import { AddPartyOfficialIdentifierResultSchema } from '../../shared/actions/add-party-official-identifier.ts'; -import { createAddPartyOfficialIdentifierPartyRegistryOfficialIdentifierAddedV1OutboxMessage } from './add-party-official-identifier.party-registry-official-identifier-added-v1.outbox-message.ts'; +import { publishAttachedOfficialIdentifiers } from './attached-official-identifier-events.ts'; import { ClaimOwnedByDifferentParty, DuplicateCandidateConflict, @@ -66,32 +66,10 @@ const handle = Effect.fn('ResolveDuplicateCandidateMatchAction.handle')(function targetResourceType: result.caseRef.resourceType, }); if (result.partyRef !== null) { - const { partyRef } = result; - yield* Effect.forEach( + yield* publishAttachedOfficialIdentifiers( + context, + result.partyRef, addedOfficialIdentifierRefs, - (officialIdentifierRef) => { - const addedIdentifier = { officialIdentifierRef, partyRef }; - return context - .addDomainEvent({ - eventType: 'party.registry.official-identifier-added.v1', - payloadJson: addedIdentifier, - producerModuleKey: 'party.registry', - subjectModuleKey: 'party.registry', - subjectResourceId: officialIdentifierRef.resourceId, - subjectResourceType: officialIdentifierRef.resourceType, - }) - .pipe( - Effect.flatMap((event) => - context.addOutboxMessage( - event, - createAddPartyOfficialIdentifierPartyRegistryOfficialIdentifierAddedV1OutboxMessage( - addedIdentifier, - ), - ), - ), - ); - }, - { concurrency: 1, discard: true }, ); } return result; diff --git a/app/verticals/party-registry/src/actions/unarchive-organization-engagement.action.ts b/app/verticals/party-registry/src/actions/unarchive-organization-engagement.action.ts index d11b30c09..017b781eb 100644 --- a/app/verticals/party-registry/src/actions/unarchive-organization-engagement.action.ts +++ b/app/verticals/party-registry/src/actions/unarchive-organization-engagement.action.ts @@ -7,12 +7,8 @@ import { defineTenantModuleEntrypoint, OperationContextUnavailable, } from '@app/core-runtime'; -import type { ActionHandlerContext } from '@app/core-runtime'; -import { Effect, Schema } from 'effect'; +import { Effect } from 'effect'; import { - EngagementProfileConflict, - EngagementProfileNotFound, - EngagementProfilePersistenceUnavailable, OrganizationEngagementLifecyclePayloadSchema, OrganizationEngagementProfileSchema, } from '../../shared/domain/engagement-profile.ts'; @@ -21,37 +17,10 @@ import type { OrganizationEngagementProfile, } from '../../shared/domain/engagement-profile.ts'; import { transitionOrganizationEngagementProfile } from '../services/engagement-profile-persistence.service.ts'; -import type { LifecycleResult } from '../services/engagement-profile-persistence.service.ts'; -import { resolveEngagementLifecycle } from './engagement-lifecycle.ts'; - -const UnarchiveOrganizationEngagementPayload = OrganizationEngagementLifecyclePayloadSchema; -const UnarchiveOrganizationEngagementResult = OrganizationEngagementProfileSchema; -const UnarchiveOrganizationEngagementError = Schema.Union([ - EngagementProfileConflict, - EngagementProfileNotFound, - EngagementProfilePersistenceUnavailable, -]); - -interface Services { - readonly unarchive: ( - profileId: string, - ) => Effect.Effect< - LifecycleResult, - EngagementProfilePersistenceUnavailable - >; -} - -const handleUnarchiveOrganizationEngagement = ( - payload: OrganizationEngagementLifecyclePayload, - context: ActionHandlerContext>, Services>, -) => - context.services - .unarchive(payload.profileRef.resourceId) - .pipe( - Effect.flatMap((result) => - resolveEngagementLifecycle(result, payload.profileRef.resourceId, 'active'), - ), - ); +import { + EngagementLifecycleErrorSchema, + handleEngagementLifecycle, +} from './engagement-lifecycle-handler.ts'; export const unarchiveOrganizationEngagementAction = defineAction( { @@ -61,7 +30,7 @@ export const unarchiveOrganizationEngagementAction = defineAction( }, actionKey: 'party.registry.unarchive-organization-engagement', auditProfile: 'standard', - domainErrorSchema: UnarchiveOrganizationEngagementError, + domainErrorSchema: EngagementLifecycleErrorSchema, domainEvents: {}, entrypoint: defineTenantModuleEntrypoint({ access: 'write', @@ -73,7 +42,7 @@ export const unarchiveOrganizationEngagementAction = defineAction( idempotency: 'required', legalEntityScope: 'required', owningModuleKey: 'party.registry', - payloadSchema: UnarchiveOrganizationEngagementPayload, + payloadSchema: OrganizationEngagementLifecyclePayloadSchema, policies: [], resourcePermission: defineActionResourcePermission( (payload) => ({ @@ -85,10 +54,12 @@ export const unarchiveOrganizationEngagementAction = defineAction( }, }), ), - resultSchema: UnarchiveOrganizationEngagementResult, + resultSchema: OrganizationEngagementProfileSchema, schemaVersion: '1', }, - handleUnarchiveOrganizationEngagement, + handleEngagementLifecycle( + 'active', + ), (transaction, scope) => { if (scope.legalEntityId === undefined) { return Effect.fail( @@ -99,7 +70,7 @@ export const unarchiveOrganizationEngagementAction = defineAction( ); } return Effect.succeed({ - unarchive: (profileId) => + transition: (profileId) => transitionOrganizationEngagementProfile(transaction, scope.tenantId, profileId, 'active'), }); }, diff --git a/app/verticals/party-registry/src/actions/unarchive-person-engagement.action.ts b/app/verticals/party-registry/src/actions/unarchive-person-engagement.action.ts index 1dfe33b68..0863b817e 100644 --- a/app/verticals/party-registry/src/actions/unarchive-person-engagement.action.ts +++ b/app/verticals/party-registry/src/actions/unarchive-person-engagement.action.ts @@ -7,12 +7,8 @@ import { defineTenantModuleEntrypoint, OperationContextUnavailable, } from '@app/core-runtime'; -import type { ActionHandlerContext } from '@app/core-runtime'; -import { Effect, Schema } from 'effect'; +import { Effect } from 'effect'; import { - EngagementProfileConflict, - EngagementProfileNotFound, - EngagementProfilePersistenceUnavailable, PersonEngagementLifecyclePayloadSchema, PersonEngagementProfileSchema, } from '../../shared/domain/engagement-profile.ts'; @@ -21,37 +17,10 @@ import type { PersonEngagementProfile, } from '../../shared/domain/engagement-profile.ts'; import { transitionPersonEngagementProfile } from '../services/engagement-profile-persistence.service.ts'; -import type { LifecycleResult } from '../services/engagement-profile-persistence.service.ts'; -import { resolveEngagementLifecycle } from './engagement-lifecycle.ts'; - -const UnarchivePersonEngagementPayload = PersonEngagementLifecyclePayloadSchema; -const UnarchivePersonEngagementResult = PersonEngagementProfileSchema; -const UnarchivePersonEngagementError = Schema.Union([ - EngagementProfileConflict, - EngagementProfileNotFound, - EngagementProfilePersistenceUnavailable, -]); - -interface Services { - readonly unarchive: ( - profileId: string, - ) => Effect.Effect< - LifecycleResult, - EngagementProfilePersistenceUnavailable - >; -} - -const handleUnarchivePersonEngagement = ( - payload: PersonEngagementLifecyclePayload, - context: ActionHandlerContext>, Services>, -) => - context.services - .unarchive(payload.profileRef.resourceId) - .pipe( - Effect.flatMap((result) => - resolveEngagementLifecycle(result, payload.profileRef.resourceId, 'active'), - ), - ); +import { + EngagementLifecycleErrorSchema, + handleEngagementLifecycle, +} from './engagement-lifecycle-handler.ts'; export const unarchivePersonEngagementAction = defineAction( { @@ -61,7 +30,7 @@ export const unarchivePersonEngagementAction = defineAction( }, actionKey: 'party.registry.unarchive-person-engagement', auditProfile: 'standard', - domainErrorSchema: UnarchivePersonEngagementError, + domainErrorSchema: EngagementLifecycleErrorSchema, domainEvents: {}, entrypoint: defineTenantModuleEntrypoint({ access: 'write', @@ -73,7 +42,7 @@ export const unarchivePersonEngagementAction = defineAction( idempotency: 'required', legalEntityScope: 'required', owningModuleKey: 'party.registry', - payloadSchema: UnarchivePersonEngagementPayload, + payloadSchema: PersonEngagementLifecyclePayloadSchema, policies: [], resourcePermission: defineActionResourcePermission( (payload) => ({ @@ -85,10 +54,10 @@ export const unarchivePersonEngagementAction = defineAction( }, }), ), - resultSchema: UnarchivePersonEngagementResult, + resultSchema: PersonEngagementProfileSchema, schemaVersion: '1', }, - handleUnarchivePersonEngagement, + handleEngagementLifecycle('active'), (transaction, scope) => { if (scope.legalEntityId === undefined) { return Effect.fail( @@ -99,7 +68,7 @@ export const unarchivePersonEngagementAction = defineAction( ); } return Effect.succeed({ - unarchive: (profileId) => + transition: (profileId) => transitionPersonEngagementProfile(transaction, scope.tenantId, profileId, 'active'), }); }, diff --git a/app/verticals/party-registry/src/actions/update-party-relationship.action.ts b/app/verticals/party-registry/src/actions/update-party-relationship.action.ts index 9fb1fbaa9..00ba8c8f0 100644 --- a/app/verticals/party-registry/src/actions/update-party-relationship.action.ts +++ b/app/verticals/party-registry/src/actions/update-party-relationship.action.ts @@ -6,17 +6,13 @@ import { defineAction, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ActionHandlerContext } from '@app/core-runtime'; import { ChangePartyRelationshipResultSchema, - PartyRelationshipLifecycleEventPayloadSchema, PartyRelationshipLifecycleEventPayloadJsonSchema, UpdateRelationshipAuditEvidenceSchema, UpdateRelationshipAuditEvidenceJsonSchema, PartyRelationshipMutationErrorSchema, UpdatePartyRelationshipPayloadSchema, } from '../../shared/domain/relationship-contract.ts'; -import type { - PartyRelationshipLifecycleEventPayload, - UpdatePartyRelationshipPayload as Payload, -} from '../../shared/domain/relationship-contract.ts'; +import type { UpdatePartyRelationshipPayload as Payload } from '../../shared/domain/relationship-contract.ts'; import { updatePartyRelationshipRecord } from '../services/party-relationship-persistence.service.ts'; import type { RelationshipChangeResult, @@ -24,19 +20,7 @@ import type { } from '../services/party-relationship-persistence.service.ts'; import { createUpdatePartyRelationshipPartyRegistryRelationshipUpdatedV1OutboxMessage } from './update-party-relationship.party-registry-relationship-updated-v1.outbox-message.ts'; -const UpdatePartyRelationshipResultSchema = ChangePartyRelationshipResultSchema; - -const eventPayload = ( - result: RelationshipChangeResult, -): PartyRelationshipLifecycleEventPayload => ({ - fromPartyRef: result.relationship.from.canonicalPartyRef, - relationshipRef: result.relationship.relationshipRef, - relationshipType: result.relationship.relationshipType, - revision: result.relationship.revision, - toPartyRef: result.relationship.to.canonicalPartyRef, - validFrom: result.relationship.validFrom, - validTo: result.relationship.validTo, -}); +import { encodeRelationshipEventPayload } from './relationship-event-payload.ts'; interface Services { readonly update: ( @@ -85,9 +69,7 @@ const handleUpdatePartyRelationship = Effect.fn( relationshipRef: payload.relationshipRef, }).pipe(Effect.orDie); yield* context.recordAuditEvidence(auditEvidence); - const payloadJson = yield* Schema.encodeEffect(PartyRelationshipLifecycleEventPayloadSchema)( - eventPayload(result), - ).pipe(Effect.orDie); + const payloadJson = yield* encodeRelationshipEventPayload(result.relationship); const domainEvent = yield* context.addDomainEvent({ eventType: 'party.registry.relationship-updated.v1', payloadJson, @@ -129,7 +111,7 @@ export const updatePartyRelationshipAction = defineAction( owningModuleKey: 'party.registry', payloadSchema: UpdatePartyRelationshipPayloadSchema, policies: [], - resultSchema: UpdatePartyRelationshipResultSchema, + resultSchema: ChangePartyRelationshipResultSchema, schemaVersion: '1', tenantPermission: () => 'manage_party_relationships', }, diff --git a/app/verticals/party-registry/src/api/counterparty-read-support.ts b/app/verticals/party-registry/src/api/counterparty-read-support.ts new file mode 100644 index 000000000..0c29ccecf --- /dev/null +++ b/app/verticals/party-registry/src/api/counterparty-read-support.ts @@ -0,0 +1,58 @@ +import { ReadHandlerNotFound, ReadHandlerUnavailable } from '@app/core-runtime'; +import { Effect, Match } from 'effect'; +import type { CounterpartyPersistenceUnavailable } from '../../shared/domain/counterparty-errors.ts'; +import type { CounterpartyRef } from '../../shared/party-registry-references.ts'; +import type { LookupResult } from '../services/counterparty-persistence.service.ts'; + +export const counterpartyPermissionTarget = (input: { + readonly counterpartyRef: CounterpartyRef; +}) => ({ + kind: 'any_of' as const, + targets: [ + { + kind: 'resource' as const, + resource: { + moduleId: input.counterpartyRef.moduleId, + resourceId: input.counterpartyRef.resourceId, + resourceType: input.counterpartyRef.resourceType, + }, + }, + { kind: 'tenant' as const, permission: 'manage_party_identity' as const }, + ] as const, +}); + +const notFound = (context: string) => + new ReadHandlerNotFound({ + code: 'read_handler_not_found', + reason: `The Counterparty does not exist in the ${context}`, + }); + +export const resolveCounterpartyRead = ( + ref: CounterpartyRef, + tenantId: string, + load: ( + counterpartyId: string, + ) => Effect.Effect, CounterpartyPersistenceUnavailable>, + unavailableReason: string, +) => + ref.tenantId === tenantId + ? load(ref.resourceId).pipe( + Effect.mapError((cause) => + Object.defineProperty( + new ReadHandlerUnavailable({ + code: 'read_handler_unavailable', + reason: unavailableReason, + }), + 'cause', + { value: cause }, + ), + ), + Effect.flatMap((result) => + Match.value(result).pipe( + Match.tag('found', ({ value }) => Effect.succeed(value)), + Match.tag('not_found', () => Effect.fail(notFound('authorized context'))), + Match.exhaustive, + ), + ), + ) + : Effect.fail(notFound('trusted Tenant')); diff --git a/app/verticals/party-registry/src/api/counterparty-read.read.ts b/app/verticals/party-registry/src/api/counterparty-read.read.ts index 04cf17692..54280b93e 100644 --- a/app/verticals/party-registry/src/api/counterparty-read.read.ts +++ b/app/verticals/party-registry/src/api/counterparty-read.read.ts @@ -1,20 +1,16 @@ +import { + counterpartyPermissionTarget, + resolveCounterpartyRead, +} from './counterparty-read-support.ts'; // @generated by OntOS Codesmith module-api v1 import type { ReadHandlerContext } from '@app/core-runtime'; -import { - defineRead, - defineTenantModuleEntrypoint, - ReadHandlerNotFound, - ReadHandlerUnavailable, -} from '@app/core-runtime'; -import { Effect, Match } from 'effect'; +import { defineRead, defineTenantModuleEntrypoint } from '@app/core-runtime'; +import { Effect } from 'effect'; import { CounterpartyReadRequestSchema, CounterpartyReadResponseSchema, } from '../../shared/apis/counterparty-read.ts'; -import type { - CounterpartyReadRequest, - CounterpartyReadResponse, -} from '../../shared/apis/counterparty-read.ts'; +import type { CounterpartyReadResponse } from '../../shared/apis/counterparty-read.ts'; import type { CounterpartyPersistenceUnavailable } from '../../shared/domain/counterparty-errors.ts'; import { findCounterpartyRecord } from '../services/counterparty-persistence.service.ts'; import type { LookupResult } from '../services/counterparty-persistence.service.ts'; @@ -27,30 +23,7 @@ const counterpartyReadEntrypoint = defineTenantModuleEntrypoint({ role: 'api', }); -export const counterpartyReadPermissionTarget = (input: CounterpartyReadRequest) => ({ - kind: 'any_of' as const, - targets: [ - { - kind: 'resource' as const, - resource: { - moduleId: input.counterpartyRef.moduleId, - resourceId: input.counterpartyRef.resourceId, - resourceType: input.counterpartyRef.resourceType, - }, - }, - { kind: 'tenant' as const, permission: 'manage_party_identity' as const }, - ] as const, -}); - -const counterpartyUnavailable = (cause: unknown) => - Object.defineProperty( - new ReadHandlerUnavailable({ - code: 'read_handler_unavailable', - reason: 'Counterparty persistence is temporarily unavailable', - }), - 'cause', - { value: cause }, - ); +export const counterpartyReadPermissionTarget = counterpartyPermissionTarget; export const counterpartyReadRead = defineRead( { @@ -80,32 +53,12 @@ export const counterpartyReadRead = defineRead( >; }>, ) => - input.counterpartyRef.tenantId === context.scope.tenantId - ? context.services.find(input.counterpartyRef.resourceId).pipe( - Effect.mapError(counterpartyUnavailable), - Effect.flatMap((result) => - Match.value(result).pipe( - Match.tag('found', ({ value }) => - Effect.succeed({ evidence: { resultCount: 1 }, result: value }), - ), - Match.tag('not_found', () => - Effect.fail( - new ReadHandlerNotFound({ - code: 'read_handler_not_found', - reason: 'The Counterparty does not exist in the authorized context', - }), - ), - ), - Match.exhaustive, - ), - ), - ) - : Effect.fail( - new ReadHandlerNotFound({ - code: 'read_handler_not_found', - reason: 'The Counterparty does not exist in the trusted Tenant', - }), - ), + resolveCounterpartyRead( + input.counterpartyRef, + context.scope.tenantId, + context.services.find, + 'Counterparty persistence is temporarily unavailable', + ).pipe(Effect.map((value) => ({ evidence: { resultCount: 1 }, result: value }))), (transaction, scope) => Effect.succeed({ find: (counterpartyId: string) => diff --git a/app/verticals/party-registry/src/api/counterparty-role-history.read.ts b/app/verticals/party-registry/src/api/counterparty-role-history.read.ts index 7eccda583..33ab3bdc3 100644 --- a/app/verticals/party-registry/src/api/counterparty-role-history.read.ts +++ b/app/verticals/party-registry/src/api/counterparty-role-history.read.ts @@ -1,20 +1,16 @@ +import { + counterpartyPermissionTarget, + resolveCounterpartyRead, +} from './counterparty-read-support.ts'; // @generated by OntOS Codesmith module-api v1 import type { ReadHandlerContext } from '@app/core-runtime'; -import { - defineRead, - defineTenantModuleEntrypoint, - ReadHandlerNotFound, - ReadHandlerUnavailable, -} from '@app/core-runtime'; -import { Effect, Match } from 'effect'; +import { defineRead, defineTenantModuleEntrypoint } from '@app/core-runtime'; +import { Effect } from 'effect'; import { CounterpartyRoleHistoryRequestSchema, CounterpartyRoleHistoryResponseSchema, } from '../../shared/apis/counterparty-role-history.ts'; -import type { - CounterpartyRoleHistoryRequest, - CounterpartyRoleHistoryResponse, -} from '../../shared/apis/counterparty-role-history.ts'; +import type { CounterpartyRoleHistoryResponse } from '../../shared/apis/counterparty-role-history.ts'; import type { CounterpartyPersistenceUnavailable } from '../../shared/domain/counterparty-errors.ts'; import { listCounterpartyRoleHistory } from '../services/counterparty-persistence.service.ts'; import type { LookupResult } from '../services/counterparty-persistence.service.ts'; @@ -27,30 +23,7 @@ const counterpartyRoleHistoryEntrypoint = defineTenantModuleEntrypoint({ role: 'api', }); -export const counterpartyRoleHistoryPermissionTarget = (input: CounterpartyRoleHistoryRequest) => ({ - kind: 'any_of' as const, - targets: [ - { - kind: 'resource' as const, - resource: { - moduleId: input.counterpartyRef.moduleId, - resourceId: input.counterpartyRef.resourceId, - resourceType: input.counterpartyRef.resourceType, - }, - }, - { kind: 'tenant' as const, permission: 'manage_party_identity' as const }, - ] as const, -}); - -const roleHistoryUnavailable = (cause: unknown) => - Object.defineProperty( - new ReadHandlerUnavailable({ - code: 'read_handler_unavailable', - reason: 'Counterparty Role history is temporarily unavailable', - }), - 'cause', - { value: cause }, - ); +export const counterpartyRoleHistoryPermissionTarget = counterpartyPermissionTarget; export const counterpartyRoleHistoryRead = defineRead( { @@ -80,35 +53,17 @@ export const counterpartyRoleHistoryRead = defineRead( >; }>, ) => - input.counterpartyRef.tenantId === context.scope.tenantId - ? context.services.list(input.counterpartyRef.resourceId).pipe( - Effect.mapError(roleHistoryUnavailable), - Effect.flatMap((result) => - Match.value(result).pipe( - Match.tag('found', ({ value }) => - Effect.succeed({ - evidence: { resultCount: value.length }, - result: { counterpartyRef: input.counterpartyRef, roles: value }, - }), - ), - Match.tag('not_found', () => - Effect.fail( - new ReadHandlerNotFound({ - code: 'read_handler_not_found', - reason: 'The Counterparty does not exist in the authorized context', - }), - ), - ), - Match.exhaustive, - ), - ), - ) - : Effect.fail( - new ReadHandlerNotFound({ - code: 'read_handler_not_found', - reason: 'The Counterparty does not exist in the trusted Tenant', - }), - ), + resolveCounterpartyRead( + input.counterpartyRef, + context.scope.tenantId, + context.services.list, + 'Counterparty Role history is temporarily unavailable', + ).pipe( + Effect.map((value) => ({ + evidence: { resultCount: value.length }, + result: { counterpartyRef: input.counterpartyRef, roles: value }, + })), + ), (transaction, scope) => Effect.succeed({ list: (counterpartyId: string) => diff --git a/app/verticals/party-registry/src/api/duplicate-candidate-detail.read.ts b/app/verticals/party-registry/src/api/duplicate-candidate-detail.read.ts index e57e8078a..b01dc391b 100644 --- a/app/verticals/party-registry/src/api/duplicate-candidate-detail.read.ts +++ b/app/verticals/party-registry/src/api/duplicate-candidate-detail.read.ts @@ -1,17 +1,13 @@ // @generated by OntOS Codesmith module-api v1 -import { - ReadHandlerNotFound, - ReadHandlerUnavailable, - defineRead, - defineTenantModuleEntrypoint, -} from '@app/core-runtime'; +import { defineRead, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ReadHandlerContext } from '@app/core-runtime'; -import { Effect, Match, Schema } from 'effect'; +import { Effect, Schema } from 'effect'; import { DuplicateCandidateDetailRequestSchema, DuplicateCandidateDetailResponseSchema, } from '../../shared/apis/duplicate-candidate-detail.ts'; import { findDuplicateCandidateCase } from '../services/party-matching-persistence.service.ts'; +import { readUnavailable, requireReadValue, readDetailResult } from './read-outcome.ts'; const duplicateCandidateDetailEntrypoint = defineTenantModuleEntrypoint({ access: 'read', @@ -23,15 +19,9 @@ const duplicateCandidateDetailEntrypoint = defineTenantModuleEntrypoint({ interface Services { readonly find: (caseId: string) => ReturnType; } -const duplicateCandidateUnavailable = (cause: unknown) => - Object.defineProperty( - new ReadHandlerUnavailable({ - code: 'read_handler_unavailable', - reason: 'Duplicate Candidate persistence is unavailable', - }), - 'cause', - { value: cause }, - ); +const duplicateCandidateUnavailable = readUnavailable( + 'Duplicate Candidate persistence is unavailable', +); export const duplicateCandidateDetailRead = defineRead( { accessKind: 'detail', @@ -52,25 +42,13 @@ export const duplicateCandidateDetailRead = defineRead( (input, context: ReadHandlerContext) => context.services.find(input.caseRef.resourceId).pipe( Effect.mapError(duplicateCandidateUnavailable), - Effect.flatMap((found) => - Match.value(found).pipe( - Match.tag('found', ({ value }) => - Schema.decodeUnknownEffect(DuplicateCandidateDetailResponseSchema)(value).pipe( - Effect.map((result) => ({ evidence: { resultCount: 1 }, result })), - Effect.mapError(duplicateCandidateUnavailable), - ), - ), - Match.tag('not_found', () => - Effect.fail( - new ReadHandlerNotFound({ - code: 'read_handler_not_found', - reason: 'The Duplicate Candidate case does not exist', - }), - ), - ), - Match.exhaustive, + Effect.flatMap(requireReadValue('The Duplicate Candidate case does not exist')), + Effect.flatMap((value) => + Schema.decodeUnknownEffect(DuplicateCandidateDetailResponseSchema)(value).pipe( + Effect.mapError(duplicateCandidateUnavailable), ), ), + Effect.map(readDetailResult), ), (transaction, scope) => Effect.succeed({ diff --git a/app/verticals/party-registry/src/api/engagement-profile-client.ts b/app/verticals/party-registry/src/api/engagement-profile-client.ts index e6941a2cb..b65c1f7f4 100644 --- a/app/verticals/party-registry/src/api/engagement-profile-client.ts +++ b/app/verticals/party-registry/src/api/engagement-profile-client.ts @@ -7,14 +7,7 @@ import { engagementProfileOperationContexts, partyRegistryOperationContexts, } from '../../shared/api.ts'; -import type { - AttachOrganizationEngagementPayload, - AttachPersonEngagementPayload, - OperationContext, - OrganizationEngagementLifecyclePayload, - PartyRegistryReadiness, - PersonEngagementLifecyclePayload, -} from '../../shared/api.ts'; +import type { OperationContext, PartyRegistryReadiness } from '../../shared/api.ts'; import { operationGateway } from './action-gateway.ts'; import { authenticatePartyRegistryHttpRequest, @@ -74,9 +67,20 @@ const invoke = ( return invokePartyRegistryHttpClient(requestContext, operation); }, options.gateway); -const mutationHeaders = (options: ContactsMutationOptions) => ({ - 'idempotency-key': options.idempotencyKey, -}); +const engagementMutation = + ( + context: OperationContext, + endpoint: ( + client: ContactsClient, + ) => (request: { + headers: { 'idempotency-key': string }; + payload: Payload; + }) => Effect.Effect, + ) => + (payload: Payload, options: ContactsMutationOptions) => + invoke(options, context, (client) => + endpoint(client)({ headers: { 'idempotency-key': options.idempotencyKey }, payload }), + ); export const getContactsReadiness = ( options: ContactsClientOptions = {}, @@ -86,59 +90,32 @@ export const getContactsReadiness = ( operationContext: options.operationContext ?? partyRegistryOperationContexts.readiness, }).pipe(Effect.flatMap((client) => client.foundation.readiness({}))); -export const attachOrganizationEngagement = ( - payload: AttachOrganizationEngagementPayload, - options: ContactsMutationOptions, -) => - invoke(options, engagementProfileOperationContexts.attachOrganizationEngagement, (client) => - client.organizationEngagementMutations.attach({ - headers: mutationHeaders(options), - payload, - }), - ); - -export const archiveOrganizationEngagement = ( - payload: OrganizationEngagementLifecyclePayload, - options: ContactsMutationOptions, -) => - invoke(options, engagementProfileOperationContexts.archiveOrganizationEngagement, (client) => - client.organizationEngagementMutations.archive({ - headers: mutationHeaders(options), - payload, - }), - ); - -export const unarchiveOrganizationEngagement = ( - payload: OrganizationEngagementLifecyclePayload, - options: ContactsMutationOptions, -) => - invoke(options, engagementProfileOperationContexts.unarchiveOrganizationEngagement, (client) => - client.organizationEngagementMutations.unarchive({ - headers: mutationHeaders(options), - payload, - }), - ); - -export const attachPersonEngagement = ( - payload: AttachPersonEngagementPayload, - options: ContactsMutationOptions, -) => - invoke(options, engagementProfileOperationContexts.attachPersonEngagement, (client) => - client.personEngagementMutations.attach({ headers: mutationHeaders(options), payload }), - ); - -export const archivePersonEngagement = ( - payload: PersonEngagementLifecyclePayload, - options: ContactsMutationOptions, -) => - invoke(options, engagementProfileOperationContexts.archivePersonEngagement, (client) => - client.personEngagementMutations.archive({ headers: mutationHeaders(options), payload }), - ); - -export const unarchivePersonEngagement = ( - payload: PersonEngagementLifecyclePayload, - options: ContactsMutationOptions, -) => - invoke(options, engagementProfileOperationContexts.unarchivePersonEngagement, (client) => - client.personEngagementMutations.unarchive({ headers: mutationHeaders(options), payload }), - ); +export const attachOrganizationEngagement = engagementMutation( + engagementProfileOperationContexts.attachOrganizationEngagement, + (client) => client.organizationEngagementMutations.attach, +); + +export const archiveOrganizationEngagement = engagementMutation( + engagementProfileOperationContexts.archiveOrganizationEngagement, + (client) => client.organizationEngagementMutations.archive, +); + +export const unarchiveOrganizationEngagement = engagementMutation( + engagementProfileOperationContexts.unarchiveOrganizationEngagement, + (client) => client.organizationEngagementMutations.unarchive, +); + +export const attachPersonEngagement = engagementMutation( + engagementProfileOperationContexts.attachPersonEngagement, + (client) => client.personEngagementMutations.attach, +); + +export const archivePersonEngagement = engagementMutation( + engagementProfileOperationContexts.archivePersonEngagement, + (client) => client.personEngagementMutations.archive, +); + +export const unarchivePersonEngagement = engagementMutation( + engagementProfileOperationContexts.unarchivePersonEngagement, + (client) => client.personEngagementMutations.unarchive, +); diff --git a/app/verticals/party-registry/src/api/organization-engagement-profile.read.ts b/app/verticals/party-registry/src/api/organization-engagement-profile.read.ts index e4b9e5655..2beefe12f 100644 --- a/app/verticals/party-registry/src/api/organization-engagement-profile.read.ts +++ b/app/verticals/party-registry/src/api/organization-engagement-profile.read.ts @@ -1,23 +1,17 @@ // @generated by OntOS Codesmith module-api v1 import { OperationContextUnavailable, - ReadHandlerNotFound, - ReadHandlerUnavailable, defineRead, defineTenantModuleEntrypoint, } from '@app/core-runtime'; import type { ReadHandlerContext } from '@app/core-runtime'; -import { Effect, Match } from 'effect'; +import { Effect } from 'effect'; import { OrganizationEngagementProfileRequestSchema, OrganizationEngagementProfileResponseSchema, } from '../../shared/apis/organization-engagement-profile.ts'; -import type { - OrganizationEngagementProfile, - EngagementProfilePersistenceUnavailable, -} from '../../shared/domain/engagement-profile.ts'; import { findOrganizationEngagementProfile } from '../services/engagement-profile-persistence.service.ts'; -import type { LookupResult } from '../services/engagement-profile-persistence.service.ts'; +import { readUnavailable, requireReadValue, readDetailResult } from './read-outcome.ts'; const organizationEngagementProfileEntrypoint = defineTenantModuleEntrypoint({ access: 'read', @@ -28,23 +22,12 @@ const organizationEngagementProfileEntrypoint = defineTenantModuleEntrypoint({ }); interface Services { - readonly find: ( - profileId: string, - ) => Effect.Effect< - LookupResult, - EngagementProfilePersistenceUnavailable - >; + readonly find: (profileId: string) => ReturnType; } -const organizationProfileUnavailable = (cause: unknown) => - Object.defineProperty( - new ReadHandlerUnavailable({ - code: 'read_handler_unavailable', - reason: 'Contacts engagement persistence is temporarily unavailable', - }), - 'cause', - { value: cause }, - ); +const organizationProfileUnavailable = readUnavailable( + 'Contacts engagement persistence is temporarily unavailable', +); export const organizationEngagementProfileRead = defineRead( { @@ -64,25 +47,13 @@ export const organizationEngagementProfileRead = defineRead( schemaVersion: '1', }, (input, context: ReadHandlerContext) => - context.services.find(input.profileRef.resourceId).pipe( - Effect.mapError(organizationProfileUnavailable), - Effect.flatMap((result) => - Match.value(result).pipe( - Match.tag('found', ({ value }) => - Effect.succeed({ evidence: { resultCount: 1 }, result: value }), - ), - Match.tag('not_found', () => - Effect.fail( - new ReadHandlerNotFound({ - code: 'read_handler_not_found', - reason: 'The organization engagement profile does not exist', - }), - ), - ), - Match.exhaustive, - ), + context.services + .find(input.profileRef.resourceId) + .pipe( + Effect.mapError(organizationProfileUnavailable), + Effect.flatMap(requireReadValue('The organization engagement profile does not exist')), + Effect.map(readDetailResult), ), - ), (transaction, scope) => { if (scope.legalEntityId === undefined) { return Effect.fail( diff --git a/app/verticals/party-registry/src/api/party-command-client.ts b/app/verticals/party-registry/src/api/party-command-client.ts index 7f090a9a9..2c9d9c063 100644 --- a/app/verticals/party-registry/src/api/party-command-client.ts +++ b/app/verticals/party-registry/src/api/party-command-client.ts @@ -189,90 +189,68 @@ export const resolvePartyCommandCommit = ( resolvePartyCommandCommitWithAuthorization(payload, authorization, options), ); -export const addContactPointWithAuthorization = ( - payload: AddContactPointPayload, - ...[credential, options]: PartyCommandInvocation -) => - invokeAuthorized(credential, options, (client) => +const defineCommand = ( + operation: ( + client: PartyCommandClient, + payload: Payload, + headers: { readonly 'idempotency-key': string }, + ) => Effect.Effect, +) => { + const authorized = (payload: Payload, ...[credential, options]: PartyCommandInvocation) => + invokeAuthorized(credential, options, (client) => + operation(client, payload, { 'idempotency-key': options.idempotencyKey }), + ); + const execute = (payload: Payload, options: PartyCommandOptions) => + invoke(options, (credential) => authorized(payload, credential, options)); + return { authorized, execute }; +}; + +export const { authorized: addContactPointWithAuthorization, execute: addContactPoint } = + defineCommand((client, payload: AddContactPointPayload, headers) => Schema.encodeUnknownEffect(AddContactPointPayloadSchema)(payload).pipe( Effect.flatMap((endpointPayload) => client.partyCommands.addContactPoint({ - headers: { 'idempotency-key': options.idempotencyKey }, + headers, payload: endpointPayload, }), ), ), ); -export const addContactPoint = (payload: AddContactPointPayload, options: PartyCommandOptions) => - invoke(options, (authorization) => - addContactPointWithAuthorization(payload, authorization, options), - ); - -export const addPartyOfficialIdentifierWithAuthorization = ( - payload: AddPartyOfficialIdentifierPayload, - ...[credential, options]: PartyCommandInvocation -) => - invokeAuthorized(credential, options, (client) => - Schema.encodeUnknownEffect(AddPartyOfficialIdentifierPayloadSchema)(payload).pipe( - Effect.flatMap((endpointPayload) => - client.partyCommands.addPartyOfficialIdentifier({ - headers: { 'idempotency-key': options.idempotencyKey }, - payload: endpointPayload, - }), - ), +export const { + authorized: addPartyOfficialIdentifierWithAuthorization, + execute: addPartyOfficialIdentifier, +} = defineCommand((client, payload: AddPartyOfficialIdentifierPayload, headers) => + Schema.encodeUnknownEffect(AddPartyOfficialIdentifierPayloadSchema)(payload).pipe( + Effect.flatMap((endpointPayload) => + client.partyCommands.addPartyOfficialIdentifier({ + headers, + payload: endpointPayload, + }), ), - ); - -export const addPartyOfficialIdentifier = ( - payload: AddPartyOfficialIdentifierPayload, - options: PartyCommandOptions, -) => - invoke(options, (authorization) => - addPartyOfficialIdentifierWithAuthorization(payload, authorization, options), - ); + ), +); -export const archivePartyWithAuthorization = ( - payload: ArchivePartyPayload, - ...[credential, options]: PartyCommandInvocation -) => - invokeAuthorized(credential, options, (client) => +export const { authorized: archivePartyWithAuthorization, execute: archiveParty } = defineCommand( + (client, payload: ArchivePartyPayload, headers) => client.partyCommands.archiveParty({ - headers: { 'idempotency-key': options.idempotencyKey }, + headers, payload, }), - ); - -export const archiveParty = (payload: ArchivePartyPayload, options: PartyCommandOptions) => - invoke(options, (authorization) => - archivePartyWithAuthorization(payload, authorization, options), - ); - -export const confirmDuplicatePartiesWithAuthorization = ( - payload: ConfirmDuplicatePartiesPayload, - ...[credential, options]: PartyCommandInvocation -) => - invokeAuthorized(credential, options, (client) => - client.partyCommands.confirmDuplicateParties({ - headers: { 'idempotency-key': options.idempotencyKey }, - payload, - }), - ); +); -export const confirmDuplicateParties = ( - payload: ConfirmDuplicatePartiesPayload, - options: PartyCommandOptions, -) => - invoke(options, (authorization) => - confirmDuplicatePartiesWithAuthorization(payload, authorization, options), - ); +export const { + authorized: confirmDuplicatePartiesWithAuthorization, + execute: confirmDuplicateParties, +} = defineCommand((client, payload: ConfirmDuplicatePartiesPayload, headers) => + client.partyCommands.confirmDuplicateParties({ + headers, + payload, + }), +); -export const correctPartyFactWithAuthorization = ( - payload: CorrectPartyFactPayload, - ...[credential, options]: PartyCommandInvocation -) => - invokeAuthorized(credential, options, (client) => { - const headers = { 'idempotency-key': options.idempotencyKey }; +export const { authorized: correctPartyFactWithAuthorization, execute: correctPartyFact } = + defineCommand((client, payload: CorrectPartyFactPayload, headers) => { // HttpApi retains an overload for each union member; narrow without weakening its schema. if (payload.factKind !== 'RELATIONSHIP') { return client.partyCommands.correctPartyFact({ headers, payload }); @@ -283,358 +261,181 @@ export const correctPartyFactWithAuthorization = ( return client.partyCommands.correctPartyFact({ headers, payload }); }); -export const correctPartyFact = (payload: CorrectPartyFactPayload, options: PartyCommandOptions) => - invoke(options, (authorization) => - correctPartyFactWithAuthorization(payload, authorization, options), - ); - -export const counterpartyCreateWithAuthorization = ( - payload: CounterpartyCreatePayload, - ...[credential, options]: PartyCommandInvocation -) => - invokeAuthorized(credential, options, (client) => +export const { authorized: counterpartyCreateWithAuthorization, execute: counterpartyCreate } = + defineCommand((client, payload: CounterpartyCreatePayload, headers) => client.partyCommands.counterpartyCreate({ - headers: { 'idempotency-key': options.idempotencyKey }, + headers, payload, }), ); -export const counterpartyCreate = ( - payload: CounterpartyCreatePayload, - options: PartyCommandOptions, -) => - invoke(options, (authorization) => - counterpartyCreateWithAuthorization(payload, authorization, options), - ); - -export const counterpartyRoleAddWithAuthorization = ( - payload: CounterpartyRoleAddPayload, - ...[credential, options]: PartyCommandInvocation -) => - invokeAuthorized(credential, options, (client) => +export const { authorized: counterpartyRoleAddWithAuthorization, execute: counterpartyRoleAdd } = + defineCommand((client, payload: CounterpartyRoleAddPayload, headers) => client.partyCommands.counterpartyRoleAdd({ - headers: { 'idempotency-key': options.idempotencyKey }, + headers, payload, }), ); -export const counterpartyRoleAdd = ( - payload: CounterpartyRoleAddPayload, - options: PartyCommandOptions, -) => - invoke(options, (authorization) => - counterpartyRoleAddWithAuthorization(payload, authorization, options), - ); - -export const counterpartyRoleEndWithAuthorization = ( - payload: CounterpartyRoleEndPayload, - ...[credential, options]: PartyCommandInvocation -) => - invokeAuthorized(credential, options, (client) => +export const { authorized: counterpartyRoleEndWithAuthorization, execute: counterpartyRoleEnd } = + defineCommand((client, payload: CounterpartyRoleEndPayload, headers) => client.partyCommands.counterpartyRoleEnd({ - headers: { 'idempotency-key': options.idempotencyKey }, - payload, - }), - ); - -export const counterpartyRoleEnd = ( - payload: CounterpartyRoleEndPayload, - options: PartyCommandOptions, -) => - invoke(options, (authorization) => - counterpartyRoleEndWithAuthorization(payload, authorization, options), - ); - -export const createPartyRelationshipWithAuthorization = ( - payload: CreatePartyRelationshipPayload, - ...[credential, options]: PartyCommandInvocation -) => - invokeAuthorized(credential, options, (client) => - client.partyCommands.createPartyRelationship({ - headers: { 'idempotency-key': options.idempotencyKey }, + headers, payload, }), ); -export const createPartyRelationship = ( - payload: CreatePartyRelationshipPayload, - options: PartyCommandOptions, -) => - invoke(options, (authorization) => - createPartyRelationshipWithAuthorization(payload, authorization, options), - ); +export const { + authorized: createPartyRelationshipWithAuthorization, + execute: createPartyRelationship, +} = defineCommand((client, payload: CreatePartyRelationshipPayload, headers) => + client.partyCommands.createPartyRelationship({ + headers, + payload, + }), +); -export const createPartyWithAuthorization = ( - payload: CreatePartyPayload, - ...[credential, options]: PartyCommandInvocation -) => - invokeAuthorized(credential, options, (client) => +export const { authorized: createPartyWithAuthorization, execute: createParty } = defineCommand( + (client, payload: CreatePartyPayload, headers) => Schema.encodeUnknownEffect(CreatePartyPayloadSchema)(payload).pipe( Effect.flatMap((endpointPayload) => client.partyCommands.createParty({ - headers: { 'idempotency-key': options.idempotencyKey }, + headers, payload: endpointPayload, }), ), ), - ); - -export const createParty = (payload: CreatePartyPayload, options: PartyCommandOptions) => - invoke(options, (credential) => createPartyWithAuthorization(payload, credential, options)); - -export const dismissDuplicateCandidateWithAuthorization = ( - payload: DismissDuplicateCandidatePayload, - ...[credential, options]: PartyCommandInvocation -) => - invokeAuthorized(credential, options, (client) => - client.partyCommands.dismissDuplicateCandidate({ - headers: { 'idempotency-key': options.idempotencyKey }, - payload, - }), - ); +); -export const dismissDuplicateCandidate = ( - payload: DismissDuplicateCandidatePayload, - options: PartyCommandOptions, -) => - invoke(options, (authorization) => - dismissDuplicateCandidateWithAuthorization(payload, authorization, options), - ); +export const { + authorized: dismissDuplicateCandidateWithAuthorization, + execute: dismissDuplicateCandidate, +} = defineCommand((client, payload: DismissDuplicateCandidatePayload, headers) => + client.partyCommands.dismissDuplicateCandidate({ + headers, + payload, + }), +); -export const endContactPointWithAuthorization = ( - payload: EndContactPointPayload, - ...[credential, options]: PartyCommandInvocation -) => - invokeAuthorized(credential, options, (client) => +export const { authorized: endContactPointWithAuthorization, execute: endContactPoint } = + defineCommand((client, payload: EndContactPointPayload, headers) => client.partyCommands.endContactPoint({ - headers: { 'idempotency-key': options.idempotencyKey }, + headers, payload, }), ); -export const endContactPoint = (payload: EndContactPointPayload, options: PartyCommandOptions) => - invoke(options, (authorization) => - endContactPointWithAuthorization(payload, authorization, options), - ); - -export const endPartyOfficialIdentifierWithAuthorization = ( - payload: EndPartyOfficialIdentifierPayload, - ...[credential, options]: PartyCommandInvocation -) => - invokeAuthorized(credential, options, (client) => - client.partyCommands.endPartyOfficialIdentifier({ - headers: { 'idempotency-key': options.idempotencyKey }, - payload, - }), - ); - -export const endPartyOfficialIdentifier = ( - payload: EndPartyOfficialIdentifierPayload, - options: PartyCommandOptions, -) => - invoke(options, (authorization) => - endPartyOfficialIdentifierWithAuthorization(payload, authorization, options), - ); +export const { + authorized: endPartyOfficialIdentifierWithAuthorization, + execute: endPartyOfficialIdentifier, +} = defineCommand((client, payload: EndPartyOfficialIdentifierPayload, headers) => + client.partyCommands.endPartyOfficialIdentifier({ + headers, + payload, + }), +); -export const endPartyRelationshipWithAuthorization = ( - payload: EndPartyRelationshipPayload, - ...[credential, options]: PartyCommandInvocation -) => - invokeAuthorized(credential, options, (client) => +export const { authorized: endPartyRelationshipWithAuthorization, execute: endPartyRelationship } = + defineCommand((client, payload: EndPartyRelationshipPayload, headers) => client.partyCommands.endPartyRelationship({ - headers: { 'idempotency-key': options.idempotencyKey }, - payload, - }), - ); - -export const endPartyRelationship = ( - payload: EndPartyRelationshipPayload, - options: PartyCommandOptions, -) => - invoke(options, (authorization) => - endPartyRelationshipWithAuthorization(payload, authorization, options), - ); - -export const markDuplicateCandidateNeedsEvidenceWithAuthorization = ( - payload: MarkDuplicateCandidateNeedsEvidencePayload, - ...[credential, options]: PartyCommandInvocation -) => - invokeAuthorized(credential, options, (client) => - client.partyCommands.markDuplicateCandidateNeedsEvidence({ - headers: { 'idempotency-key': options.idempotencyKey }, + headers, payload, }), ); -export const markDuplicateCandidateNeedsEvidence = ( - payload: MarkDuplicateCandidateNeedsEvidencePayload, - options: PartyCommandOptions, -) => - invoke(options, (authorization) => - markDuplicateCandidateNeedsEvidenceWithAuthorization(payload, authorization, options), - ); +export const { + authorized: markDuplicateCandidateNeedsEvidenceWithAuthorization, + execute: markDuplicateCandidateNeedsEvidence, +} = defineCommand((client, payload: MarkDuplicateCandidateNeedsEvidencePayload, headers) => + client.partyCommands.markDuplicateCandidateNeedsEvidence({ + headers, + payload, + }), +); -export const matchPartyWithAuthorization = ( - payload: MatchPartyPayload, - ...[credential, options]: PartyCommandInvocation -) => - invokeAuthorized(credential, options, (client) => +export const { authorized: matchPartyWithAuthorization, execute: matchParty } = defineCommand( + (client, payload: MatchPartyPayload, headers) => client.partyCommands.matchParty({ - headers: { 'idempotency-key': options.idempotencyKey }, + headers, payload, }), - ); - -export const matchParty = (payload: MatchPartyPayload, options: PartyCommandOptions) => - invoke(options, (authorization) => matchPartyWithAuthorization(payload, authorization, options)); +); -export const requestSearchRebuildWithAuthorization = ( - payload: RequestSearchRebuildPayload, - ...[credential, options]: PartyCommandInvocation -) => - invokeAuthorized(credential, options, (client) => +export const { authorized: requestSearchRebuildWithAuthorization, execute: requestSearchRebuild } = + defineCommand((client, payload: RequestSearchRebuildPayload, headers) => client.partyCommands.requestSearchRebuild({ - headers: { 'idempotency-key': options.idempotencyKey }, - payload, - }), - ); - -export const requestSearchRebuild = ( - payload: RequestSearchRebuildPayload, - options: PartyCommandOptions, -) => - invoke(options, (authorization) => - requestSearchRebuildWithAuthorization(payload, authorization, options), - ); - -export const resolveDuplicateCandidateCreateWithAuthorization = ( - payload: ResolveDuplicateCandidateCreatePayload, - ...[credential, options]: PartyCommandInvocation -) => - invokeAuthorized(credential, options, (client) => - client.partyCommands.resolveDuplicateCandidateCreate({ - headers: { 'idempotency-key': options.idempotencyKey }, + headers, payload, }), ); -export const resolveDuplicateCandidateCreate = ( - payload: ResolveDuplicateCandidateCreatePayload, - options: PartyCommandOptions, -) => - invoke(options, (authorization) => - resolveDuplicateCandidateCreateWithAuthorization(payload, authorization, options), - ); - -export const resolveDuplicateCandidateMatchWithAuthorization = ( - payload: ResolveDuplicateCandidateMatchPayload, - ...[credential, options]: PartyCommandInvocation -) => - invokeAuthorized(credential, options, (client) => - client.partyCommands.resolveDuplicateCandidateMatch({ - headers: { 'idempotency-key': options.idempotencyKey }, - payload, - }), - ); +export const { + authorized: resolveDuplicateCandidateCreateWithAuthorization, + execute: resolveDuplicateCandidateCreate, +} = defineCommand((client, payload: ResolveDuplicateCandidateCreatePayload, headers) => + client.partyCommands.resolveDuplicateCandidateCreate({ + headers, + payload, + }), +); -export const resolveDuplicateCandidateMatch = ( - payload: ResolveDuplicateCandidateMatchPayload, - options: PartyCommandOptions, -) => - invoke(options, (authorization) => - resolveDuplicateCandidateMatchWithAuthorization(payload, authorization, options), - ); +export const { + authorized: resolveDuplicateCandidateMatchWithAuthorization, + execute: resolveDuplicateCandidateMatch, +} = defineCommand((client, payload: ResolveDuplicateCandidateMatchPayload, headers) => + client.partyCommands.resolveDuplicateCandidateMatch({ + headers, + payload, + }), +); -export const unarchivePartyWithAuthorization = ( - payload: UnarchivePartyPayload, - ...[credential, options]: PartyCommandInvocation -) => - invokeAuthorized(credential, options, (client) => +export const { authorized: unarchivePartyWithAuthorization, execute: unarchiveParty } = + defineCommand((client, payload: UnarchivePartyPayload, headers) => client.partyCommands.unarchiveParty({ - headers: { 'idempotency-key': options.idempotencyKey }, + headers, payload, }), ); -export const unarchiveParty = (payload: UnarchivePartyPayload, options: PartyCommandOptions) => - invoke(options, (authorization) => - unarchivePartyWithAuthorization(payload, authorization, options), - ); - -export const updateContactPointWithAuthorization = ( - payload: UpdateContactPointPayload, - ...[credential, options]: PartyCommandInvocation -) => - invokeAuthorized(credential, options, (client) => +export const { authorized: updateContactPointWithAuthorization, execute: updateContactPoint } = + defineCommand((client, payload: UpdateContactPointPayload, headers) => client.partyCommands.updateContactPoint({ - headers: { 'idempotency-key': options.idempotencyKey }, - payload, - }), - ); - -export const updateContactPoint = ( - payload: UpdateContactPointPayload, - options: PartyCommandOptions, -) => - invoke(options, (authorization) => - updateContactPointWithAuthorization(payload, authorization, options), - ); - -export const updatePartyOfficialIdentifierWithAuthorization = ( - payload: UpdatePartyOfficialIdentifierPayload, - ...[credential, options]: PartyCommandInvocation -) => - invokeAuthorized(credential, options, (client) => - client.partyCommands.updatePartyOfficialIdentifier({ - headers: { 'idempotency-key': options.idempotencyKey }, + headers, payload, }), ); -export const updatePartyOfficialIdentifier = ( - payload: UpdatePartyOfficialIdentifierPayload, - options: PartyCommandOptions, -) => - invoke(options, (authorization) => - updatePartyOfficialIdentifierWithAuthorization(payload, authorization, options), - ); - -export const updatePartyRelationshipWithAuthorization = ( - payload: UpdatePartyRelationshipPayload, - ...[credential, options]: PartyCommandInvocation -) => - invokeAuthorized(credential, options, (client) => - client.partyCommands.updatePartyRelationship({ - headers: { 'idempotency-key': options.idempotencyKey }, - payload, - }), - ); +export const { + authorized: updatePartyOfficialIdentifierWithAuthorization, + execute: updatePartyOfficialIdentifier, +} = defineCommand((client, payload: UpdatePartyOfficialIdentifierPayload, headers) => + client.partyCommands.updatePartyOfficialIdentifier({ + headers, + payload, + }), +); -export const updatePartyRelationship = ( - payload: UpdatePartyRelationshipPayload, - options: PartyCommandOptions, -) => - invoke(options, (authorization) => - updatePartyRelationshipWithAuthorization(payload, authorization, options), - ); +export const { + authorized: updatePartyRelationshipWithAuthorization, + execute: updatePartyRelationship, +} = defineCommand((client, payload: UpdatePartyRelationshipPayload, headers) => + client.partyCommands.updatePartyRelationship({ + headers, + payload, + }), +); -export const updatePartyWithAuthorization = ( - payload: UpdatePartyPayload, - ...[credential, options]: PartyCommandInvocation -) => - invokeAuthorized(credential, options, (client) => +export const { authorized: updatePartyWithAuthorization, execute: updateParty } = defineCommand( + (client, payload: UpdatePartyPayload, headers) => Schema.encodeUnknownEffect(UpdatePartyPayloadSchema)(payload).pipe( Effect.flatMap((endpointPayload) => client.partyCommands.updateParty({ - headers: { 'idempotency-key': options.idempotencyKey }, + headers, payload: endpointPayload, }), ), ), - ); - -export const updateParty = (payload: UpdatePartyPayload, options: PartyCommandOptions) => - invoke(options, (authorization) => updatePartyWithAuthorization(payload, authorization, options)); +); /** Resolve commit before reading the durable result; never resubmit Create during recovery. */ export const recoverPartyCreate = ( diff --git a/app/verticals/party-registry/src/api/party-correction.read.ts b/app/verticals/party-registry/src/api/party-correction.read.ts index 0c833635b..bd4e79d31 100644 --- a/app/verticals/party-registry/src/api/party-correction.read.ts +++ b/app/verticals/party-registry/src/api/party-correction.read.ts @@ -1,17 +1,13 @@ // @generated by OntOS Codesmith module-api v1 -import { - ReadHandlerNotFound, - ReadHandlerUnavailable, - defineRead, - defineTenantModuleEntrypoint, -} from '@app/core-runtime'; +import { defineRead, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ReadHandlerContext } from '@app/core-runtime'; -import { Effect, Match } from 'effect'; +import { Effect } from 'effect'; import { PartyCorrectionRequestSchema, PartyCorrectionResponseSchema, } from '../../shared/apis/party-correction.ts'; import { findPartyCorrection } from '../services/party-correction.service.ts'; +import { readUnavailable, requireReadValue, readDetailResult } from './read-outcome.ts'; const partyCorrectionEntrypoint = defineTenantModuleEntrypoint({ authorization: { kind: 'context_permission', permission: 'module.access' }, @@ -25,15 +21,7 @@ interface Services { } export const partyCorrectionPermissionTarget = () => ({ kind: 'tenant', permission: 'review_party_identity' }) as const; -const unavailable = (cause: unknown) => - Object.defineProperty( - new ReadHandlerUnavailable({ - code: 'read_handler_unavailable', - reason: 'Party Correction persistence is unavailable', - }), - 'cause', - { value: cause }, - ); +const unavailable = readUnavailable('Party Correction persistence is unavailable'); export const partyCorrectionRead = defineRead( { accessKind: 'detail', @@ -52,25 +40,13 @@ export const partyCorrectionRead = defineRead( schemaVersion: '1', }, (input, context: ReadHandlerContext) => - context.services.find(input.correctionRef.resourceId).pipe( - Effect.mapError(unavailable), - Effect.flatMap((found) => - Match.value(found).pipe( - Match.tag('found', ({ value }) => - Effect.succeed({ evidence: { resultCount: 1 }, result: value }), - ), - Match.tag('not_found', () => - Effect.fail( - new ReadHandlerNotFound({ - code: 'read_handler_not_found', - reason: 'The Party Correction does not exist', - }), - ), - ), - Match.exhaustive, - ), + context.services + .find(input.correctionRef.resourceId) + .pipe( + Effect.mapError(unavailable), + Effect.flatMap(requireReadValue('The Party Correction does not exist')), + Effect.map(readDetailResult), ), - ), (transaction, scope) => Effect.succeed({ find: (correctionId: string) => diff --git a/app/verticals/party-registry/src/api/party-match.read.ts b/app/verticals/party-registry/src/api/party-match.read.ts index 917c665dd..b6d3077e8 100644 --- a/app/verticals/party-registry/src/api/party-match.read.ts +++ b/app/verticals/party-registry/src/api/party-match.read.ts @@ -1,9 +1,5 @@ // @generated by OntOS Codesmith module-api v1 -import { - ReadHandlerUnavailable, - defineRead, - defineTenantModuleEntrypoint, -} from '@app/core-runtime'; +import { defineRead, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ReadHandlerContext } from '@app/core-runtime'; import { Effect } from 'effect'; import { @@ -12,6 +8,7 @@ import { } from '../../shared/apis/party-match.ts'; import type { PartyCandidate } from '../../shared/domain/identity-contracts.ts'; import { previewPartyMatch } from '../services/party-matching-persistence.service.ts'; +import { readUnavailable } from './read-outcome.ts'; const partyMatchEntrypoint = defineTenantModuleEntrypoint({ authorization: { kind: 'context_permission', permission: 'module.access' }, @@ -23,15 +20,7 @@ const partyMatchEntrypoint = defineTenantModuleEntrypoint({ interface Services { readonly preview: (candidate: PartyCandidate) => ReturnType; } -const partyMatchUnavailable = (cause: unknown) => - Object.defineProperty( - new ReadHandlerUnavailable({ - code: 'read_handler_unavailable', - reason: 'Party match preview is unavailable', - }), - 'cause', - { value: cause }, - ); +const partyMatchUnavailable = readUnavailable('Party match preview is unavailable'); /** UX preview only. Use the match-party Action for a durable identity decision and review case. */ export const partyMatchRead = defineRead( { diff --git a/app/verticals/party-registry/src/api/party-official-identifier-detail.read.ts b/app/verticals/party-registry/src/api/party-official-identifier-detail.read.ts index d0afdac70..7cde3a4b9 100644 --- a/app/verticals/party-registry/src/api/party-official-identifier-detail.read.ts +++ b/app/verticals/party-registry/src/api/party-official-identifier-detail.read.ts @@ -1,17 +1,13 @@ // @generated by OntOS Codesmith module-api v1 -import { - ReadHandlerNotFound, - ReadHandlerUnavailable, - defineRead, - defineTenantModuleEntrypoint, -} from '@app/core-runtime'; +import { defineRead, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ReadHandlerContext } from '@app/core-runtime'; -import { Effect, Match } from 'effect'; +import { Effect } from 'effect'; import { PartyOfficialIdentifierDetailRequestSchema, PartyOfficialIdentifierDetailResponseSchema, } from '../../shared/apis/party-official-identifier-detail.ts'; import { findOfficialIdentifierRecord } from '../services/party-official-identifier-persistence.service.ts'; +import { readUnavailable, requireReadValue, readDetailResult } from './read-outcome.ts'; const partyOfficialIdentifierDetailEntrypoint = defineTenantModuleEntrypoint({ authorization: { kind: 'context_permission', permission: 'module.access' }, @@ -23,14 +19,7 @@ const partyOfficialIdentifierDetailEntrypoint = defineTenantModuleEntrypoint({ interface Services { readonly find: (identifierId: string) => ReturnType; } -const unavailable = (cause: unknown) => { - const failure = new ReadHandlerUnavailable({ - code: 'read_handler_unavailable', - reason: 'Official Identifier persistence is unavailable', - }); - Object.defineProperty(failure, 'cause', { configurable: true, value: cause }); - return failure; -}; +const unavailable = readUnavailable('Official Identifier persistence is unavailable', true); export const partyOfficialIdentifierDetailRead = defineRead( { accessKind: 'detail', @@ -49,25 +38,13 @@ export const partyOfficialIdentifierDetailRead = defineRead( schemaVersion: '1', }, (input, context: ReadHandlerContext) => - context.services.find(input.officialIdentifierRef.resourceId).pipe( - Effect.mapError(unavailable), - Effect.flatMap((found) => - Match.value(found).pipe( - Match.tag('found', ({ value }) => - Effect.succeed({ evidence: { resultCount: 1 }, result: value }), - ), - Match.tag('not_found', () => - Effect.fail( - new ReadHandlerNotFound({ - code: 'read_handler_not_found', - reason: 'The Official Identifier does not exist', - }), - ), - ), - Match.exhaustive, - ), + context.services + .find(input.officialIdentifierRef.resourceId) + .pipe( + Effect.mapError(unavailable), + Effect.flatMap(requireReadValue('The Official Identifier does not exist')), + Effect.map(readDetailResult), ), - ), (transaction, scope) => Effect.succeed({ find: (identifierId: string) => diff --git a/app/verticals/party-registry/src/api/party-official-identifier-history.read.ts b/app/verticals/party-registry/src/api/party-official-identifier-history.read.ts index 1baf9c04c..d6d486994 100644 --- a/app/verticals/party-registry/src/api/party-official-identifier-history.read.ts +++ b/app/verticals/party-registry/src/api/party-official-identifier-history.read.ts @@ -1,9 +1,5 @@ // @generated by OntOS Codesmith module-api v1 -import { - ReadHandlerUnavailable, - defineRead, - defineTenantModuleEntrypoint, -} from '@app/core-runtime'; +import { defineRead, defineTenantModuleEntrypoint } from '@app/core-runtime'; import type { ReadHandlerContext } from '@app/core-runtime'; import { Effect } from 'effect'; import { @@ -11,6 +7,7 @@ import { PartyOfficialIdentifierHistoryResponseSchema, } from '../../shared/apis/party-official-identifier-history.ts'; import { listOfficialIdentifierHistory } from '../services/party-official-identifier-persistence.service.ts'; +import { readUnavailable } from './read-outcome.ts'; const partyOfficialIdentifierHistoryEntrypoint = defineTenantModuleEntrypoint({ authorization: { kind: 'context_permission', permission: 'module.access' }, @@ -22,14 +19,7 @@ const partyOfficialIdentifierHistoryEntrypoint = defineTenantModuleEntrypoint({ interface Services { readonly list: (partyId: string) => ReturnType; } -const unavailable = (cause: unknown) => { - const failure = new ReadHandlerUnavailable({ - code: 'read_handler_unavailable', - reason: 'Official Identifier history is unavailable', - }); - Object.defineProperty(failure, 'cause', { configurable: true, value: cause }); - return failure; -}; +const unavailable = readUnavailable('Official Identifier history is unavailable', true); export const partyOfficialIdentifierHistoryRead = defineRead( { accessKind: 'list', diff --git a/app/verticals/party-registry/src/api/party-registry-http-client.ts b/app/verticals/party-registry/src/api/party-registry-http-client.ts index 5376b6e82..f9ae00076 100644 --- a/app/verticals/party-registry/src/api/party-registry-http-client.ts +++ b/app/verticals/party-registry/src/api/party-registry-http-client.ts @@ -31,17 +31,6 @@ export interface PartyRegistryHttpClientOptions { readonly [traceparentOption]?: string; } -export type PartyRegistryAuthorizedInvocation = readonly [ - credential: string, - requestCorrelation: string, - options?: Options, -]; - -export type PartyRegistryOperationInvocation = readonly [ - requestCorrelation: string, - options?: Options, -]; - export interface PartyRegistryHttpRequestContextValue { readonly baseUrl: string | URL; readonly credential?: Redacted.Redacted; diff --git a/app/verticals/party-registry/src/api/person-engagement-profile.read.ts b/app/verticals/party-registry/src/api/person-engagement-profile.read.ts index 1eeeef01d..55b6d22d9 100644 --- a/app/verticals/party-registry/src/api/person-engagement-profile.read.ts +++ b/app/verticals/party-registry/src/api/person-engagement-profile.read.ts @@ -1,23 +1,17 @@ // @generated by OntOS Codesmith module-api v1 import { OperationContextUnavailable, - ReadHandlerNotFound, - ReadHandlerUnavailable, defineRead, defineTenantModuleEntrypoint, } from '@app/core-runtime'; import type { ReadHandlerContext } from '@app/core-runtime'; -import { Effect, Match } from 'effect'; +import { Effect } from 'effect'; import { PersonEngagementProfileRequestSchema, PersonEngagementProfileResponseSchema, } from '../../shared/apis/person-engagement-profile.ts'; -import type { - PersonEngagementProfile, - EngagementProfilePersistenceUnavailable, -} from '../../shared/domain/engagement-profile.ts'; import { findPersonEngagementProfile } from '../services/engagement-profile-persistence.service.ts'; -import type { LookupResult } from '../services/engagement-profile-persistence.service.ts'; +import { readUnavailable, requireReadValue, readDetailResult } from './read-outcome.ts'; const personEngagementProfileEntrypoint = defineTenantModuleEntrypoint({ access: 'read', @@ -28,23 +22,12 @@ const personEngagementProfileEntrypoint = defineTenantModuleEntrypoint({ }); interface Services { - readonly find: ( - profileId: string, - ) => Effect.Effect< - LookupResult, - EngagementProfilePersistenceUnavailable - >; + readonly find: (profileId: string) => ReturnType; } -const personProfileUnavailable = (cause: unknown) => - Object.defineProperty( - new ReadHandlerUnavailable({ - code: 'read_handler_unavailable', - reason: 'Contacts engagement persistence is temporarily unavailable', - }), - 'cause', - { value: cause }, - ); +const personProfileUnavailable = readUnavailable( + 'Contacts engagement persistence is temporarily unavailable', +); export const personEngagementProfileRead = defineRead( { @@ -64,25 +47,13 @@ export const personEngagementProfileRead = defineRead( schemaVersion: '1', }, (input, context: ReadHandlerContext) => - context.services.find(input.profileRef.resourceId).pipe( - Effect.mapError(personProfileUnavailable), - Effect.flatMap((result) => - Match.value(result).pipe( - Match.tag('found', ({ value }) => - Effect.succeed({ evidence: { resultCount: 1 }, result: value }), - ), - Match.tag('not_found', () => - Effect.fail( - new ReadHandlerNotFound({ - code: 'read_handler_not_found', - reason: 'The person engagement profile does not exist', - }), - ), - ), - Match.exhaustive, - ), + context.services + .find(input.profileRef.resourceId) + .pipe( + Effect.mapError(personProfileUnavailable), + Effect.flatMap(requireReadValue('The person engagement profile does not exist')), + Effect.map(readDetailResult), ), - ), (transaction, scope) => { if (scope.legalEntityId === undefined) { return Effect.fail( diff --git a/app/verticals/party-registry/src/api/read-outcome.ts b/app/verticals/party-registry/src/api/read-outcome.ts new file mode 100644 index 000000000..3a2c430f1 --- /dev/null +++ b/app/verticals/party-registry/src/api/read-outcome.ts @@ -0,0 +1,28 @@ +import { ReadHandlerNotFound, ReadHandlerUnavailable } from '@app/core-runtime'; +import { Effect, Match } from 'effect'; +import type { LookupResult } from '../services/engagement-profile-persistence.service.ts'; + +export const readUnavailable = + (reason: string, configurable = false) => + (cause: unknown) => + Object.defineProperty( + new ReadHandlerUnavailable({ code: 'read_handler_unavailable', reason }), + 'cause', + { configurable, value: cause }, + ); + +export const requireReadValue = + (reason: string) => + (found: LookupResult) => + Match.value(found).pipe( + Match.tag('found', ({ value }) => Effect.succeed(value)), + Match.tag('not_found', () => + Effect.fail(new ReadHandlerNotFound({ code: 'read_handler_not_found', reason })), + ), + Match.exhaustive, + ); + +export const readDetailResult = (result: Value) => ({ + evidence: { resultCount: 1 }, + result, +}); diff --git a/app/verticals/party-registry/src/db/catalog.ts b/app/verticals/party-registry/src/db/catalog.ts index ef8f24dfa..49c34cd7b 100644 --- a/app/verticals/party-registry/src/db/catalog.ts +++ b/app/verticals/party-registry/src/db/catalog.ts @@ -1,21 +1,9 @@ +import { compareTableCatalog } from './compare-table-catalog.ts'; import { PARTY_SCHEMA_NAME, PARTY_TABLE_INVENTORY } from './schema.ts'; export const expectedPartyTableCatalog = PARTY_TABLE_INVENTORY.map( (tableName) => `${PARTY_SCHEMA_NAME}.${tableName}`, ); -export interface PartyCatalogDifference { - readonly missing: readonly string[]; - readonly unexpected: readonly string[]; -} - -export const comparePartyCatalog = ( - qualifiedTableNames: readonly string[], -): PartyCatalogDifference => { - const actual = new Set(qualifiedTableNames); - const expected = new Set(expectedPartyTableCatalog); - return { - missing: [...expected].filter((name) => !actual.has(name)).toSorted(), - unexpected: [...actual].filter((name) => !expected.has(name)).toSorted(), - }; -}; +export const comparePartyCatalog = (qualifiedTableNames: readonly string[]) => + compareTableCatalog(expectedPartyTableCatalog, qualifiedTableNames); diff --git a/app/verticals/party-registry/src/db/client.ts b/app/verticals/party-registry/src/db/client.ts index 86f205d86..b0c5784c8 100644 --- a/app/verticals/party-registry/src/db/client.ts +++ b/app/verticals/party-registry/src/db/client.ts @@ -28,12 +28,7 @@ const connectionFailure = (cause: unknown): PartyDatabaseConnectionError => reason: 'Unable to initialize the Party Registry PostgreSQL connection pool', }), 'cause', - { - configurable: false, - enumerable: false, - value: cause, - writable: false, - }, + { value: cause }, ); export const acquirePoolResource = ( diff --git a/app/verticals/party-registry/src/db/compare-table-catalog.ts b/app/verticals/party-registry/src/db/compare-table-catalog.ts new file mode 100644 index 000000000..02f5658ae --- /dev/null +++ b/app/verticals/party-registry/src/db/compare-table-catalog.ts @@ -0,0 +1,11 @@ +export const compareTableCatalog = ( + expectedTableNames: readonly string[], + actualTableNames: readonly string[], +) => { + const expected = new Set(expectedTableNames); + const actual = new Set(actualTableNames); + return { + missing: [...expected.difference(actual)].toSorted(), + unexpected: [...actual.difference(expected)].toSorted(), + }; +}; diff --git a/app/verticals/party-registry/src/db/engagement-catalog.ts b/app/verticals/party-registry/src/db/engagement-catalog.ts index 40b500cf3..5d2173af1 100644 --- a/app/verticals/party-registry/src/db/engagement-catalog.ts +++ b/app/verticals/party-registry/src/db/engagement-catalog.ts @@ -1,22 +1,9 @@ +import { compareTableCatalog } from './compare-table-catalog.ts'; import { CONTACTS_SCHEMA_NAME, CONTACTS_TABLE_INVENTORY } from './engagement-schema.ts'; export const expectedContactsTableCatalog = CONTACTS_TABLE_INVENTORY.map( (tableName) => `${CONTACTS_SCHEMA_NAME}.${tableName}`, ); -export interface ContactsCatalogDifference { - readonly missing: readonly string[]; - readonly unexpected: readonly string[]; -} - -export const compareContactsCatalog = ( - qualifiedTableNames: readonly string[], -): ContactsCatalogDifference => { - const actual = new Set(qualifiedTableNames); - const expected = new Set(expectedContactsTableCatalog); - - return { - missing: [...expected].filter((name) => !actual.has(name)).toSorted(), - unexpected: [...actual].filter((name) => !expected.has(name)).toSorted(), - }; -}; +export const compareContactsCatalog = (qualifiedTableNames: readonly string[]) => + compareTableCatalog(expectedContactsTableCatalog, qualifiedTableNames); diff --git a/app/verticals/party-registry/src/db/schema.ts b/app/verticals/party-registry/src/db/schema.ts index 4a6669f58..89d9113de 100644 --- a/app/verticals/party-registry/src/db/schema.ts +++ b/app/verticals/party-registry/src/db/schema.ts @@ -78,12 +78,16 @@ const verificationColumns = () => ({ policyVersion: text('policy_version').notNull(), }); -const endedPeriodColumns = () => ({ +const activePeriodColumns = () => ({ validFrom: validFrom(), validTo: validTo(), recordedAt: recordedAt(), state: text('state').default('ACTIVE').notNull(), isCurrent: boolean('is_current').default(true).notNull(), +}); + +const endedPeriodColumns = () => ({ + ...activePeriodColumns(), endReason: text('end_reason'), endProvenanceSource: text('end_provenance_source'), endProvenanceMethod: text('end_provenance_method'), @@ -93,6 +97,39 @@ const endedPeriodColumns = () => ({ endedRecordedAt: timestamp('ended_recorded_at', { withTimezone: true }), }); +const activePeriodConstraints = ( + prefix: string, + table: Readonly>, +) => [ + check( + `${prefix}_interval_ck`, + sql`${table.validTo} is null or ${table.validTo} >= ${table.validFrom}`, + ), + check( + `${prefix}_state_ck`, + sql`${table.state} in ('ACTIVE', 'ENDED', 'SUPERSEDED', 'RETRACTED', 'DISPUTED') and ((${table.state} = 'ACTIVE' and ${table.isCurrent}) or (${table.state} <> 'ACTIVE' and not ${table.isCurrent}))`, + ), +]; + +const contactEvidenceConstraints = ( + prefix: string, + table: Readonly< + Record< + keyof ReturnType | keyof ReturnType, + AnyPgColumn + > + >, +) => [ + check( + `${prefix}_end_evidence_ck`, + sql`(${table.validTo} is null and ${table.endReason} is null and ${table.endProvenanceSource} is null and ${table.endProvenanceMethod} is null and ${table.endEvidenceRefs} is null and ${table.endedByActionInvocationId} is null and ${table.endedByPrincipalId} is null and ${table.endedRecordedAt} is null) or (${table.validTo} is not null and ${table.endReason} = btrim(${table.endReason}) and length(${table.endReason}) > 0 and ${table.endProvenanceSource} = btrim(${table.endProvenanceSource}) and length(${table.endProvenanceSource}) > 0 and ${table.endProvenanceMethod} = btrim(${table.endProvenanceMethod}) and length(${table.endProvenanceMethod}) > 0 and jsonb_typeof(${table.endEvidenceRefs}) = 'array' and jsonb_array_length(${table.endEvidenceRefs}) <= 32 and ${table.endedByActionInvocationId} is not null and ${table.endedByPrincipalId} is not null and ${table.endedRecordedAt} is not null and ${table.endedRecordedAt} >= ${table.recordedAt})`, + ), + check( + `${prefix}_verification_ck`, + sql`${table.verificationState} in ('UNVERIFIED', 'VERIFIED', 'REJECTED') and (${table.verificationState} <> 'VERIFIED' or (${table.verifiedAt} is not null and length(btrim(${table.verificationMethod})) > 0 and length(btrim(${table.verifierReference})) > 0))`, + ), +]; + const enableGovernedRls =
    (table: { readonly enableRLS: () => Table }): Table => table.enableRLS(); @@ -204,11 +241,7 @@ export const partyFactAssertions = enableGovernedRls( factKind: text('fact_kind').notNull(), evidenceEvaluation: jsonb('evidence_evaluation').$type(), normalizedValue: text('normalized_value').notNull(), - validFrom: validFrom(), - validTo: validTo(), - recordedAt: recordedAt(), - state: text('state').default('ACTIVE').notNull(), - isCurrent: boolean('is_current').default(true).notNull(), + ...activePeriodColumns(), ...provenanceColumns(), ...verificationColumns(), supersedesAssertionId: uuid('supersedes_assertion_id'), @@ -242,14 +275,7 @@ export const partyFactAssertions = enableGovernedRls( 'party_fact_assertions_value_ck', sql`${table.normalizedValue} = btrim(${table.normalizedValue}) and length(${table.normalizedValue}) > 0`, ), - check( - 'party_fact_assertions_interval_ck', - sql`${table.validTo} is null or ${table.validTo} >= ${table.validFrom}`, - ), - check( - 'party_fact_assertions_state_ck', - sql`${table.state} in ('ACTIVE', 'ENDED', 'SUPERSEDED', 'RETRACTED', 'DISPUTED') and ((${table.state} = 'ACTIVE' and ${table.isCurrent}) or (${table.state} <> 'ACTIVE' and not ${table.isCurrent}))`, - ), + ...activePeriodConstraints('party_fact_assertions', table), check( 'party_fact_assertions_verification_ck', sql`${table.verificationState} in ('UNVERIFIED', 'VERIFIED', 'REJECTED') and (${table.verificationState} <> 'VERIFIED' or ${table.verifiedAt} is not null)`, @@ -274,11 +300,7 @@ export const partyOfficialIdentifiers = enableGovernedRls( namespace: text('namespace').notNull(), jurisdiction: text('jurisdiction').default('CZ').notNull(), normalizedValue: text('normalized_value').notNull(), - validFrom: validFrom(), - validTo: validTo(), - recordedAt: recordedAt(), - state: text('state').default('ACTIVE').notNull(), - isCurrent: boolean('is_current').default(true).notNull(), + ...activePeriodColumns(), provenanceSource: text('provenance_source').notNull(), provenanceMethod: text('provenance_method').notNull(), externalEvidence: jsonb('external_evidence').$type(), @@ -324,14 +346,7 @@ export const partyOfficialIdentifiers = enableGovernedRls( 'party_official_identifiers_normalized_value_ck', sql`(${table.identifierTypeKey} = 'ICO' and ${table.normalizedValue} ~ '^[0-9]{8}$') or (${table.identifierTypeKey} = 'CZ_DIC' and ${table.normalizedValue} ~ '^CZ[0-9]{8,10}$')`, ), - check( - 'party_official_identifiers_interval_ck', - sql`${table.validTo} is null or ${table.validTo} >= ${table.validFrom}`, - ), - check( - 'party_official_identifiers_state_ck', - sql`${table.state} in ('ACTIVE', 'ENDED', 'SUPERSEDED', 'RETRACTED', 'DISPUTED') and ((${table.state} = 'ACTIVE' and ${table.isCurrent}) or (${table.state} <> 'ACTIVE' and not ${table.isCurrent}))`, - ), + ...activePeriodConstraints('party_official_identifiers', table), check( 'party_official_identifiers_verification_ck', sql`${table.verificationState} in ('UNVERIFIED', 'VERIFIED', 'REJECTED') and (${table.verificationState} <> 'VERIFIED' or ${table.verifiedAt} is not null)`, @@ -467,22 +482,8 @@ export const partyContactPoints = enableGovernedRls( 'party_contact_points_additional_evidence_ck', sql`jsonb_typeof(${table.additionalEvidenceRefs}) = 'array' and jsonb_array_length(${table.additionalEvidenceRefs}) <= 32`, ), - check( - 'party_contact_points_interval_ck', - sql`${table.validTo} is null or ${table.validTo} >= ${table.validFrom}`, - ), - check( - 'party_contact_points_state_ck', - sql`${table.state} in ('ACTIVE', 'ENDED', 'SUPERSEDED', 'RETRACTED', 'DISPUTED') and ((${table.state} = 'ACTIVE' and ${table.isCurrent}) or (${table.state} <> 'ACTIVE' and not ${table.isCurrent}))`, - ), - check( - 'party_contact_points_end_evidence_ck', - sql`(${table.validTo} is null and ${table.endReason} is null and ${table.endProvenanceSource} is null and ${table.endProvenanceMethod} is null and ${table.endEvidenceRefs} is null and ${table.endedByActionInvocationId} is null and ${table.endedByPrincipalId} is null and ${table.endedRecordedAt} is null) or (${table.validTo} is not null and ${table.endReason} = btrim(${table.endReason}) and length(${table.endReason}) > 0 and ${table.endProvenanceSource} = btrim(${table.endProvenanceSource}) and length(${table.endProvenanceSource}) > 0 and ${table.endProvenanceMethod} = btrim(${table.endProvenanceMethod}) and length(${table.endProvenanceMethod}) > 0 and jsonb_typeof(${table.endEvidenceRefs}) = 'array' and jsonb_array_length(${table.endEvidenceRefs}) <= 32 and ${table.endedByActionInvocationId} is not null and ${table.endedByPrincipalId} is not null and ${table.endedRecordedAt} is not null and ${table.endedRecordedAt} >= ${table.recordedAt})`, - ), - check( - 'party_contact_points_verification_ck', - sql`${table.verificationState} in ('UNVERIFIED', 'VERIFIED', 'REJECTED') and (${table.verificationState} <> 'VERIFIED' or (${table.verifiedAt} is not null and length(btrim(${table.verificationMethod})) > 0 and length(btrim(${table.verifierReference})) > 0))`, - ), + ...activePeriodConstraints('party_contact_points', table), + ...contactEvidenceConstraints('party_contact_points', table), check('party_contact_points_revision_ck', sql`${table.revision} > 0`), externalEvidenceConstraint( 'party_contact_points_external_evidence_ck', @@ -549,22 +550,8 @@ export const partyContactPointPurposes = enableGovernedRls( 'party_contact_point_purposes_registry_ck', sql`(${table.purposeKey} <> 'REGISTERED') or (${table.registryContext} <> 'GENERAL' and ${table.jurisdiction} ~ '^[A-Z]{2}$' and ${table.jurisdiction} <> 'ZZ')`, ), - check( - 'party_contact_point_purposes_interval_ck', - sql`${table.validTo} is null or ${table.validTo} >= ${table.validFrom}`, - ), - check( - 'party_contact_point_purposes_state_ck', - sql`${table.state} in ('ACTIVE', 'ENDED', 'SUPERSEDED', 'RETRACTED', 'DISPUTED') and ((${table.state} = 'ACTIVE' and ${table.isCurrent}) or (${table.state} <> 'ACTIVE' and not ${table.isCurrent}))`, - ), - check( - 'party_contact_point_purposes_end_evidence_ck', - sql`(${table.validTo} is null and ${table.endReason} is null and ${table.endProvenanceSource} is null and ${table.endProvenanceMethod} is null and ${table.endEvidenceRefs} is null and ${table.endedByActionInvocationId} is null and ${table.endedByPrincipalId} is null and ${table.endedRecordedAt} is null) or (${table.validTo} is not null and ${table.endReason} = btrim(${table.endReason}) and length(${table.endReason}) > 0 and ${table.endProvenanceSource} = btrim(${table.endProvenanceSource}) and length(${table.endProvenanceSource}) > 0 and ${table.endProvenanceMethod} = btrim(${table.endProvenanceMethod}) and length(${table.endProvenanceMethod}) > 0 and jsonb_typeof(${table.endEvidenceRefs}) = 'array' and jsonb_array_length(${table.endEvidenceRefs}) <= 32 and ${table.endedByActionInvocationId} is not null and ${table.endedByPrincipalId} is not null and ${table.endedRecordedAt} is not null and ${table.endedRecordedAt} >= ${table.recordedAt})`, - ), - check( - 'party_contact_point_purposes_verification_ck', - sql`${table.verificationState} in ('UNVERIFIED', 'VERIFIED', 'REJECTED') and (${table.verificationState} <> 'VERIFIED' or (${table.verifiedAt} is not null and length(btrim(${table.verificationMethod})) > 0 and length(btrim(${table.verifierReference})) > 0))`, - ), + ...activePeriodConstraints('party_contact_point_purposes', table), + ...contactEvidenceConstraints('party_contact_point_purposes', table), check('party_contact_point_purposes_revision_ck', sql`${table.revision} > 0`), externalEvidenceConstraint( 'party_contact_point_purposes_external_evidence_ck', diff --git a/app/verticals/party-registry/src/routes/ultramodern-jsonld.ts b/app/verticals/party-registry/src/routes/ultramodern-jsonld.ts deleted file mode 100644 index 474f5af0b..000000000 --- a/app/verticals/party-registry/src/routes/ultramodern-jsonld.ts +++ /dev/null @@ -1,113 +0,0 @@ -export type JsonLdPrimitive = string | number | boolean | null; -export type JsonLdValue = - | JsonLdPrimitive - | readonly JsonLdValue[] - | { readonly [key: string]: JsonLdValue }; -export type JsonLdObject = Readonly>; -export type RouteJsonLd = JsonLdObject | readonly JsonLdObject[]; - -const schemaContext = 'https://schema.org' as const; - -type SchemaObject = JsonLdObject & { - readonly '@context': typeof schemaContext; - readonly '@type': TType; -}; - -type ThingReference = - | string - | { - readonly '@id'?: string; - readonly '@type'?: string; - readonly name?: string; - readonly url?: string; - }; - -const withSchemaContext = ( - type: TType, - input: TInput, -): SchemaObject & TInput => ({ - '@context': schemaContext, - '@type': type, - ...input, -}); - -export const defineRouteJsonLd = (jsonLd: TJsonLd): TJsonLd => jsonLd; - -export interface WebPageJsonLdInput { - readonly name: string; - readonly url: string; - readonly description?: string; - readonly inLanguage?: string | readonly string[]; - readonly isPartOf?: ThingReference; -} - -export const webPageJsonLd = (input: WebPageJsonLdInput) => withSchemaContext('WebPage', input); - -export interface WebApplicationJsonLdInput { - readonly name: string; - readonly url: string; - readonly applicationCategory?: string; - readonly browserRequirements?: string; - readonly description?: string; - readonly operatingSystem?: string; -} - -export const webApplicationJsonLd = (input: WebApplicationJsonLdInput) => - withSchemaContext('WebApplication', input); - -export interface SoftwareApplicationJsonLdInput { - readonly name: string; - readonly url: string; - readonly applicationCategory?: string; - readonly applicationSubCategory?: string; - readonly description?: string; - readonly offers?: ThingReference; - readonly operatingSystem?: string; -} - -export const softwareApplicationJsonLd = (input: SoftwareApplicationJsonLdInput) => - withSchemaContext('SoftwareApplication', input); - -export interface OrganizationJsonLdInput { - readonly name: string; - readonly url?: string; - readonly logo?: string; - readonly sameAs?: readonly string[]; -} - -export const organizationJsonLd = (input: OrganizationJsonLdInput) => - withSchemaContext('Organization', input); - -export interface BreadcrumbListItemInput { - readonly name: string; - readonly item: string; -} - -export const breadcrumbListJsonLd = (items: readonly BreadcrumbListItemInput[]) => - withSchemaContext('BreadcrumbList', { - itemListElement: items.map((entry, index) => ({ - '@type': 'ListItem', - item: entry.item, - name: entry.name, - position: index + 1, - })), - }); - -export interface FAQPageQuestionInput { - readonly name: string; - readonly acceptedAnswer: { - readonly text: string; - }; -} - -export const faqPageJsonLd = (questions: readonly FAQPageQuestionInput[]) => - withSchemaContext('FAQPage', { - mainEntity: questions.map((question) => ({ - '@type': 'Question', - acceptedAnswer: { - '@type': 'Answer', - text: question.acceptedAnswer.text, - }, - name: question.name, - })), - }); diff --git a/app/verticals/party-registry/src/search/counterparties.provider.ts b/app/verticals/party-registry/src/search/counterparties.provider.ts index 2c7a0cf8c..01be7e137 100644 --- a/app/verticals/party-registry/src/search/counterparties.provider.ts +++ b/app/verticals/party-registry/src/search/counterparties.provider.ts @@ -1,6 +1,6 @@ // @generated by OntOS Codesmith Governed Contribution v1 // @ontos-contribution-kind search-provider -import { DateTime, Effect, Match } from 'effect'; +import { DateTime, Effect } from 'effect'; import { OperationContextUnavailable, ReadHandlerUnavailable, @@ -18,8 +18,9 @@ import type { } from '../../shared/apis/counterparties-search.ts'; import { PartySearchProjectionGateway } from '../../shared/domain/search-projection-gateway.ts'; import type { PartySearchProjectionGatewayService as PartySearchProjectionGatewayPort } from '../../shared/domain/search-projection-gateway.ts'; -import { PartySearchProjectionUnavailable } from '../../shared/domain/search-projection-error.ts'; +import type { PartySearchProjectionUnavailable } from '../../shared/domain/search-projection-error.ts'; import { normalizeCounterpartySearchHits } from '../../shared/domain/search-semantics.ts'; +import { resolveSearchNormalization } from './search-normalization.ts'; const counterpartiesEntrypoint = defineTenantModuleEntrypoint({ authorization: { kind: 'context_permission', permission: 'module.access' }, @@ -49,61 +50,23 @@ export const loadCounterpartySearch = ( scope: Readonly<{ readonly legalEntityId: string; readonly tenantId: string }>, input: CounterpartiesProviderRequest, effectiveAt: string, -) => - gateway - .searchCounterparties( - input.role === undefined - ? { - effectiveAt, - includeArchived: input.includeArchived ?? false, - legalEntityId: scope.legalEntityId, - query: input.query, - tenantId: scope.tenantId, - } - : { - effectiveAt, - includeArchived: input.includeArchived ?? false, - legalEntityId: scope.legalEntityId, - query: input.query, - role: input.role, - tenantId: scope.tenantId, - }, - ) +) => { + const baseQuery = { + effectiveAt, + includeArchived: input.includeArchived ?? false, + legalEntityId: scope.legalEntityId, + query: input.query, + tenantId: scope.tenantId, + }; + const query = input.role === undefined ? baseQuery : { ...baseQuery, role: input.role }; + return gateway + .searchCounterparties(query) .pipe( - Effect.flatMap((hits) => { - const normalized = normalizeCounterpartySearchHits( - input.role === undefined - ? { - effectiveAt, - includeArchived: input.includeArchived ?? false, - legalEntityId: scope.legalEntityId, - tenantId: scope.tenantId, - } - : { - effectiveAt, - includeArchived: input.includeArchived ?? false, - legalEntityId: scope.legalEntityId, - role: input.role, - tenantId: scope.tenantId, - }, - hits, - ); - return Match.value(normalized).pipe( - Match.tag('SearchResults', ({ items }) => - Effect.succeed(items satisfies CounterpartiesProviderResponse), - ), - Match.tag('SearchProjectionViolation', ({ reason }) => - Effect.fail( - new PartySearchProjectionUnavailable({ - code: 'party_search_projection_unavailable', - reason, - }), - ), - ), - Match.exhaustive, - ); - }), + Effect.flatMap((hits) => + resolveSearchNormalization(normalizeCounterpartySearchHits(query, hits)), + ), ); +}; export const counterpartiesRead = defineRead( { diff --git a/app/verticals/party-registry/src/search/parties.provider.ts b/app/verticals/party-registry/src/search/parties.provider.ts index bf47985a7..3d03ed8ed 100644 --- a/app/verticals/party-registry/src/search/parties.provider.ts +++ b/app/verticals/party-registry/src/search/parties.provider.ts @@ -1,6 +1,6 @@ // @generated by OntOS Codesmith Governed Contribution v1 // @ontos-contribution-kind search-provider -import { Effect, Layer, Match, Schema } from 'effect'; +import { Effect, Layer, Schema } from 'effect'; import { CoreSearchQueryRuntime, ReadHandlerUnavailable, @@ -33,6 +33,7 @@ import { normalizePartySearchHits } from '../../shared/domain/search-semantics.t import { CurrentCounterpartyRoleSchema } from '../../shared/domain/search-result.ts'; import { CounterpartyRefSchema } from '../../shared/resources/counterparty.ts'; import { PartyRefSchema } from '../../shared/resources/party.ts'; +import { resolveSearchNormalization } from './search-normalization.ts'; const partiesEntrypoint = defineTenantModuleEntrypoint({ authorization: { kind: 'context_permission', permission: 'module.access' }, @@ -209,20 +210,7 @@ export const loadPartySearch = ( { includeArchived: input.includeArchived ?? false, tenantId: scope.tenantId }, hits, ); - return Match.value(normalized).pipe( - Match.tag('SearchResults', ({ items }) => - Effect.succeed(items satisfies PartiesProviderResponse), - ), - Match.tag('SearchProjectionViolation', ({ reason }) => - Effect.fail( - new PartySearchProjectionUnavailable({ - code: 'party_search_projection_unavailable', - reason, - }), - ), - ), - Match.exhaustive, - ); + return resolveSearchNormalization(normalized); }), ); diff --git a/app/verticals/party-registry/src/search/search-normalization.ts b/app/verticals/party-registry/src/search/search-normalization.ts new file mode 100644 index 000000000..6eda612f4 --- /dev/null +++ b/app/verticals/party-registry/src/search/search-normalization.ts @@ -0,0 +1,17 @@ +import { Effect, Match } from 'effect'; +import { PartySearchProjectionUnavailable } from '../../shared/domain/search-projection-error.ts'; +import type { SearchNormalizationResult } from '../../shared/domain/search-semantics.ts'; + +export const resolveSearchNormalization = (normalized: SearchNormalizationResult) => + Match.value(normalized).pipe( + Match.tag('SearchResults', ({ items }) => Effect.succeed(items)), + Match.tag('SearchProjectionViolation', ({ reason }) => + Effect.fail( + new PartySearchProjectionUnavailable({ + code: 'party_search_projection_unavailable', + reason, + }), + ), + ), + Match.exhaustive, + ); diff --git a/app/verticals/party-registry/src/services/engagement-profile-persistence.service.ts b/app/verticals/party-registry/src/services/engagement-profile-persistence.service.ts index 0f5df8d90..486141d43 100644 --- a/app/verticals/party-registry/src/services/engagement-profile-persistence.service.ts +++ b/app/verticals/party-registry/src/services/engagement-profile-persistence.service.ts @@ -133,193 +133,100 @@ export const ensureReferencesBelongToTenant = ( }), ); -export const createOrganizationEngagementProfile = ( - transaction: ScopedTransaction, - input: { - readonly counterpartyRef?: CounterpartyRef; - readonly partyRef: PartyRef; - readonly tenantId: string; - }, -) => - ensureReferencesBelongToTenant(input.tenantId, input).pipe( - Effect.andThen( - transaction - .insert(organizationEngagementProfiles) - .values({ - counterpartyResourceId: input.counterpartyRef?.resourceId ?? null, - partyResourceId: input.partyRef.resourceId, - tenantId: input.tenantId, - }) - .returning() - .pipe(Effect.mapError(mutationFailure)), - ), - Effect.flatMap(([row]) => - row === undefined - ? Effect.fail(unavailable()) - : Effect.succeed(organizationEngagementProfileFromRecord(row)), - ), - ); - -export const createPersonEngagementProfile = ( - transaction: ScopedTransaction, - input: { - readonly counterpartyRef?: CounterpartyRef; - readonly partyRef: PartyRef; - readonly tenantId: string; - }, -) => - ensureReferencesBelongToTenant(input.tenantId, input).pipe( - Effect.andThen( - transaction - .insert(personEngagementProfiles) - .values({ - counterpartyResourceId: input.counterpartyRef?.resourceId ?? null, - partyResourceId: input.partyRef.resourceId, - tenantId: input.tenantId, - }) - .returning() - .pipe(Effect.mapError(mutationFailure)), - ), - Effect.flatMap(([row]) => - row === undefined ? Effect.fail(unavailable()) : Effect.succeed(personDto(row)), +const engagementProfilePersistence = ( + table: typeof organizationEngagementProfiles | typeof personEngagementProfiles, + toDto: (row: OrganizationEngagementProfileRecord) => Value, +) => { + const profilePredicate = (tenantId: string, profileId: string) => + and(eq(table.tenantId, tenantId), eq(table.engagementProfileId, profileId)); + + return { + create: ( + transaction: ScopedTransaction, + input: { + readonly counterpartyRef?: CounterpartyRef; + readonly partyRef: PartyRef; + readonly tenantId: string; + }, + ) => + ensureReferencesBelongToTenant(input.tenantId, input).pipe( + Effect.andThen( + transaction + .insert(table) + .values({ + counterpartyResourceId: input.counterpartyRef?.resourceId ?? null, + partyResourceId: input.partyRef.resourceId, + tenantId: input.tenantId, + }) + .returning() + .pipe(Effect.mapError(mutationFailure)), + ), + Effect.flatMap(([row]) => + row === undefined ? Effect.fail(unavailable()) : Effect.succeed(toDto(row)), + ), + ), + transition: Effect.fn('EngagementProfilePersistenceService.transition')( + function* transitionProfile( + transaction: ScopedTransaction, + tenantId: string, + profileId: string, + state: 'active' | 'archived', + ): Effect.fn.Return, EngagementProfilePersistenceUnavailable> { + const predicate = profilePredicate(tenantId, profileId); + const [current] = yield* transaction + .select() + .from(table) + .where(predicate) + .limit(1) + .for('update') + .pipe(Effect.mapError(unavailable)); + if (current === undefined) { + return { _tag: 'not_found' } as const; + } + if ((state === 'archived') === (current.archivedAt !== null)) { + return { _tag: 'conflict', value: toDto(current) } as const; + } + const now = yield* DateTime.nowAsDate; + const [updated] = yield* transaction + .update(table) + .set({ archivedAt: state === 'archived' ? now : null, updatedAt: now }) + .where(predicate) + .returning() + .pipe(Effect.mapError(unavailable)); + if (updated === undefined) { + return yield* unavailable(); + } + return { _tag: 'found', value: toDto(updated) } as const; + }, ), - ); - -const transition = Effect.fn('EngagementProfilePersistenceService.transition')( - function* transitionProfile( - loadCurrent: () => Effect.Effect, - updateCurrent: (now: Date) => Effect.Effect, - requestedState: 'active' | 'archived', - toDto: (row: Row) => Value, - ): Effect.fn.Return, EngagementProfilePersistenceUnavailable> { - const [current] = yield* loadCurrent().pipe(Effect.mapError(unavailable)); - if (current === undefined) { - return { _tag: 'not_found' } as const; - } - if ((requestedState === 'archived') === (current.archivedAt !== null)) { - return { _tag: 'conflict', value: toDto(current) } as const; - } - const now = yield* DateTime.nowAsDate; - const [updated] = yield* updateCurrent(now).pipe(Effect.mapError(unavailable)); - if (updated === undefined) { - return yield* unavailable(); - } - return { _tag: 'found', value: toDto(updated) } as const; - }, -); - -export const transitionOrganizationEngagementProfile = ( - transaction: ScopedTransaction, - tenantId: string, - profileId: string, - state: 'active' | 'archived', -) => - transition( - () => + find: (transaction: ScopedTransaction, tenantId: string, profileId: string) => transaction .select() - .from(organizationEngagementProfiles) - .where( - and( - eq(organizationEngagementProfiles.tenantId, tenantId), - eq(organizationEngagementProfiles.engagementProfileId, profileId), - ), - ) + .from(table) + .where(profilePredicate(tenantId, profileId)) .limit(1) - .for('update'), - (now) => - transaction - .update(organizationEngagementProfiles) - .set({ archivedAt: state === 'archived' ? now : null, updatedAt: now }) - .where( - and( - eq(organizationEngagementProfiles.tenantId, tenantId), - eq(organizationEngagementProfiles.engagementProfileId, profileId), + .pipe( + Effect.mapError(unavailable), + Effect.map(([row]) => + row === undefined + ? ({ _tag: 'not_found' } as const) + : ({ _tag: 'found', value: toDto(row) } as const), ), - ) - .returning(), - state, - organizationEngagementProfileFromRecord, - ); - -export const transitionPersonEngagementProfile = ( - transaction: ScopedTransaction, - tenantId: string, - profileId: string, - state: 'active' | 'archived', -) => - transition( - () => - transaction - .select() - .from(personEngagementProfiles) - .where( - and( - eq(personEngagementProfiles.tenantId, tenantId), - eq(personEngagementProfiles.engagementProfileId, profileId), - ), - ) - .limit(1) - .for('update'), - (now) => - transaction - .update(personEngagementProfiles) - .set({ archivedAt: state === 'archived' ? now : null, updatedAt: now }) - .where( - and( - eq(personEngagementProfiles.tenantId, tenantId), - eq(personEngagementProfiles.engagementProfileId, profileId), - ), - ) - .returning(), - state, - personDto, - ); + ), + }; +}; -export const findOrganizationEngagementProfile = ( - transaction: ScopedTransaction, - tenantId: string, - profileId: string, -) => - transaction - .select() - .from(organizationEngagementProfiles) - .where( - and( - eq(organizationEngagementProfiles.tenantId, tenantId), - eq(organizationEngagementProfiles.engagementProfileId, profileId), - ), - ) - .limit(1) - .pipe( - Effect.mapError(unavailable), - Effect.map(([row]) => - row === undefined - ? ({ _tag: 'not_found' } as const) - : ({ _tag: 'found', value: organizationEngagementProfileFromRecord(row) } as const), - ), - ); +export const { + create: createOrganizationEngagementProfile, + transition: transitionOrganizationEngagementProfile, + find: findOrganizationEngagementProfile, +} = engagementProfilePersistence( + organizationEngagementProfiles, + organizationEngagementProfileFromRecord, +); -export const findPersonEngagementProfile = ( - transaction: ScopedTransaction, - tenantId: string, - profileId: string, -) => - transaction - .select() - .from(personEngagementProfiles) - .where( - and( - eq(personEngagementProfiles.tenantId, tenantId), - eq(personEngagementProfiles.engagementProfileId, profileId), - ), - ) - .limit(1) - .pipe( - Effect.mapError(unavailable), - Effect.map(([row]) => - row === undefined - ? ({ _tag: 'not_found' } as const) - : ({ _tag: 'found', value: personDto(row) } as const), - ), - ); +export const { + create: createPersonEngagementProfile, + transition: transitionPersonEngagementProfile, + find: findPersonEngagementProfile, +} = engagementProfilePersistence(personEngagementProfiles, personDto); diff --git a/app/verticals/party-registry/src/services/party-correction.service.ts b/app/verticals/party-registry/src/services/party-correction.service.ts index fe85d8fb5..862047556 100644 --- a/app/verticals/party-registry/src/services/party-correction.service.ts +++ b/app/verticals/party-registry/src/services/party-correction.service.ts @@ -244,9 +244,7 @@ const correctRelationship = Effect.fn('PartyCorrectionService.correctRelationshi command: RelationshipCorrectionCommand, acceptance: { readonly actionInvocationId: string; readonly principalId: string }, ) { - let relationshipId: null | string = null; let replacementRelationshipId: null | string = null; - let replacementAssertionId: null | string = null; if (command.relationshipRef.tenantId !== tenantId) { return yield* new PartyCorrectionConflict({ code: 'party_correction_conflict', @@ -275,7 +273,7 @@ const correctRelationship = Effect.fn('PartyCorrectionService.correctRelationshi yield* resolvePartyAlias(transaction, tenantId, target.toPartyId).pipe( Effect.mapError(unavailable), ); - ({ relationshipId } = target); + const { relationshipId } = target; const [transitioned] = yield* transaction .update(partyRelationships) .set({ @@ -329,10 +327,14 @@ const correctRelationship = Effect.fn('PartyCorrectionService.correctRelationshi return yield* unavailable(); } replacementRelationshipId = replacement.relationshipId; - replacementAssertionId = replacement.relationshipId; } - return { correctedPartyId, relationshipId, replacementRelationshipId, replacementAssertionId }; + return { + correctedPartyId, + relationshipId, + replacementRelationshipId, + replacementAssertionId: replacementRelationshipId, + }; }, ); @@ -344,9 +346,7 @@ const correctOfficialIdentifier = Effect.fn('PartyCorrectionService.correctOffic acceptance: { readonly actionInvocationId: string; readonly principalId: string }, now: Date, ) { - let officialIdentifierId: null | string = null; let replacementOfficialIdentifierId: null | string = null; - let replacementAssertionId: null | string = null; const correctedPartyId = command.partyId; yield* requireCanonicalCorrectionTarget(transaction, tenantId, command.partyId); const [targetRow] = yield* transaction @@ -366,7 +366,7 @@ const correctOfficialIdentifier = Effect.fn('PartyCorrectionService.correctOffic targetRow, 'The target Official Identifier assertion is absent or not active', ); - ({ officialIdentifierId } = target); + const { officialIdentifierId } = target; // The shared tenant-qualified claim lock serializes releases against create/add/unarchive. // SAFETY: The persisted identifier columns are constrained to the closed identifier vocabulary. yield* lockAndResolveClaims(transaction, tenantId, [ @@ -444,14 +444,13 @@ const correctOfficialIdentifier = Effect.fn('PartyCorrectionService.correctOffic return yield* unavailable(); } replacementOfficialIdentifierId = replacement.officialIdentifierId; - replacementAssertionId = replacement.officialIdentifierId; } return { correctedPartyId, officialIdentifierId, replacementOfficialIdentifierId, - replacementAssertionId, + replacementAssertionId: replacementOfficialIdentifierId, }; }, ); @@ -464,10 +463,7 @@ const correctIdentityAssertion = Effect.fn('PartyCorrectionService.correctIdenti acceptance: { readonly actionInvocationId: string; readonly principalId: string }, now: Date, ) { - let partyFactAssertionId: null | string = null; let replacementAssertionId: null | string = null; - let replacementEvidenceEvaluation: null | ReturnType = - null; const correctedPartyId = command.partyId; yield* requireCanonicalCorrectionTarget(transaction, tenantId, command.partyId); const [targetRow] = yield* transaction @@ -488,13 +484,13 @@ const correctIdentityAssertion = Effect.fn('PartyCorrectionService.correctIdenti targetRow, 'The target Party assertion is absent or not active', ); - replacementEvidenceEvaluation = yield* validatePartyTypeCorrection( + const replacementEvidenceEvaluation = yield* validatePartyTypeCorrection( transaction, tenantId, command, target.normalizedValue, ); - partyFactAssertionId = target.assertionId; + const partyFactAssertionId = target.assertionId; yield* transaction .update(partyFactAssertions) .set({ diff --git a/app/verticals/party-registry/src/services/party-identity-persistence.service.ts b/app/verticals/party-registry/src/services/party-identity-persistence.service.ts index 2ed7d6269..e21d0e275 100644 --- a/app/verticals/party-registry/src/services/party-identity-persistence.service.ts +++ b/app/verticals/party-registry/src/services/party-identity-persistence.service.ts @@ -76,6 +76,24 @@ export type PartyLifecycle = typeof PartyLifecycleSchema.Type; type PartyUnarchiveLifecycle = typeof PartyUnarchiveLifecycleSchema.Type; const MATCH_RULE_VERSION = 'party-exact-claims.v1'; +export const findOpenDuplicateCandidateCase = ( + transaction: Pick, + tenantId: string, + evaluationFingerprint: string, +) => + transaction + .select() + .from(duplicateCandidateCases) + .where( + and( + eq(duplicateCandidateCases.tenantId, tenantId), + eq(duplicateCandidateCases.evaluationFingerprint, evaluationFingerprint), + eq(duplicateCandidateCases.matchRuleVersion, MATCH_RULE_VERSION), + inArray(duplicateCandidateCases.lifecycleState, ['OPEN', 'NEEDS_EVIDENCE']), + ), + ) + .limit(1); + const instantAsDate = (instant: string | DateTime.Utc): Date => DateTime.toDateUtc(DateTime.makeUnsafe(instant)); const ClaimKeyJsonCodec = Schema.fromJsonString( @@ -832,19 +850,11 @@ const createUnarchiveReviewCase = Effect.fn( evaluatedEvidence, partyIds, ); - const [open] = yield* transaction - .select() - .from(duplicateCandidateCases) - .where( - and( - eq(duplicateCandidateCases.tenantId, tenantId), - eq(duplicateCandidateCases.evaluationFingerprint, evaluationFingerprint), - eq(duplicateCandidateCases.matchRuleVersion, MATCH_RULE_VERSION), - inArray(duplicateCandidateCases.lifecycleState, ['OPEN', 'NEEDS_EVIDENCE']), - ), - ) - .limit(1) - .pipe(Effect.mapError(unavailable)); + const [open] = yield* findOpenDuplicateCandidateCase( + transaction, + tenantId, + evaluationFingerprint, + ).pipe(Effect.mapError(unavailable)); if (open !== undefined) { return open; } diff --git a/app/verticals/party-registry/src/services/party-matching-persistence.service.ts b/app/verticals/party-registry/src/services/party-matching-persistence.service.ts index 97c6f8477..2d3b3adf1 100644 --- a/app/verticals/party-registry/src/services/party-matching-persistence.service.ts +++ b/app/verticals/party-registry/src/services/party-matching-persistence.service.ts @@ -1,5 +1,5 @@ // @generated by OntOS Codesmith Action Service v1 -import { and, desc, eq, gt, inArray, isNull, lte, or, sql } from 'drizzle-orm'; +import { and, desc, eq, gt, isNull, lte, or, sql } from 'drizzle-orm'; import { DateTime, Effect, Option, Result, Schema } from 'effect'; import { createHash } from 'node:crypto'; import type { NormalizedOfficialIdentifier } from '../../shared/domain/identifier-contracts.ts'; @@ -59,6 +59,7 @@ import { lockTenantIdentityWrites, } from './party-identifier-claim.service.ts'; import { + findOpenDuplicateCandidateCase, findPartyRecord, insertPartyRecord, partyDto, @@ -409,19 +410,11 @@ const createOrReuseCase = Effect.fn('PartyMatchingPersistenceService.createOrReu }) .from(sql`(values (1)) as party_case_lock_anchor(value)`) .pipe(Effect.mapError(unavailable)); - const [existing] = yield* transaction - .select() - .from(duplicateCandidateCases) - .where( - and( - eq(duplicateCandidateCases.tenantId, tenantId), - eq(duplicateCandidateCases.evaluationFingerprint, evaluationFingerprint), - eq(duplicateCandidateCases.matchRuleVersion, MATCH_RULE_VERSION), - inArray(duplicateCandidateCases.lifecycleState, ['OPEN', 'NEEDS_EVIDENCE']), - ), - ) - .limit(1) - .pipe(Effect.mapError(unavailable)); + const [existing] = yield* findOpenDuplicateCandidateCase( + transaction, + tenantId, + evaluationFingerprint, + ).pipe(Effect.mapError(unavailable)); if (existing !== undefined) { return existing; } diff --git a/app/verticals/party-registry/src/services/party-relationship-persistence.service.ts b/app/verticals/party-registry/src/services/party-relationship-persistence.service.ts index 390528f48..bc2463480 100644 --- a/app/verticals/party-registry/src/services/party-relationship-persistence.service.ts +++ b/app/verticals/party-registry/src/services/party-relationship-persistence.service.ts @@ -356,6 +356,21 @@ const resolveCreateDecision = (input: { Match.exhaustive, ); +const staleRelationshipRevision = (actualRevision: number, expectedRevision: number) => + Effect.fail( + new PartyRelationshipRevisionConflict({ + actualRevision, + code: 'party_relationship_revision_conflict', + expectedRevision, + reason: 'The Party Relationship changed after it was read', + }), + ); + +const invalidRelationshipInterval = (reason: string) => + Effect.fail( + new PartyRelationshipInvalidInterval({ code: 'party_relationship_invalid_interval', reason }), + ); + const validateUpdateDecision = ( decision: ReturnType, payload: UpdatePartyRelationshipPayload, @@ -368,29 +383,16 @@ const validateUpdateDecision = ( Match.value(decision).pipe( Match.tag('update', () => Effect.void), Match.tag('revision_conflict', (conflict) => - Effect.fail( - new PartyRelationshipRevisionConflict({ - actualRevision: conflict.actualRevision, - code: 'party_relationship_revision_conflict', - expectedRevision: payload.expectedRevision, - reason: 'The Party Relationship changed after it was read', - }), - ), + staleRelationshipRevision(conflict.actualRevision, payload.expectedRevision), ), Match.tag('invalid_interval', () => - Effect.fail( - new PartyRelationshipInvalidInterval({ - code: 'party_relationship_invalid_interval', - reason: 'validTo must be later than validFrom for the exclusive [from,to) interval', - }), + invalidRelationshipInterval( + 'validTo must be later than validFrom for the exclusive [from,to) interval', ), ), Match.tag('end_required', () => - Effect.fail( - new PartyRelationshipInvalidInterval({ - code: 'party_relationship_invalid_interval', - reason: 'Use End to establish an immediate or retrospective effective end', - }), + invalidRelationshipInterval( + 'Use End to establish an immediate or retrospective effective end', ), ), Match.tag('correction_required', (correction) => @@ -419,30 +421,15 @@ const validateEndDecision = ( Match.tag('end', () => Effect.succeed('CHANGE' as const)), Match.tag('unchanged', () => Effect.succeed('UNCHANGED' as const)), Match.tag('revision_conflict', (conflict) => - Effect.fail( - new PartyRelationshipRevisionConflict({ - actualRevision: conflict.actualRevision, - code: 'party_relationship_revision_conflict', - expectedRevision: payload.expectedRevision, - reason: 'The Party Relationship changed after it was read', - }), - ), + staleRelationshipRevision(conflict.actualRevision, payload.expectedRevision), ), Match.tag('invalid_interval', () => - Effect.fail( - new PartyRelationshipInvalidInterval({ - code: 'party_relationship_invalid_interval', - reason: 'The effective end must be later than the relationship validFrom', - }), + invalidRelationshipInterval( + 'The effective end must be later than the relationship validFrom', ), ), Match.tag('update_required', () => - Effect.fail( - new PartyRelationshipInvalidInterval({ - code: 'party_relationship_invalid_interval', - reason: 'Use Update to change a still-future planned end', - }), - ), + invalidRelationshipInterval('Use Update to change a still-future planned end'), ), Match.tag('correction_required', (correction) => Effect.fail( diff --git a/app/verticals/party-registry/src/ultramodern-build.ts b/app/verticals/party-registry/src/ultramodern-build.ts deleted file mode 100644 index eefaf129e..000000000 --- a/app/verticals/party-registry/src/ultramodern-build.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { - ultramodernBuildArtifact, - ultramodernApiMarker, - ultramodernDeliveryUnit, - ultramodernUiMarker, - ultramodernVerticalIdentity, -} from '../shared/ultramodern-build'; diff --git a/app/verticals/party-registry/src/workers/party-search-worker.ts b/app/verticals/party-registry/src/workers/party-search-worker.ts new file mode 100644 index 000000000..19cfd2911 --- /dev/null +++ b/app/verticals/party-registry/src/workers/party-search-worker.ts @@ -0,0 +1,41 @@ +import { defineOutboxWorker } from '@app/core-runtime'; +import type { OutboxWorkerDescriptor, OutboxWorkerHandlerContext } from '@app/core-runtime'; +import { Effect } from 'effect'; +import type { Schema } from 'effect'; +import { PartySearchProjector } from '../services/party-search-projection.service.ts'; +import type { PartySearchProjectionTarget } from '../services/party-search-projection.service.ts'; + +export const definePartySearchWorker = >( + descriptor: Pick< + OutboxWorkerDescriptor, + 'entrypoint' | 'payloadSchema' | 'producerModuleKey' | 'topic' + >, + projection: { + readonly spanName: string; + readonly target: (payload: PayloadSchema['Type']) => PartySearchProjectionTarget; + }, +) => { + const handle = Effect.fn(projection.spanName)(function* projectCommittedEvent( + payload: PayloadSchema['Type'], + context: OutboxWorkerHandlerContext, + ) { + const projector = yield* PartySearchProjector; + yield* projector.project(context, projection.target(payload)); + }); + const worker = defineOutboxWorker( + { + ...descriptor, + consumerModuleKey: 'party.registry', + leaseDurationMs: 30_000, + retryPolicy: { + initialBackoffMs: 1000, + maxAttempts: 5, + maxBackoffMs: 60_000, + multiplier: 2, + }, + workerKey: descriptor.entrypoint.entrypointKey, + }, + handle, + ); + return { handle, worker }; +}; diff --git a/app/verticals/party-registry/src/workers/project-contact-point-added-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-contact-point-added-to-search.worker.ts index 05f726590..714883116 100644 --- a/app/verticals/party-registry/src/workers/project-contact-point-added-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-contact-point-added-to-search.worker.ts @@ -3,29 +3,16 @@ // @ontos-outbox-worker-owner party.registry // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.contact-point-added.v1 -import { Effect } from 'effect'; -import { defineOutboxWorker, defineTenantModuleEntrypoint } from '@app/core-runtime'; -import type { OutboxWorkerHandlerContext } from '@app/core-runtime'; -import { PartySearchProjector } from '../services/party-search-projection.service.ts'; +import { defineTenantModuleEntrypoint } from '@app/core-runtime'; +import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-contact-point-added-v1'; -const handleProjectContactPointAddedToSearch = Effect.fn( - 'ProjectContactPointAddedToSearchWorker.handleProjectContactPointAddedToSearch', -)(function* projectCommittedEvent( - payload: typeof OutboxPayloadSchema.Type, - context: OutboxWorkerHandlerContext, -) { - const projector = yield* PartySearchProjector; - yield* projector.project(context, { partyId: payload.partyRef.resourceId }); -}); - -export const projectContactPointAddedToSearchWorker = defineOutboxWorker( +export const { worker: projectContactPointAddedToSearchWorker } = definePartySearchWorker( { - consumerModuleKey: 'party.registry', entrypoint: defineTenantModuleEntrypoint({ access: 'background', authorization: { kind: 'owner_local_background' }, @@ -33,17 +20,12 @@ export const projectContactPointAddedToSearchWorker = defineOutboxWorker( moduleKey: 'party.registry', role: 'worker', }), - leaseDurationMs: 30_000, payloadSchema: OutboxPayloadSchema, producerModuleKey: outboxProducerModuleKey, - retryPolicy: { - initialBackoffMs: 1000, - maxAttempts: 5, - maxBackoffMs: 60_000, - multiplier: 2, - }, topic: outboxTopic, - workerKey: 'party.registry.project-contact-point-added-to-search', }, - handleProjectContactPointAddedToSearch, + { + spanName: 'ProjectContactPointAddedToSearchWorker.handleProjectContactPointAddedToSearch', + target: (payload) => ({ partyId: payload.partyRef.resourceId }), + }, ); diff --git a/app/verticals/party-registry/src/workers/project-contact-point-ended-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-contact-point-ended-to-search.worker.ts index 5169b0be7..06c18e1a4 100644 --- a/app/verticals/party-registry/src/workers/project-contact-point-ended-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-contact-point-ended-to-search.worker.ts @@ -3,29 +3,16 @@ // @ontos-outbox-worker-owner party.registry // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.contact-point-ended.v1 -import { Effect } from 'effect'; -import { defineOutboxWorker, defineTenantModuleEntrypoint } from '@app/core-runtime'; -import type { OutboxWorkerHandlerContext } from '@app/core-runtime'; -import { PartySearchProjector } from '../services/party-search-projection.service.ts'; +import { defineTenantModuleEntrypoint } from '@app/core-runtime'; +import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-contact-point-ended-v1'; -const handleProjectContactPointEndedToSearch = Effect.fn( - 'ProjectContactPointEndedToSearchWorker.handleProjectContactPointEndedToSearch', -)(function* projectCommittedEvent( - payload: typeof OutboxPayloadSchema.Type, - context: OutboxWorkerHandlerContext, -) { - const projector = yield* PartySearchProjector; - yield* projector.project(context, { partyId: payload.partyRef.resourceId }); -}); - -export const projectContactPointEndedToSearchWorker = defineOutboxWorker( +export const { worker: projectContactPointEndedToSearchWorker } = definePartySearchWorker( { - consumerModuleKey: 'party.registry', entrypoint: defineTenantModuleEntrypoint({ access: 'background', authorization: { kind: 'owner_local_background' }, @@ -33,17 +20,12 @@ export const projectContactPointEndedToSearchWorker = defineOutboxWorker( moduleKey: 'party.registry', role: 'worker', }), - leaseDurationMs: 30_000, payloadSchema: OutboxPayloadSchema, producerModuleKey: outboxProducerModuleKey, - retryPolicy: { - initialBackoffMs: 1000, - maxAttempts: 5, - maxBackoffMs: 60_000, - multiplier: 2, - }, topic: outboxTopic, - workerKey: 'party.registry.project-contact-point-ended-to-search', }, - handleProjectContactPointEndedToSearch, + { + spanName: 'ProjectContactPointEndedToSearchWorker.handleProjectContactPointEndedToSearch', + target: (payload) => ({ partyId: payload.partyRef.resourceId }), + }, ); diff --git a/app/verticals/party-registry/src/workers/project-contact-point-updated-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-contact-point-updated-to-search.worker.ts index 591ef8647..b1e2cf26c 100644 --- a/app/verticals/party-registry/src/workers/project-contact-point-updated-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-contact-point-updated-to-search.worker.ts @@ -3,29 +3,16 @@ // @ontos-outbox-worker-owner party.registry // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.contact-point-updated.v1 -import { Effect } from 'effect'; -import { defineOutboxWorker, defineTenantModuleEntrypoint } from '@app/core-runtime'; -import type { OutboxWorkerHandlerContext } from '@app/core-runtime'; -import { PartySearchProjector } from '../services/party-search-projection.service.ts'; +import { defineTenantModuleEntrypoint } from '@app/core-runtime'; +import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-contact-point-updated-v1'; -const handleProjectContactPointUpdatedToSearch = Effect.fn( - 'handleProjectContactPointUpdatedToSearch', -)(function* projectCommittedEvent( - payload: typeof OutboxPayloadSchema.Type, - context: OutboxWorkerHandlerContext, -) { - const projector = yield* PartySearchProjector; - yield* projector.project(context, { partyId: payload.partyRef.resourceId }); -}); - -export const projectContactPointUpdatedToSearchWorker = defineOutboxWorker( +export const { worker: projectContactPointUpdatedToSearchWorker } = definePartySearchWorker( { - consumerModuleKey: 'party.registry', entrypoint: defineTenantModuleEntrypoint({ access: 'background', authorization: { kind: 'owner_local_background' }, @@ -33,17 +20,12 @@ export const projectContactPointUpdatedToSearchWorker = defineOutboxWorker( moduleKey: 'party.registry', role: 'worker', }), - leaseDurationMs: 30_000, payloadSchema: OutboxPayloadSchema, producerModuleKey: outboxProducerModuleKey, - retryPolicy: { - initialBackoffMs: 1000, - maxAttempts: 5, - maxBackoffMs: 60_000, - multiplier: 2, - }, topic: outboxTopic, - workerKey: 'party.registry.project-contact-point-updated-to-search', }, - handleProjectContactPointUpdatedToSearch, + { + spanName: 'handleProjectContactPointUpdatedToSearch', + target: (payload) => ({ partyId: payload.partyRef.resourceId }), + }, ); diff --git a/app/verticals/party-registry/src/workers/project-counterparty-created-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-counterparty-created-to-search.worker.ts index a970b5add..0c5ccc534 100644 --- a/app/verticals/party-registry/src/workers/project-counterparty-created-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-counterparty-created-to-search.worker.ts @@ -3,29 +3,16 @@ // @ontos-outbox-worker-owner party.registry // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.counterparty-created.v1 -import { Effect } from 'effect'; -import { defineOutboxWorker, defineTenantModuleEntrypoint } from '@app/core-runtime'; -import type { OutboxWorkerHandlerContext } from '@app/core-runtime'; -import { PartySearchProjector } from '../services/party-search-projection.service.ts'; +import { defineTenantModuleEntrypoint } from '@app/core-runtime'; +import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-counterparty-created-v1'; -const handleProjectCounterpartyCreatedToSearch = Effect.fn( - 'handleProjectCounterpartyCreatedToSearch', -)(function* projectCommittedEvent( - payload: typeof OutboxPayloadSchema.Type, - context: OutboxWorkerHandlerContext, -) { - const projector = yield* PartySearchProjector; - yield* projector.project(context, { counterpartyId: payload.counterpartyRef.resourceId }); -}); - -export const projectCounterpartyCreatedToSearchWorker = defineOutboxWorker( +export const { worker: projectCounterpartyCreatedToSearchWorker } = definePartySearchWorker( { - consumerModuleKey: 'party.registry', entrypoint: defineTenantModuleEntrypoint({ access: 'background', authorization: { kind: 'owner_local_background' }, @@ -33,17 +20,12 @@ export const projectCounterpartyCreatedToSearchWorker = defineOutboxWorker( moduleKey: 'party.registry', role: 'worker', }), - leaseDurationMs: 30_000, payloadSchema: OutboxPayloadSchema, producerModuleKey: outboxProducerModuleKey, - retryPolicy: { - initialBackoffMs: 1000, - maxAttempts: 5, - maxBackoffMs: 60_000, - multiplier: 2, - }, topic: outboxTopic, - workerKey: 'party.registry.project-counterparty-created-to-search', }, - handleProjectCounterpartyCreatedToSearch, + { + spanName: 'handleProjectCounterpartyCreatedToSearch', + target: (payload) => ({ counterpartyId: payload.counterpartyRef.resourceId }), + }, ); diff --git a/app/verticals/party-registry/src/workers/project-counterparty-role-added-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-counterparty-role-added-to-search.worker.ts index f7b0bc60f..cb8b9379a 100644 --- a/app/verticals/party-registry/src/workers/project-counterparty-role-added-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-counterparty-role-added-to-search.worker.ts @@ -3,29 +3,16 @@ // @ontos-outbox-worker-owner party.registry // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.counterparty-role-added.v1 -import { Effect } from 'effect'; -import { defineOutboxWorker, defineTenantModuleEntrypoint } from '@app/core-runtime'; -import type { OutboxWorkerHandlerContext } from '@app/core-runtime'; -import { PartySearchProjector } from '../services/party-search-projection.service.ts'; +import { defineTenantModuleEntrypoint } from '@app/core-runtime'; +import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-counterparty-role-added-v1'; -const handleProjectCounterpartyRoleAddedToSearch = Effect.fn( - 'handleProjectCounterpartyRoleAddedToSearch', -)(function* projectCommittedEvent( - payload: typeof OutboxPayloadSchema.Type, - context: OutboxWorkerHandlerContext, -) { - const projector = yield* PartySearchProjector; - yield* projector.project(context, { counterpartyId: payload.counterpartyRef.resourceId }); -}); - -export const projectCounterpartyRoleAddedToSearchWorker = defineOutboxWorker( +export const { worker: projectCounterpartyRoleAddedToSearchWorker } = definePartySearchWorker( { - consumerModuleKey: 'party.registry', entrypoint: defineTenantModuleEntrypoint({ access: 'background', authorization: { kind: 'owner_local_background' }, @@ -33,17 +20,12 @@ export const projectCounterpartyRoleAddedToSearchWorker = defineOutboxWorker( moduleKey: 'party.registry', role: 'worker', }), - leaseDurationMs: 30_000, payloadSchema: OutboxPayloadSchema, producerModuleKey: outboxProducerModuleKey, - retryPolicy: { - initialBackoffMs: 1000, - maxAttempts: 5, - maxBackoffMs: 60_000, - multiplier: 2, - }, topic: outboxTopic, - workerKey: 'party.registry.project-counterparty-role-added-to-search', }, - handleProjectCounterpartyRoleAddedToSearch, + { + spanName: 'handleProjectCounterpartyRoleAddedToSearch', + target: (payload) => ({ counterpartyId: payload.counterpartyRef.resourceId }), + }, ); diff --git a/app/verticals/party-registry/src/workers/project-counterparty-role-ended-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-counterparty-role-ended-to-search.worker.ts index 197db941c..8cacda8a5 100644 --- a/app/verticals/party-registry/src/workers/project-counterparty-role-ended-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-counterparty-role-ended-to-search.worker.ts @@ -3,29 +3,16 @@ // @ontos-outbox-worker-owner party.registry // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.counterparty-role-ended.v1 -import { Effect } from 'effect'; -import { defineOutboxWorker, defineTenantModuleEntrypoint } from '@app/core-runtime'; -import type { OutboxWorkerHandlerContext } from '@app/core-runtime'; -import { PartySearchProjector } from '../services/party-search-projection.service.ts'; +import { defineTenantModuleEntrypoint } from '@app/core-runtime'; +import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-counterparty-role-ended-v1'; -const handleProjectCounterpartyRoleEndedToSearch = Effect.fn( - 'handleProjectCounterpartyRoleEndedToSearch', -)(function* projectCommittedEvent( - payload: typeof OutboxPayloadSchema.Type, - context: OutboxWorkerHandlerContext, -) { - const projector = yield* PartySearchProjector; - yield* projector.project(context, { counterpartyId: payload.counterpartyRef.resourceId }); -}); - -export const projectCounterpartyRoleEndedToSearchWorker = defineOutboxWorker( +export const { worker: projectCounterpartyRoleEndedToSearchWorker } = definePartySearchWorker( { - consumerModuleKey: 'party.registry', entrypoint: defineTenantModuleEntrypoint({ access: 'background', authorization: { kind: 'owner_local_background' }, @@ -33,17 +20,12 @@ export const projectCounterpartyRoleEndedToSearchWorker = defineOutboxWorker( moduleKey: 'party.registry', role: 'worker', }), - leaseDurationMs: 30_000, payloadSchema: OutboxPayloadSchema, producerModuleKey: outboxProducerModuleKey, - retryPolicy: { - initialBackoffMs: 1000, - maxAttempts: 5, - maxBackoffMs: 60_000, - multiplier: 2, - }, topic: outboxTopic, - workerKey: 'party.registry.project-counterparty-role-ended-to-search', }, - handleProjectCounterpartyRoleEndedToSearch, + { + spanName: 'handleProjectCounterpartyRoleEndedToSearch', + target: (payload) => ({ counterpartyId: payload.counterpartyRef.resourceId }), + }, ); diff --git a/app/verticals/party-registry/src/workers/project-official-identifier-added-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-official-identifier-added-to-search.worker.ts index 1e05b2a61..74647fe8b 100644 --- a/app/verticals/party-registry/src/workers/project-official-identifier-added-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-official-identifier-added-to-search.worker.ts @@ -3,29 +3,19 @@ // @ontos-outbox-worker-owner party.registry // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.official-identifier-added.v1 -import { Effect } from 'effect'; -import { defineOutboxWorker, defineTenantModuleEntrypoint } from '@app/core-runtime'; -import type { OutboxWorkerHandlerContext } from '@app/core-runtime'; -import { PartySearchProjector } from '../services/party-search-projection.service.ts'; +import { defineTenantModuleEntrypoint } from '@app/core-runtime'; +import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-official-identifier-added-v1'; -export const handleProjectOfficialIdentifierAddedToSearch = Effect.fn( - 'handleProjectOfficialIdentifierAddedToSearch', -)(function* projectCommittedEvent( - payload: typeof OutboxPayloadSchema.Type, - context: OutboxWorkerHandlerContext, -) { - const projector = yield* PartySearchProjector; - yield* projector.project(context, { partyId: payload.partyRef.resourceId }); -}); - -export const projectOfficialIdentifierAddedToSearchWorker = defineOutboxWorker( +export const { + worker: projectOfficialIdentifierAddedToSearchWorker, + handle: handleProjectOfficialIdentifierAddedToSearch, +} = definePartySearchWorker( { - consumerModuleKey: 'party.registry', entrypoint: defineTenantModuleEntrypoint({ access: 'background', authorization: { kind: 'owner_local_background' }, @@ -33,17 +23,12 @@ export const projectOfficialIdentifierAddedToSearchWorker = defineOutboxWorker( moduleKey: 'party.registry', role: 'worker', }), - leaseDurationMs: 30_000, payloadSchema: OutboxPayloadSchema, producerModuleKey: outboxProducerModuleKey, - retryPolicy: { - initialBackoffMs: 1000, - maxAttempts: 5, - maxBackoffMs: 60_000, - multiplier: 2, - }, topic: outboxTopic, - workerKey: 'party.registry.project-official-identifier-added-to-search', }, - handleProjectOfficialIdentifierAddedToSearch, + { + spanName: 'handleProjectOfficialIdentifierAddedToSearch', + target: (payload) => ({ partyId: payload.partyRef.resourceId }), + }, ); diff --git a/app/verticals/party-registry/src/workers/project-official-identifier-ended-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-official-identifier-ended-to-search.worker.ts index 1a906287b..ff4df6ff5 100644 --- a/app/verticals/party-registry/src/workers/project-official-identifier-ended-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-official-identifier-ended-to-search.worker.ts @@ -3,29 +3,16 @@ // @ontos-outbox-worker-owner party.registry // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.official-identifier-ended.v1 -import { Effect } from 'effect'; -import { defineOutboxWorker, defineTenantModuleEntrypoint } from '@app/core-runtime'; -import type { OutboxWorkerHandlerContext } from '@app/core-runtime'; -import { PartySearchProjector } from '../services/party-search-projection.service.ts'; +import { defineTenantModuleEntrypoint } from '@app/core-runtime'; +import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-official-identifier-ended-v1'; -const handleProjectOfficialIdentifierEndedToSearch = Effect.fn( - 'handleProjectOfficialIdentifierEndedToSearch', -)(function* projectCommittedEvent( - payload: typeof OutboxPayloadSchema.Type, - context: OutboxWorkerHandlerContext, -) { - const projector = yield* PartySearchProjector; - yield* projector.project(context, { partyId: payload.partyRef.resourceId }); -}); - -export const projectOfficialIdentifierEndedToSearchWorker = defineOutboxWorker( +export const { worker: projectOfficialIdentifierEndedToSearchWorker } = definePartySearchWorker( { - consumerModuleKey: 'party.registry', entrypoint: defineTenantModuleEntrypoint({ access: 'background', authorization: { kind: 'owner_local_background' }, @@ -33,17 +20,12 @@ export const projectOfficialIdentifierEndedToSearchWorker = defineOutboxWorker( moduleKey: 'party.registry', role: 'worker', }), - leaseDurationMs: 30_000, payloadSchema: OutboxPayloadSchema, producerModuleKey: outboxProducerModuleKey, - retryPolicy: { - initialBackoffMs: 1000, - maxAttempts: 5, - maxBackoffMs: 60_000, - multiplier: 2, - }, topic: outboxTopic, - workerKey: 'party.registry.project-official-identifier-ended-to-search', }, - handleProjectOfficialIdentifierEndedToSearch, + { + spanName: 'handleProjectOfficialIdentifierEndedToSearch', + target: (payload) => ({ partyId: payload.partyRef.resourceId }), + }, ); diff --git a/app/verticals/party-registry/src/workers/project-official-identifier-updated-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-official-identifier-updated-to-search.worker.ts index 3c81dabcb..57859befd 100644 --- a/app/verticals/party-registry/src/workers/project-official-identifier-updated-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-official-identifier-updated-to-search.worker.ts @@ -3,29 +3,19 @@ // @ontos-outbox-worker-owner party.registry // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.official-identifier-updated.v1 -import { Effect } from 'effect'; -import { defineOutboxWorker, defineTenantModuleEntrypoint } from '@app/core-runtime'; -import type { OutboxWorkerHandlerContext } from '@app/core-runtime'; -import { PartySearchProjector } from '../services/party-search-projection.service.ts'; +import { defineTenantModuleEntrypoint } from '@app/core-runtime'; +import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-official-identifier-updated-v1'; -export const handleProjectOfficialIdentifierUpdatedToSearch = Effect.fn( - 'handleProjectOfficialIdentifierUpdatedToSearch', -)(function* projectCommittedIdentifierUpdate( - payload: typeof OutboxPayloadSchema.Type, - context: OutboxWorkerHandlerContext, -) { - const projector = yield* PartySearchProjector; - yield* projector.project(context, { partyId: payload.partyRef.resourceId }); -}); - -export const projectOfficialIdentifierUpdatedToSearchWorker = defineOutboxWorker( +export const { + worker: projectOfficialIdentifierUpdatedToSearchWorker, + handle: handleProjectOfficialIdentifierUpdatedToSearch, +} = definePartySearchWorker( { - consumerModuleKey: 'party.registry', entrypoint: defineTenantModuleEntrypoint({ access: 'background', authorization: { kind: 'owner_local_background' }, @@ -33,17 +23,12 @@ export const projectOfficialIdentifierUpdatedToSearchWorker = defineOutboxWorker moduleKey: 'party.registry', role: 'worker', }), - leaseDurationMs: 30_000, payloadSchema: OutboxPayloadSchema, producerModuleKey: outboxProducerModuleKey, - retryPolicy: { - initialBackoffMs: 1000, - maxAttempts: 5, - maxBackoffMs: 60_000, - multiplier: 2, - }, topic: outboxTopic, - workerKey: 'party.registry.project-official-identifier-updated-to-search', }, - handleProjectOfficialIdentifierUpdatedToSearch, + { + spanName: 'handleProjectOfficialIdentifierUpdatedToSearch', + target: (payload) => ({ partyId: payload.partyRef.resourceId }), + }, ); diff --git a/app/verticals/party-registry/src/workers/project-party-archived-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-party-archived-to-search.worker.ts index 747172e5d..6b5fde983 100644 --- a/app/verticals/party-registry/src/workers/project-party-archived-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-party-archived-to-search.worker.ts @@ -3,29 +3,16 @@ // @ontos-outbox-worker-owner party.registry // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.party-archived.v1 -import { Effect } from 'effect'; -import { defineOutboxWorker, defineTenantModuleEntrypoint } from '@app/core-runtime'; -import type { OutboxWorkerHandlerContext } from '@app/core-runtime'; -import { PartySearchProjector } from '../services/party-search-projection.service.ts'; +import { defineTenantModuleEntrypoint } from '@app/core-runtime'; +import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-party-archived-v1'; -const handleProjectPartyArchivedToSearch = Effect.fn( - 'ProjectPartyArchivedToSearchWorker.handleProjectPartyArchivedToSearch', -)(function* projectCommittedEvent( - payload: typeof OutboxPayloadSchema.Type, - context: OutboxWorkerHandlerContext, -) { - const projector = yield* PartySearchProjector; - yield* projector.project(context, { partyId: payload.partyRef.resourceId }); -}); - -export const projectPartyArchivedToSearchWorker = defineOutboxWorker( +export const { worker: projectPartyArchivedToSearchWorker } = definePartySearchWorker( { - consumerModuleKey: 'party.registry', entrypoint: defineTenantModuleEntrypoint({ access: 'background', authorization: { kind: 'owner_local_background' }, @@ -33,17 +20,12 @@ export const projectPartyArchivedToSearchWorker = defineOutboxWorker( moduleKey: 'party.registry', role: 'worker', }), - leaseDurationMs: 30_000, payloadSchema: OutboxPayloadSchema, producerModuleKey: outboxProducerModuleKey, - retryPolicy: { - initialBackoffMs: 1000, - maxAttempts: 5, - maxBackoffMs: 60_000, - multiplier: 2, - }, topic: outboxTopic, - workerKey: 'party.registry.project-party-archived-to-search', }, - handleProjectPartyArchivedToSearch, + { + spanName: 'ProjectPartyArchivedToSearchWorker.handleProjectPartyArchivedToSearch', + target: (payload) => ({ partyId: payload.partyRef.resourceId }), + }, ); diff --git a/app/verticals/party-registry/src/workers/project-party-created-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-party-created-to-search.worker.ts index 8d8919181..803fb1316 100644 --- a/app/verticals/party-registry/src/workers/project-party-created-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-party-created-to-search.worker.ts @@ -3,29 +3,16 @@ // @ontos-outbox-worker-owner party.registry // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.party-created.v1 -import { Effect } from 'effect'; -import { defineOutboxWorker, defineTenantModuleEntrypoint } from '@app/core-runtime'; -import type { OutboxWorkerHandlerContext } from '@app/core-runtime'; -import { PartySearchProjector } from '../services/party-search-projection.service.ts'; +import { defineTenantModuleEntrypoint } from '@app/core-runtime'; +import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-party-created-v1'; -const handleProjectPartyCreatedToSearch = Effect.fn( - 'ProjectPartyCreatedToSearchWorker.handleProjectPartyCreatedToSearch', -)(function* projectCommittedEvent( - payload: typeof OutboxPayloadSchema.Type, - context: OutboxWorkerHandlerContext, -) { - const projector = yield* PartySearchProjector; - yield* projector.project(context, { partyId: payload.partyRef.resourceId }); -}); - -export const projectPartyCreatedToSearchWorker = defineOutboxWorker( +export const { worker: projectPartyCreatedToSearchWorker } = definePartySearchWorker( { - consumerModuleKey: 'party.registry', entrypoint: defineTenantModuleEntrypoint({ access: 'background', authorization: { kind: 'owner_local_background' }, @@ -33,17 +20,12 @@ export const projectPartyCreatedToSearchWorker = defineOutboxWorker( moduleKey: 'party.registry', role: 'worker', }), - leaseDurationMs: 30_000, payloadSchema: OutboxPayloadSchema, producerModuleKey: outboxProducerModuleKey, - retryPolicy: { - initialBackoffMs: 1000, - maxAttempts: 5, - maxBackoffMs: 60_000, - multiplier: 2, - }, topic: outboxTopic, - workerKey: 'party.registry.project-party-created-to-search', }, - handleProjectPartyCreatedToSearch, + { + spanName: 'ProjectPartyCreatedToSearchWorker.handleProjectPartyCreatedToSearch', + target: (payload) => ({ partyId: payload.partyRef.resourceId }), + }, ); diff --git a/app/verticals/party-registry/src/workers/project-party-fact-corrected-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-party-fact-corrected-to-search.worker.ts index 9a752a281..6696f51a5 100644 --- a/app/verticals/party-registry/src/workers/project-party-fact-corrected-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-party-fact-corrected-to-search.worker.ts @@ -3,29 +3,16 @@ // @ontos-outbox-worker-owner party.registry // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.party-fact-corrected.v1 -import { Effect } from 'effect'; -import { defineOutboxWorker, defineTenantModuleEntrypoint } from '@app/core-runtime'; -import type { OutboxWorkerHandlerContext } from '@app/core-runtime'; -import { PartySearchProjector } from '../services/party-search-projection.service.ts'; +import { defineTenantModuleEntrypoint } from '@app/core-runtime'; +import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-party-fact-corrected-v1'; -const handleProjectPartyFactCorrectedToSearch = Effect.fn( - 'ProjectPartyFactCorrectedToSearchWorker.handleProjectPartyFactCorrectedToSearch', -)(function* projectCommittedEvent( - payload: typeof OutboxPayloadSchema.Type, - context: OutboxWorkerHandlerContext, -) { - const projector = yield* PartySearchProjector; - yield* projector.project(context, { partyId: payload.partyRef.resourceId }); -}); - -export const projectPartyFactCorrectedToSearchWorker = defineOutboxWorker( +export const { worker: projectPartyFactCorrectedToSearchWorker } = definePartySearchWorker( { - consumerModuleKey: 'party.registry', entrypoint: defineTenantModuleEntrypoint({ access: 'background', authorization: { kind: 'owner_local_background' }, @@ -33,17 +20,12 @@ export const projectPartyFactCorrectedToSearchWorker = defineOutboxWorker( moduleKey: 'party.registry', role: 'worker', }), - leaseDurationMs: 30_000, payloadSchema: OutboxPayloadSchema, producerModuleKey: outboxProducerModuleKey, - retryPolicy: { - initialBackoffMs: 1000, - maxAttempts: 5, - maxBackoffMs: 60_000, - multiplier: 2, - }, topic: outboxTopic, - workerKey: 'party.registry.project-party-fact-corrected-to-search', }, - handleProjectPartyFactCorrectedToSearch, + { + spanName: 'ProjectPartyFactCorrectedToSearchWorker.handleProjectPartyFactCorrectedToSearch', + target: (payload) => ({ partyId: payload.partyRef.resourceId }), + }, ); diff --git a/app/verticals/party-registry/src/workers/project-party-unarchived-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-party-unarchived-to-search.worker.ts index 653270e9c..285c9f340 100644 --- a/app/verticals/party-registry/src/workers/project-party-unarchived-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-party-unarchived-to-search.worker.ts @@ -3,29 +3,16 @@ // @ontos-outbox-worker-owner party.registry // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.party-unarchived.v1 -import { Effect } from 'effect'; -import { defineOutboxWorker, defineTenantModuleEntrypoint } from '@app/core-runtime'; -import type { OutboxWorkerHandlerContext } from '@app/core-runtime'; -import { PartySearchProjector } from '../services/party-search-projection.service.ts'; +import { defineTenantModuleEntrypoint } from '@app/core-runtime'; +import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-party-unarchived-v1'; -const handleProjectPartyUnarchivedToSearch = Effect.fn( - 'ProjectPartyUnarchivedToSearchWorker.handleProjectPartyUnarchivedToSearch', -)(function* projectCommittedEvent( - payload: typeof OutboxPayloadSchema.Type, - context: OutboxWorkerHandlerContext, -) { - const projector = yield* PartySearchProjector; - yield* projector.project(context, { partyId: payload.partyRef.resourceId }); -}); - -export const projectPartyUnarchivedToSearchWorker = defineOutboxWorker( +export const { worker: projectPartyUnarchivedToSearchWorker } = definePartySearchWorker( { - consumerModuleKey: 'party.registry', entrypoint: defineTenantModuleEntrypoint({ access: 'background', authorization: { kind: 'owner_local_background' }, @@ -33,17 +20,12 @@ export const projectPartyUnarchivedToSearchWorker = defineOutboxWorker( moduleKey: 'party.registry', role: 'worker', }), - leaseDurationMs: 30_000, payloadSchema: OutboxPayloadSchema, producerModuleKey: outboxProducerModuleKey, - retryPolicy: { - initialBackoffMs: 1000, - maxAttempts: 5, - maxBackoffMs: 60_000, - multiplier: 2, - }, topic: outboxTopic, - workerKey: 'party.registry.project-party-unarchived-to-search', }, - handleProjectPartyUnarchivedToSearch, + { + spanName: 'ProjectPartyUnarchivedToSearchWorker.handleProjectPartyUnarchivedToSearch', + target: (payload) => ({ partyId: payload.partyRef.resourceId }), + }, ); diff --git a/app/verticals/party-registry/src/workers/project-party-updated-to-search.worker.ts b/app/verticals/party-registry/src/workers/project-party-updated-to-search.worker.ts index 03b070998..469160285 100644 --- a/app/verticals/party-registry/src/workers/project-party-updated-to-search.worker.ts +++ b/app/verticals/party-registry/src/workers/project-party-updated-to-search.worker.ts @@ -3,29 +3,16 @@ // @ontos-outbox-worker-owner party.registry // @ontos-outbox-worker-producer party.registry // @ontos-outbox-worker-topic party.registry.party-updated.v1 -import { Effect } from 'effect'; -import { defineOutboxWorker, defineTenantModuleEntrypoint } from '@app/core-runtime'; -import type { OutboxWorkerHandlerContext } from '@app/core-runtime'; -import { PartySearchProjector } from '../services/party-search-projection.service.ts'; +import { defineTenantModuleEntrypoint } from '@app/core-runtime'; +import { definePartySearchWorker } from './party-search-worker.ts'; import { OutboxPayloadSchema, outboxProducerModuleKey, outboxTopic, } from '@app/party-registry/outbox/party-registry-party-updated-v1'; -const handleProjectPartyUpdatedToSearch = Effect.fn( - 'ProjectPartyUpdatedToSearchWorker.handleProjectPartyUpdatedToSearch', -)(function* projectCommittedEvent( - payload: typeof OutboxPayloadSchema.Type, - context: OutboxWorkerHandlerContext, -) { - const projector = yield* PartySearchProjector; - yield* projector.project(context, { partyId: payload.partyRef.resourceId }); -}); - -export const projectPartyUpdatedToSearchWorker = defineOutboxWorker( +export const { worker: projectPartyUpdatedToSearchWorker } = definePartySearchWorker( { - consumerModuleKey: 'party.registry', entrypoint: defineTenantModuleEntrypoint({ access: 'background', authorization: { kind: 'owner_local_background' }, @@ -33,17 +20,12 @@ export const projectPartyUpdatedToSearchWorker = defineOutboxWorker( moduleKey: 'party.registry', role: 'worker', }), - leaseDurationMs: 30_000, payloadSchema: OutboxPayloadSchema, producerModuleKey: outboxProducerModuleKey, - retryPolicy: { - initialBackoffMs: 1000, - maxAttempts: 5, - maxBackoffMs: 60_000, - multiplier: 2, - }, topic: outboxTopic, - workerKey: 'party.registry.project-party-updated-to-search', }, - handleProjectPartyUpdatedToSearch, + { + spanName: 'ProjectPartyUpdatedToSearchWorker.handleProjectPartyUpdatedToSearch', + target: (payload) => ({ partyId: payload.partyRef.resourceId }), + }, ); diff --git a/app/verticals/party-registry/tests/integration/database-boundary.test.ts b/app/verticals/party-registry/tests/integration/database-boundary.test.ts index 48a36fd8d..98777e87e 100644 --- a/app/verticals/party-registry/tests/integration/database-boundary.test.ts +++ b/app/verticals/party-registry/tests/integration/database-boundary.test.ts @@ -3,16 +3,17 @@ import { runEffectTestPromise, runEffectTestSync as runNativeSync, } from '@app/core-runtime/testing/effect-runtime'; -import { findPostgresFailure, loadDatabaseConnectionPair } from '@app/core-runtime'; -import { DateTime, Effect, Exit as NativeExit, Scope as NativeScope, Option } from 'effect'; +import { DateTime, Effect, Exit as NativeExit, Scope as NativeScope } from 'effect'; // @effect-diagnostics asyncFunction:off globalDate:off -- Existing compatibility boundary; expires: 2026-12-31. import { and, eq, gt, inArray, isNull, lte, or, sql } from 'drizzle-orm'; import assert from 'node:assert/strict'; import test, { after as afterNativeDatabase } from 'node:test'; -import { Pool } from 'pg'; +import type { Pool } from 'pg'; import { makeTestDatabaseFromPool } from '../../../../packages/core-runtime/tests/support/database.ts'; +import { hasPostgreSqlCode, openBoundaryDatabases } from '../support/database-boundary.ts'; +import { purgeFixtureRows } from '../../../../packages/core-runtime/tests/support/fixture-cleanup.ts'; import { RuleKeySchema } from '../../shared/domain/matching-contracts.ts'; import { counterparties, @@ -41,6 +42,12 @@ afterNativeDatabase( NativeScope.close(nativeDatabaseScope, NativeExit.void).pipe(nativeTestCallback), ); +/** Both boundary roles read the same owned schema through the scope closed after these tests. */ +const openPartyDatabase = async (pool: Pool) => + await runEffectTestPromise( + makeTestDatabaseFromPool(pool, partyRelations).pipe(NativeScope.provide(nativeDatabaseScope)), + ); + const tenantA = 'a1000000-0000-4000-8000-000000000001'; const tenantB = 'a1000000-0000-4000-8000-000000000002'; const legalEntityA = 'a2000000-0000-4000-8000-000000000001'; @@ -64,97 +71,31 @@ const actionA = 'aa000000-0000-4000-8000-000000000001'; const principalA = 'ab000000-0000-4000-8000-000000000001'; const fixtureTenants = [tenantA, tenantB] as const; -const hasPostgreSqlCode = - (expected: string) => - (error: Parameters[0]): boolean => - Option.exists(findPostgresFailure(error), ({ code }) => code === expected); - test('enforces Party owner invariants, tenant isolation, and independent fact lifecycles', async () => { - const connections = await runEffectTestPromise(loadDatabaseConnectionPair()); - const adminPool = new Pool({ connectionString: connections.admin.connectionString }); - const runtimePool = new Pool({ connectionString: connections.runtime.connectionString, max: 1 }); - const admin = await runEffectTestPromise( - makeTestDatabaseFromPool(adminPool, partyRelations).pipe( - NativeScope.provide(nativeDatabaseScope), - ), - ); - const runtime = await runEffectTestPromise( - makeTestDatabaseFromPool(runtimePool, partyRelations).pipe( - NativeScope.provide(nativeDatabaseScope), - ), - ); + const { admin, adminPool, runtime, runtimePool } = await openBoundaryDatabases(openPartyDatabase); - const cleanup = async () => { - await runEffectTestPromise( - admin.delete(partyCorrections).where(inArray(partyCorrections.tenantId, fixtureTenants)), - ); - await runEffectTestPromise( - admin.delete(partyAliases).where(inArray(partyAliases.tenantId, fixtureTenants)), - ); - await runEffectTestPromise( - admin.delete(partyMerges).where(inArray(partyMerges.tenantId, fixtureTenants)), - ); - await runEffectTestPromise( - admin - .delete(partyMatchDecisions) - .where(inArray(partyMatchDecisions.tenantId, fixtureTenants)), - ); - await runEffectTestPromise( - admin - .delete(duplicateCandidateCaseParties) - .where(inArray(duplicateCandidateCaseParties.tenantId, fixtureTenants)), - ); - await runEffectTestPromise( - admin - .delete(duplicateCandidateCases) - .where(inArray(duplicateCandidateCases.tenantId, fixtureTenants)), - ); - await runEffectTestPromise( - admin - .delete(counterpartyRoleAdminReadModels) - .where(inArray(counterpartyRoleAdminReadModels.tenantId, fixtureTenants)), - ); - await runEffectTestPromise( - admin - .delete(counterpartyAdminReadModels) - .where(inArray(counterpartyAdminReadModels.tenantId, fixtureTenants)), - ); - await runEffectTestPromise( - admin - .delete(counterpartyRolePeriods) - .where(inArray(counterpartyRolePeriods.tenantId, fixtureTenants)), - ); - await runEffectTestPromise( - admin.delete(counterparties).where(inArray(counterparties.tenantId, fixtureTenants)), - ); - await runEffectTestPromise( - admin.delete(partyRelationships).where(inArray(partyRelationships.tenantId, fixtureTenants)), - ); - await runEffectTestPromise( - admin - .delete(partyContactPointPurposes) - .where(inArray(partyContactPointPurposes.tenantId, fixtureTenants)), - ); - await runEffectTestPromise( - admin.delete(partyContactPoints).where(inArray(partyContactPoints.tenantId, fixtureTenants)), - ); - await runEffectTestPromise( - admin - .delete(partyIdentifierClaims) - .where(inArray(partyIdentifierClaims.tenantId, fixtureTenants)), - ); - await runEffectTestPromise( - admin - .delete(partyOfficialIdentifiers) - .where(inArray(partyOfficialIdentifiers.tenantId, fixtureTenants)), - ); - await runEffectTestPromise( - admin - .delete(partyFactAssertions) - .where(inArray(partyFactAssertions.tenantId, fixtureTenants)), - ); - await runEffectTestPromise( - admin.delete(parties).where(inArray(parties.tenantId, fixtureTenants)), + // Ordered child-before-parent so every delete respects the owned foreign keys. + const cleanup = async (): Promise => { + await purgeFixtureRows( + [ + partyCorrections, + partyAliases, + partyMerges, + partyMatchDecisions, + duplicateCandidateCaseParties, + duplicateCandidateCases, + counterpartyRoleAdminReadModels, + counterpartyAdminReadModels, + counterpartyRolePeriods, + counterparties, + partyRelationships, + partyContactPointPurposes, + partyContactPoints, + partyIdentifierClaims, + partyOfficialIdentifiers, + partyFactAssertions, + parties, + ].map((table) => admin.delete(table).where(inArray(table.tenantId, fixtureTenants))), ); }; diff --git a/app/verticals/party-registry/tests/integration/engagement-database-boundary.test.ts b/app/verticals/party-registry/tests/integration/engagement-database-boundary.test.ts index 4a68bcade..231191f8d 100644 --- a/app/verticals/party-registry/tests/integration/engagement-database-boundary.test.ts +++ b/app/verticals/party-registry/tests/integration/engagement-database-boundary.test.ts @@ -3,16 +3,17 @@ import { runEffectTestPromise, runEffectTestSync as runNativeSync, } from '@app/core-runtime/testing/effect-runtime'; -import { findPostgresFailure, loadDatabaseConnectionPair } from '@app/core-runtime'; // @effect-diagnostics asyncFunction:off -- Existing compatibility boundary; expires: 2026-12-31. import { eq, inArray, sql } from 'drizzle-orm'; -import { Effect, Exit as NativeExit, Scope as NativeScope, Option } from 'effect'; +import { Effect, Exit as NativeExit, Scope as NativeScope } from 'effect'; import assert from 'node:assert/strict'; import test, { after as afterNativeDatabase } from 'node:test'; -import { Pool } from 'pg'; +import type { Pool } from 'pg'; import { makeTestDatabaseFromPool } from '../../../../packages/core-runtime/tests/support/database.ts'; +import { hasPostgreSqlCode, openBoundaryDatabases } from '../support/database-boundary.ts'; +import { purgeFixtureRows } from '../../../../packages/core-runtime/tests/support/fixture-cleanup.ts'; import { contactsRelations, organizationEngagementProfiles, @@ -24,39 +25,27 @@ afterNativeDatabase( NativeScope.close(nativeDatabaseScope, NativeExit.void).pipe(nativeTestCallback), ); +/** Both boundary roles read the same owned schema through the scope closed after these tests. */ +const openContactsDatabase = async (pool: Pool) => + await runEffectTestPromise( + makeTestDatabaseFromPool(pool, contactsRelations).pipe( + NativeScope.provide(nativeDatabaseScope), + ), + ); + const tenantA = 'c1000000-0000-4000-8000-000000000001'; const tenantB = 'c1000000-0000-4000-8000-000000000002'; const fixtureTenants = [tenantA, tenantB] as const; -const hasPostgreSqlCode = - (expected: string) => - (error: Parameters[0]): boolean => - Option.exists(findPostgresFailure(error), ({ code }) => code === expected); - test('enforces tenant isolation and canonical-reference uniqueness without cross-vertical FKs', async () => { - const connections = await runEffectTestPromise(loadDatabaseConnectionPair()); - const adminPool = new Pool({ connectionString: connections.admin.connectionString }); - const runtimePool = new Pool({ connectionString: connections.runtime.connectionString, max: 1 }); - const admin = await runEffectTestPromise( - makeTestDatabaseFromPool(adminPool, contactsRelations).pipe( - NativeScope.provide(nativeDatabaseScope), - ), - ); - const runtime = await runEffectTestPromise( - makeTestDatabaseFromPool(runtimePool, contactsRelations).pipe( - NativeScope.provide(nativeDatabaseScope), - ), - ); - const cleanup = async () => { - await runEffectTestPromise( - admin - .delete(personEngagementProfiles) - .where(inArray(personEngagementProfiles.tenantId, fixtureTenants)), - ); - await runEffectTestPromise( - admin - .delete(organizationEngagementProfiles) - .where(inArray(organizationEngagementProfiles.tenantId, fixtureTenants)), + const { admin, adminPool, runtime, runtimePool } = + await openBoundaryDatabases(openContactsDatabase); + // Ordered child-before-parent so every delete respects the owned foreign keys. + const cleanup = async (): Promise => { + await purgeFixtureRows( + [personEngagementProfiles, organizationEngagementProfiles].map((table) => + admin.delete(table).where(inArray(table.tenantId, fixtureTenants)), + ), ); }; diff --git a/app/verticals/party-registry/tests/integration/identity-concurrency.test.ts b/app/verticals/party-registry/tests/integration/identity-concurrency.test.ts index c6010707b..0c6466e92 100644 --- a/app/verticals/party-registry/tests/integration/identity-concurrency.test.ts +++ b/app/verticals/party-registry/tests/integration/identity-concurrency.test.ts @@ -5,13 +5,14 @@ import { } from '@app/core-runtime/testing/effect-runtime'; // @effect-diagnostics asyncFunction:off globalDate:off -- Existing compatibility boundary; expires: 2026-12-31. -import { loadDatabaseConnectionPair } from '@app/core-runtime'; import { eq, sql } from 'drizzle-orm'; import { DateTime, Effect, Exit as NativeExit, Scope as NativeScope } from 'effect'; import assert from 'node:assert/strict'; import test, { after as afterNativeDatabase } from 'node:test'; -import { Pool } from 'pg'; +import type { Pool } from 'pg'; import { makeTestDatabaseFromPool } from '../../../../packages/core-runtime/tests/support/database.ts'; +import { openBoundaryDatabases } from '../support/database-boundary.ts'; +import { purgeFixtureRows } from '../../../../packages/core-runtime/tests/support/fixture-cleanup.ts'; import { normalizeOfficialIdentifier } from '../../shared/domain/identifier-contracts.ts'; import { partySubjectKeyFromString } from '../../shared/domain/identity-contracts.ts'; import { @@ -37,45 +38,33 @@ afterNativeDatabase( NativeScope.close(nativeDatabaseScope, NativeExit.void).pipe(nativeTestCallback), ); +/** Both boundary roles read the same owned schema through the scope closed after these tests. */ +const openPartyDatabase = async (pool: Pool) => + await runEffectTestPromise( + makeTestDatabaseFromPool(pool, partyRelations).pipe(NativeScope.provide(nativeDatabaseScope)), + ); + const tenantId = 'bc100000-0000-4000-8000-000000000001'; const principalId = 'bc200000-0000-4000-8000-000000000001'; test('real PostgreSQL identity locks serialize concurrent exact creates and repeated identifier acceptance', async () => { - const connections = await runEffectTestPromise(loadDatabaseConnectionPair()); - const adminPool = new Pool({ connectionString: connections.admin.connectionString }); - const runtimePool = new Pool({ connectionString: connections.runtime.connectionString, max: 2 }); - const admin = await runEffectTestPromise( - makeTestDatabaseFromPool(adminPool, partyRelations).pipe( - NativeScope.provide(nativeDatabaseScope), - ), - ); - const runtime = await runEffectTestPromise( - makeTestDatabaseFromPool(runtimePool, partyRelations).pipe( - NativeScope.provide(nativeDatabaseScope), - ), + const { admin, adminPool, runtime, runtimePool } = await openBoundaryDatabases( + openPartyDatabase, + 2, ); - const cleanup = async () => { - await runEffectTestPromise( - admin.delete(partyMatchDecisions).where(eq(partyMatchDecisions.tenantId, tenantId)), - ); - await runEffectTestPromise( - admin - .delete(duplicateCandidateCaseParties) - .where(eq(duplicateCandidateCaseParties.tenantId, tenantId)), - ); - await runEffectTestPromise( - admin.delete(duplicateCandidateCases).where(eq(duplicateCandidateCases.tenantId, tenantId)), - ); - await runEffectTestPromise( - admin.delete(partyIdentifierClaims).where(eq(partyIdentifierClaims.tenantId, tenantId)), - ); - await runEffectTestPromise( - admin.delete(partyOfficialIdentifiers).where(eq(partyOfficialIdentifiers.tenantId, tenantId)), - ); - await runEffectTestPromise( - admin.delete(partyFactAssertions).where(eq(partyFactAssertions.tenantId, tenantId)), + // Ordered child-before-parent so every delete respects the owned foreign keys. + const cleanup = async (): Promise => { + await purgeFixtureRows( + [ + partyMatchDecisions, + duplicateCandidateCaseParties, + duplicateCandidateCases, + partyIdentifierClaims, + partyOfficialIdentifiers, + partyFactAssertions, + parties, + ].map((table) => admin.delete(table).where(eq(table.tenantId, tenantId))), ); - await runEffectTestPromise(admin.delete(parties).where(eq(parties.tenantId, tenantId))); }; const scoped = ( operation: (transaction: PartyTransaction) => Effect.Effect, diff --git a/app/verticals/party-registry/tests/support/command-assertion-fetch.ts b/app/verticals/party-registry/tests/support/command-assertion-fetch.ts new file mode 100644 index 000000000..defda385e --- /dev/null +++ b/app/verticals/party-registry/tests/support/command-assertion-fetch.ts @@ -0,0 +1,22 @@ +export const makeCommandAssertionFetch = ( + ownerResponse: (request: Request) => Response, + tokenPrefix: string, +) => { + const requests: Request[] = []; + let assertions = 0; + const fakeFetch: typeof fetch = (input, init) => { + const request = new Request(input, init); + requests.push(request); + if (new URL(request.url).hostname === 'shell.example') { + assertions += 1; + return Promise.resolve( + Response.json({ + expiresAt: 2_000_000_000, + token: `${tokenPrefix}-${assertions}`, + }), + ); + } + return Promise.resolve(ownerResponse(request)); + }; + return { assertions: () => assertions, fakeFetch, requests }; +}; diff --git a/app/verticals/party-registry/tests/support/database-boundary.ts b/app/verticals/party-registry/tests/support/database-boundary.ts new file mode 100644 index 000000000..2f7babdbb --- /dev/null +++ b/app/verticals/party-registry/tests/support/database-boundary.ts @@ -0,0 +1,41 @@ +import { runEffectTestPromise } from '@app/core-runtime/testing/effect-runtime'; +import { findPostgresFailure, loadDatabaseConnectionPair } from '@app/core-runtime'; +import { Effect, Option } from 'effect'; +import { Pool } from 'pg'; + +/** Asserts that PostgreSQL itself raised the expected SQLSTATE, not that a driver shape matched. */ +export const hasPostgreSqlCode = + (expected: string) => + (error: Parameters[0]): boolean => + Option.exists(findPostgresFailure(error), ({ code }) => code === expected); + +/** + * A database boundary proves two roles against one schema: an admin connection that seeds and + * purges fixtures, and a deliberately narrow runtime connection that shows what the runtime role + * may actually do. The caller owns the schema binding and the scope that closes both pools. + */ +export const openBoundaryDatabases = ( + openDatabase: (pool: Pool) => Promise, + runtimeConnections = 1, +): Promise<{ + readonly admin: Database; + readonly adminPool: Pool; + readonly runtime: Database; + readonly runtimePool: Pool; +}> => + runEffectTestPromise( + Effect.gen(function* () { + const connections = yield* loadDatabaseConnectionPair(); + const adminPool = new Pool({ connectionString: connections.admin.connectionString }); + const runtimePool = new Pool({ + connectionString: connections.runtime.connectionString, + max: runtimeConnections, + }); + return { + admin: yield* Effect.promise(() => openDatabase(adminPool)), + adminPool, + runtime: yield* Effect.promise(() => openDatabase(runtimePool)), + runtimePool, + }; + }), + ); diff --git a/app/verticals/party-registry/tests/unit/api-integration-command-client.test.ts b/app/verticals/party-registry/tests/unit/api-integration-command-client.test.ts index cc95fa23b..430d54d01 100644 --- a/app/verticals/party-registry/tests/unit/api-integration-command-client.test.ts +++ b/app/verticals/party-registry/tests/unit/api-integration-command-client.test.ts @@ -1,8 +1,9 @@ +import { makeCommandAssertionFetch } from '../support/command-assertion-fetch.ts'; import { runEffectTestPromise } from '@app/core-runtime/testing/effect-runtime'; // @effect-diagnostics asyncFunction:off -- Existing compatibility boundary; expires: 2026-12-31. import assert from 'node:assert/strict'; import test from 'node:test'; -import { Effect, Result } from 'effect'; +import { Effect } from 'effect'; import { FetchHttpClient } from 'effect/unstable/http'; import { requestSearchRebuild, @@ -10,21 +11,10 @@ import { } from '../../src/api/party-command-client.ts'; test('fresh assertions and command metadata reach the independent owner deployment', async () => { - const requests: Request[] = []; - let assertions = 0; - const fakeFetch: typeof fetch = (input, init) => { - const request = new Request(input, init); - requests.push(request); - if (new URL(request.url).hostname === 'shell.example') { - assertions += 1; - return Promise.resolve( - Response.json({ expiresAt: 2_000_000_000, token: `token-${assertions}` }), - ); - } - return Promise.resolve( - Response.json({ requestId: '10000000-0000-4000-8000-000000000001', status: 'QUEUED' }), - ); - }; + const { requests, assertions, fakeFetch } = makeCommandAssertionFetch( + () => Response.json({ requestId: '10000000-0000-4000-8000-000000000001', status: 'QUEUED' }), + 'token', + ); const options = { baseUrl: 'https://party.example/party-registry-api', correlationId: 'command-correlation', @@ -42,7 +32,7 @@ test('fresh assertions and command metadata reach the independent owner deployme const second = await invoke(); assert.equal(first.status, 'QUEUED'); assert.equal(second.status, 'QUEUED'); - assert.equal(assertions, 2); + assert.equal(assertions(), 2); const commands = requests.filter((request) => new URL(request.url).hostname === 'party.example'); assert.deepEqual( commands.map((request) => request.url), @@ -63,33 +53,6 @@ test('fresh assertions and command metadata reach the independent owner deployme } }); -test('decodes declared errors without weakening their tag or stable conflict code', async () => { - const problem = { - _tag: 'PartyCommandConflictProblem', - code: 'action_request_hash_conflict', - detail: 'This key was used with a different command payload.', - status: 409, - title: 'Idempotency conflict', - type: 'urn:ontos:action:request-hash-conflict', - }; - const fakeFetch: typeof fetch = () => - Promise.resolve( - Response.json(problem, { - headers: { 'content-type': 'application/problem+json' }, - status: 409, - }), - ); - const outcome = await runEffectTestPromise( - requestSearchRebuildWithAuthorization({}, 'Bearer test', { - baseUrl: 'https://party.example/party-registry-api', - correlationId: 'conflict', - idempotencyKey: 'rebuild-1', - }).pipe(Effect.result, Effect.provideService(FetchHttpClient.Fetch, fakeFetch)), - ); - assert.ok(Result.isFailure(outcome)); - assert.deepEqual(outcome.failure, problem); -}); - test('the browser default uses the relative mounted BFF prefix', async () => { const urls: string[] = []; const fakeFetch: typeof fetch = (input) => { diff --git a/app/verticals/party-registry/tests/unit/api-integration-command-recovery.test.ts b/app/verticals/party-registry/tests/unit/api-integration-command-recovery.test.ts index 8e7ece6fe..1e7c67f65 100644 --- a/app/verticals/party-registry/tests/unit/api-integration-command-recovery.test.ts +++ b/app/verticals/party-registry/tests/unit/api-integration-command-recovery.test.ts @@ -1,3 +1,4 @@ +import { makeCommandAssertionFetch } from '../support/command-assertion-fetch.ts'; import { runEffectTestPromise } from '@app/core-runtime/testing/effect-runtime'; // @effect-diagnostics asyncFunction:off -- Existing compatibility boundary; expires: 2026-12-31. import assert from 'node:assert/strict'; @@ -23,57 +24,49 @@ const invocationId = Schema.decodeUnknownSync(ActionInvocationIdSchema)( '10000000-0000-4000-8000-000000000001', ); -test('already committed is terminal and carries the invocation for governed refresh', () => { - const problem = { - _tag: 'PartyCommandAlreadyCommittedProblem', - code: 'action_already_committed', - detail: 'Refresh the authoritative governed reads.', - invocationId, - resolution: 'REFRESH_GOVERNED_READS', - retryCommand: false, - status: 409, - title: 'Already committed', - type: 'urn:ontos:party:already-committed', - }; - assert.deepEqual( - Schema.decodeUnknownSync(PartyCommandAlreadyCommittedProblemSchema)(problem), - problem, - ); - for (const endpoint of Object.values(partyRegistryCommandsApi.groups.partyCommands.endpoints)) { - assert.ok([...endpoint.error].some((schema) => Schema.is(schema)(problem))); - } - assert.throws(() => - Schema.decodeUnknownSync(PartyCommandAlreadyCommittedProblemSchema)({ - ...problem, - retryCommand: true, - }), - ); -}); +const recoveryProblems = [ + { + name: 'already committed is terminal and carries the invocation for governed refresh', + decode: Schema.decodeUnknownSync(PartyCommandAlreadyCommittedProblemSchema), + problem: { + _tag: 'PartyCommandAlreadyCommittedProblem', + code: 'action_already_committed', + detail: 'Refresh the authoritative governed reads.', + invocationId, + resolution: 'REFRESH_GOVERNED_READS', + retryCommand: false, + status: 409, + title: 'Already committed', + type: 'urn:ontos:party:already-committed', + }, + }, + { + name: 'commit uncertainty retains a resolution handle and never instructs blind command retry', + decode: Schema.decodeUnknownSync(PartyCommandCommitIndeterminateProblemSchema), + problem: { + _tag: 'PartyCommandCommitIndeterminateProblem', + detail: 'Resolve the invocation before deciding the next step.', + invocationId, + resolution: 'RESOLVE_COMMIT', + retryCommand: false, + status: 503, + title: 'Commit outcome unknown', + type: 'urn:ontos:party:commit-indeterminate', + }, + }, +]; -test('commit uncertainty retains a resolution handle and never instructs blind command retry', () => { - const problem = { - _tag: 'PartyCommandCommitIndeterminateProblem', - detail: 'Resolve the invocation before deciding the next step.', - invocationId, - resolution: 'RESOLVE_COMMIT', - retryCommand: false, - status: 503, - title: 'Commit outcome unknown', - type: 'urn:ontos:party:commit-indeterminate', - }; - assert.deepEqual( - Schema.decodeUnknownSync(PartyCommandCommitIndeterminateProblemSchema)(problem), - problem, - ); - for (const endpoint of Object.values(partyRegistryCommandsApi.groups.partyCommands.endpoints)) { - assert.ok([...endpoint.error].some((schema) => Schema.is(schema)(problem))); - } - assert.throws(() => - Schema.decodeUnknownSync(PartyCommandCommitIndeterminateProblemSchema)({ - ...problem, - retryCommand: true, - }), - ); +for (const { name, decode, problem } of recoveryProblems) { + test(name, () => { + assert.deepEqual(decode(problem), problem); + for (const endpoint of Object.values(partyRegistryCommandsApi.groups.partyCommands.endpoints)) { + assert.ok([...endpoint.error].some((schema) => Schema.is(schema)(problem))); + } + assert.throws(() => decode({ ...problem, retryCommand: true })); + }); +} + +test('recovery rejects an invalid invocation handle', () => { assert.throws(() => Schema.decodeUnknownSync(ResolvePartyCommandCommitPayloadSchema)({ invocationId: 'invalid' }), ); @@ -96,86 +89,51 @@ test('recovery is separate from the unchanged set of explicit mutation endpoints } }); -test('the command client decodes indeterminate commits without losing recovery metadata', async () => { - const problem = { - _tag: 'PartyCommandCommitIndeterminateProblem', - detail: 'Resolve first.', - invocationId, - resolution: 'RESOLVE_COMMIT', - retryCommand: false, - status: 503, - title: 'Unknown commit', - type: 'urn:ontos:party:commit-indeterminate', - }; - const fakeFetch: typeof fetch = () => - Promise.resolve( - Response.json(problem, { - headers: { 'content-type': 'application/problem+json' }, - status: 503, - }), - ); - const result = await runEffectTestPromise( - requestSearchRebuildWithAuthorization({}, 'Bearer test', { - baseUrl: 'https://party.example/party-registry-api', - correlationId: 'uncertain', - idempotencyKey: 'same-key', - }).pipe(Effect.result, Effect.provideService(FetchHttpClient.Fetch, fakeFetch)), - ); - assert.ok(Result.isFailure(result)); - assert.deepEqual(result.failure, problem); -}); - -test('the command client preserves committed invocation metadata across HTTP', async () => { - const problem = { - _tag: 'PartyCommandAlreadyCommittedProblem', - code: 'action_already_committed', - detail: 'Refresh the authoritative governed reads.', - invocationId, - resolution: 'REFRESH_GOVERNED_READS', - retryCommand: false, - status: 409, - title: 'Already committed', - type: 'urn:ontos:party:already-committed', - }; - const fakeFetch: typeof fetch = () => - Promise.resolve( - Response.json(problem, { - headers: { 'content-type': 'application/problem+json' }, - status: 409, - }), +for (const { name, problem } of [ + ...recoveryProblems, + { + name: 'declared conflict retains its tag and stable conflict code', + problem: { + _tag: 'PartyCommandConflictProblem', + code: 'action_request_hash_conflict', + detail: 'This key was used with a different command payload.', + status: 409, + title: 'Idempotency conflict', + type: 'urn:ontos:action:request-hash-conflict', + }, + }, +]) { + test(`command client HTTP decoding: ${name}`, async () => { + const fakeFetch: typeof fetch = () => + Promise.resolve( + Response.json(problem, { + headers: { 'content-type': 'application/problem+json' }, + status: problem.status, + }), + ); + const result = await runEffectTestPromise( + requestSearchRebuildWithAuthorization({}, 'Bearer test', { + baseUrl: 'https://party.example/party-registry-api', + correlationId: 'problem-decoding', + idempotencyKey: 'same-key', + }).pipe(Effect.result, Effect.provideService(FetchHttpClient.Fetch, fakeFetch)), ); - const result = await runEffectTestPromise( - requestSearchRebuildWithAuthorization({}, 'Bearer test', { - baseUrl: 'https://party.example/party-registry-api', - correlationId: 'committed', - idempotencyKey: 'same-key', - }).pipe(Effect.result, Effect.provideService(FetchHttpClient.Fetch, fakeFetch)), - ); - assert.ok(Result.isFailure(result)); - assert.deepEqual(result.failure, problem); -}); + assert.ok(Result.isFailure(result)); + assert.deepEqual(result.failure, problem); + }); +} test('recovery acquires a fresh assertion without submitting an idempotency key or re-running a command', async () => { - const requests: Request[] = []; - let assertions = 0; - const fakeFetch: typeof fetch = (input, init) => { - const request = new Request(input, init); - requests.push(request); - if (new URL(request.url).hostname === 'shell.example') { - assertions += 1; - return Promise.resolve( - Response.json({ expiresAt: 2_000_000_000, token: `fresh-${assertions}` }), - ); - } - return Promise.resolve( + const { requests, assertions, fakeFetch } = makeCommandAssertionFetch( + () => Response.json({ _tag: 'PartyCommandCommitResolution', invocationId, retryCommand: false, state: 'COMMITTED', }), - ); - }; + 'fresh', + ); const result = await runEffectTestPromise( resolvePartyCommandCommit( { invocationId }, @@ -188,7 +146,7 @@ test('recovery acquires a fresh assertion without submitting an idempotency key ).pipe(Effect.provideService(FetchHttpClient.Fetch, fakeFetch)), ); assert.equal(result.state, 'COMMITTED'); - assert.equal(assertions, 1); + assert.equal(assertions(), 1); assert.equal(requests.length, 2); const [, request] = requests; assert.ok(request); @@ -206,8 +164,6 @@ test('recovery acquires a fresh assertion without submitting an idempotency key test('Create recovery resolves commit and returns exact original operation result with fresh read authority', async () => { await Promise.all( (['CREATED', 'MATCHED_EXISTING', 'AMBIGUOUS'] as const).map(async (outcome) => { - const requests: Request[] = []; - let assertions = 0; const partyRef = { moduleId: 'party.registry', resourceId: invocationId, @@ -216,38 +172,27 @@ test('Create recovery resolves commit and returns exact original operation resul }; const decisionRef = { ...partyRef, resourceType: 'party.registry.party-match-decision' }; const caseRef = { ...partyRef, resourceType: 'party.registry.duplicate-candidate-case' }; - const fakeFetch: typeof fetch = (input, init) => { - const request = new Request(input, init); - requests.push(request); - if (new URL(request.url).hostname === 'shell.example') { - return Promise.resolve( - Response.json({ expiresAt: 2_000_000_000, token: `fresh-${(assertions += 1)}` }), - ); - } + const { requests, assertions, fakeFetch } = makeCommandAssertionFetch((request) => { if (request.url.endsWith('/resolve')) { - return Promise.resolve( - Response.json({ - _tag: 'PartyCommandCommitResolution', - invocationId, - retryCommand: false, - state: 'COMMITTED', - }), - ); + return Response.json({ + _tag: 'PartyCommandCommitResolution', + invocationId, + retryCommand: false, + state: 'COMMITTED', + }); } - return Promise.resolve( - Response.json({ - caseRef: outcome === 'AMBIGUOUS' ? caseRef : null, - committedCreateOutcome: outcome, - decidedAt: '2026-09-04T00:00:00Z', - decisionRef, - evidenceExplanation: [], - matchRuleVersion: 'party-exact-claims.v1', - operation: 'CREATE', - outcome: outcome === 'MATCHED_EXISTING' ? 'MATCHED' : outcome, - partyRef: outcome === 'AMBIGUOUS' ? null : partyRef, - }), - ); - }; + return Response.json({ + caseRef: outcome === 'AMBIGUOUS' ? caseRef : null, + committedCreateOutcome: outcome, + decidedAt: '2026-09-04T00:00:00Z', + decisionRef, + evidenceExplanation: [], + matchRuleVersion: 'party-exact-claims.v1', + operation: 'CREATE', + outcome: outcome === 'MATCHED_EXISTING' ? 'MATCHED' : outcome, + partyRef: outcome === 'AMBIGUOUS' ? null : partyRef, + }); + }, 'fresh'); const recovered = await runEffectTestPromise( recoverPartyCreate( { invocationId }, @@ -266,7 +211,7 @@ test('Create recovery resolves commit and returns exact original operation resul Match.exhaustive, ); assert.equal(recoveredResult.outcome, outcome); - assert.equal(assertions, 2); + assert.equal(assertions(), 2); assert.equal(requests.length, 4); assert.ok( requests.every( diff --git a/app/verticals/party-registry/tests/unit/api-integration-command-runtime.test.ts b/app/verticals/party-registry/tests/unit/api-integration-command-runtime.test.ts index 94e3ea3d5..f43f91074 100644 --- a/app/verticals/party-registry/tests/unit/api-integration-command-runtime.test.ts +++ b/app/verticals/party-registry/tests/unit/api-integration-command-runtime.test.ts @@ -1,5 +1,6 @@ // @effect-diagnostics asyncFunction:off nodeBuiltinImport:off -- Existing compatibility boundary; expires: 2026-12-31. import assert from 'node:assert/strict'; +import { runEffectTestPromise } from '@app/core-runtime/testing/effect-runtime'; import { randomUUID } from 'node:crypto'; import test from 'node:test'; import { ConfigProvider, Context, Effect, Layer, Logger, Schema } from 'effect'; @@ -336,6 +337,18 @@ const emptyRequestContext = Context.makeUnsafe(new Map()); const handle = (app: ReturnType, request: Request) => app.handler(request, emptyRequestContext); +const withMountedApp = ( + app: ReturnType, + run: (app: ReturnType) => Promise, +): Promise => + runEffectTestPromise( + Effect.acquireUseRelease( + Effect.succeed(app), + (resource) => Effect.promise(() => run(resource)), + (resource) => Effect.promise(() => resource.dispose()), + ), + ); + const forEachSequential = ( items: Iterable, run: (item: Item) => Promise, @@ -419,8 +432,7 @@ const engagementRequest = (path: string, payload: EngagementTestPayload, token: test('every registered command is mounted and rejects missing structural input or authentication before the lifecycle', async () => { const assertion = await makeAssertion(); const harness = makeActionTestHarness(); - const app = mounted(harness, assertion.environment); - try { + await withMountedApp(mounted(harness, assertion.environment), async (app) => { assert.equal(Object.keys(partyRegistryApi.groups.partyCommands.endpoints).length, 24); assert.deepEqual( Object.keys(partyRegistryApi.groups.partyCommands.endpoints).toSorted(), @@ -483,9 +495,7 @@ test('every registered command is mounted and rejects missing structural input o Schema.is(Schema.TaggedStruct('PartyCommandInvalidRequestProblem', {}))(malformedBody), ); assert.equal(harness.snapshot().invocations.length, 0); - } finally { - await app.dispose(); - } + }); }); test('missing, malformed, expired, tampered, wrong-audience, and wrong-issuer assertions are challenged without creating invocations', async () => { @@ -511,8 +521,7 @@ test('missing, malformed, expired, tampered, wrong-audience, and wrong-issuer as ]; await forEachSequential(cases, async ({ assertion, token }) => { const harness = makeActionTestHarness(); - const app = mounted(harness, assertion.environment); - try { + await withMountedApp(mounted(harness, assertion.environment), async (app) => { const response = await handle( app, commandRequest('request-search-rebuild', {}, token, { @@ -527,9 +536,7 @@ test('missing, malformed, expired, tampered, wrong-audience, and wrong-issuer as assert.equal(body.status, 401); assert.equal(JSON.stringify(body).includes(assertion.token), false); assert.equal(harness.snapshot().invocations.length, 0); - } finally { - await app.dispose(); - } + }); }); }); @@ -543,8 +550,7 @@ test('missing and malformed verification configuration are retryable and never r ], async (environment) => { const harness = makeActionTestHarness(); - const app = mounted(harness, environment); - try { + await withMountedApp(mounted(harness, environment), async (app) => { const response = await handle( app, commandRequest('request-search-rebuild', {}, assertion.token, { @@ -558,9 +564,7 @@ test('missing and malformed verification configuration are retryable and never r assert.ok(Schema.is(Schema.TaggedStruct('PartyCommandUnavailableProblem', {}))(body)); assert.equal(body.retryable, true); assert.equal(harness.snapshot().invocations.length, 0); - } finally { - await app.dispose(); - } + }); }, ); }); @@ -651,8 +655,7 @@ test('generated governed reads authenticate through the shared adapter before st ), ), }; - const app = mounted(harness, assertion.environment, readRuntime); - try { + await withMountedApp(mounted(harness, assertion.environment, readRuntime), async (app) => { await forEachSequential([undefined, 'not-a-jwt'], async (token) => { const response = await handle(app, decisionRequest(randomUUID(), token)); assert.equal(response.status, 401); @@ -674,12 +677,9 @@ test('generated governed reads authenticate through the shared adapter before st assert.equal(valid.status, 404); assert.equal(reads, 1); assert.deepEqual(receivedPrincipals, [principal]); - } finally { - await app.dispose(); - } + }); - const unavailableApp = mounted(harness, {}, readRuntime); - try { + await withMountedApp(mounted(harness, {}, readRuntime), async (unavailableApp) => { const unavailable = await handle( unavailableApp, decisionRequest(randomUUID(), assertion.otherToken), @@ -690,9 +690,7 @@ test('generated governed reads authenticate through the shared adapter before st assert.ok(Schema.is(Schema.TaggedStruct('PartyMatchDecisionUnavailableProblem', {}))(body)); assert.equal(body.retryable, true); assert.equal(reads, 1); - } finally { - await unavailableApp.dispose(); - } + }); }); test('the complete generated governed Read seam maps every Core failure to its declared HTTP problem', async () => { @@ -808,29 +806,29 @@ test('the complete generated governed Read seam maps every Core failure to its d return Effect.fail(failure); }, }; - const app = mounted(makeActionTestHarness(), assertion.environment, readRuntime); - try { - await forEachSequential(cases, async ([nextFailure, expectedStatus, expectedTag]) => { - failure = nextFailure; - const response = await handle(app, decisionRequest(randomUUID(), assertion.token)); - assert.equal(response.status, expectedStatus, nextFailure._tag); - assert.match(response.headers.get('content-type') ?? '', /application\/problem\+json/u); - assert.equal( - response.headers.get('www-authenticate'), - expectedStatus === 401 ? 'Bearer' : null, - ); - const body = await response.json(); - assert.ok(Schema.is(Schema.TaggedStruct(expectedTag, {}))(body), nextFailure._tag); - assert.equal(body.status, expectedStatus, nextFailure._tag); - assert.equal(JSON.stringify(body).includes(reason), false, nextFailure._tag); - if (expectedStatus === 503) { - assert.equal(body.retryable, true, nextFailure._tag); - } - }); - assert.equal(reads, cases.length); - } finally { - await app.dispose(); - } + await withMountedApp( + mounted(makeActionTestHarness(), assertion.environment, readRuntime), + async (app) => { + await forEachSequential(cases, async ([nextFailure, expectedStatus, expectedTag]) => { + failure = nextFailure; + const response = await handle(app, decisionRequest(randomUUID(), assertion.token)); + assert.equal(response.status, expectedStatus, nextFailure._tag); + assert.match(response.headers.get('content-type') ?? '', /application\/problem\+json/u); + assert.equal( + response.headers.get('www-authenticate'), + expectedStatus === 401 ? 'Bearer' : null, + ); + const body = await response.json(); + assert.ok(Schema.is(Schema.TaggedStruct(expectedTag, {}))(body), nextFailure._tag); + assert.equal(body.status, expectedStatus, nextFailure._tag); + assert.equal(JSON.stringify(body).includes(reason), false, nextFailure._tag); + if (expectedStatus === 503) { + assert.equal(body.retryable, true, nextFailure._tag); + } + }); + assert.equal(reads, cases.length); + }, + ); }); test('the generated governed Read seam sanitizes unexpected runtime defects', async () => { @@ -838,18 +836,18 @@ test('the generated governed Read seam sanitizes unexpected runtime defects', as const readRuntime: ReadRuntimeService = { runRead: () => Effect.die('private governed Read defect'), }; - const app = mounted(makeActionTestHarness(), assertion.environment, readRuntime); - try { - const response = await handle(app, decisionRequest(randomUUID(), assertion.token)); - assert.equal(response.status, 500); - assert.match(response.headers.get('content-type') ?? '', /application\/problem\+json/u); - const body = await response.json(); - assert.ok(Schema.is(Schema.TaggedStruct('PartyMatchDecisionInternalProblem', {}))(body)); - assert.equal(body.status, 500); - assert.equal(JSON.stringify(body).includes('private'), false); - } finally { - await app.dispose(); - } + await withMountedApp( + mounted(makeActionTestHarness(), assertion.environment, readRuntime), + async (app) => { + const response = await handle(app, decisionRequest(randomUUID(), assertion.token)); + assert.equal(response.status, 500); + assert.match(response.headers.get('content-type') ?? '', /application\/problem\+json/u); + const body = await response.json(); + assert.ok(Schema.is(Schema.TaggedStruct('PartyMatchDecisionInternalProblem', {}))(body)); + assert.equal(body.status, 500); + assert.equal(JSON.stringify(body).includes('private'), false); + }, + ); }); test('replayed assertions are challenged before a second Action or generated Read lifecycle', async () => { @@ -871,48 +869,48 @@ test('replayed assertions are challenged before a second Action or generated Rea ), ), }; - const app = mounted(harness, assertion.environment, readRuntime, makeSingleUseRedemption()); - try { - const firstAction = await handle( - app, - commandRequest('request-search-rebuild', {}, assertion.token, { - 'idempotency-key': 'first-redemption', - }), - ); - assert.notEqual(firstAction.status, 401); - assert.equal(harness.snapshot().invocations.length, 1); + await withMountedApp( + mounted(harness, assertion.environment, readRuntime, makeSingleUseRedemption()), + async (app) => { + const firstAction = await handle( + app, + commandRequest('request-search-rebuild', {}, assertion.token, { + 'idempotency-key': 'first-redemption', + }), + ); + assert.notEqual(firstAction.status, 401); + assert.equal(harness.snapshot().invocations.length, 1); - const replayedAction = await handle( - app, - commandRequest('request-search-rebuild', {}, assertion.token, { - 'idempotency-key': 'second-redemption', - }), - ); - assert.equal(replayedAction.status, 401); - assert.equal(replayedAction.headers.get('www-authenticate'), 'Bearer'); - assert.match(replayedAction.headers.get('content-type') ?? '', /application\/problem\+json/u); - assert.equal(harness.snapshot().invocations.length, 1); + const replayedAction = await handle( + app, + commandRequest('request-search-rebuild', {}, assertion.token, { + 'idempotency-key': 'second-redemption', + }), + ); + assert.equal(replayedAction.status, 401); + assert.equal(replayedAction.headers.get('www-authenticate'), 'Bearer'); + assert.match(replayedAction.headers.get('content-type') ?? '', /application\/problem\+json/u); + assert.equal(harness.snapshot().invocations.length, 1); - const actionAssertionReadReplay = await handle( - app, - decisionRequest(randomUUID(), assertion.token), - ); - assert.equal(actionAssertionReadReplay.status, 401); - assert.equal(actionAssertionReadReplay.headers.get('www-authenticate'), 'Bearer'); - assert.equal(reads, 0); + const actionAssertionReadReplay = await handle( + app, + decisionRequest(randomUUID(), assertion.token), + ); + assert.equal(actionAssertionReadReplay.status, 401); + assert.equal(actionAssertionReadReplay.headers.get('www-authenticate'), 'Bearer'); + assert.equal(reads, 0); - const firstRead = await handle(app, decisionRequest(randomUUID(), assertion.otherToken)); - assert.equal(firstRead.status, 404); - assert.equal(reads, 1); + const firstRead = await handle(app, decisionRequest(randomUUID(), assertion.otherToken)); + assert.equal(firstRead.status, 404); + assert.equal(reads, 1); - const replayedRead = await handle(app, decisionRequest(randomUUID(), assertion.otherToken)); - assert.equal(replayedRead.status, 401); - assert.equal(replayedRead.headers.get('www-authenticate'), 'Bearer'); - assert.match(replayedRead.headers.get('content-type') ?? '', /application\/problem\+json/u); - assert.equal(reads, 1); - } finally { - await app.dispose(); - } + const replayedRead = await handle(app, decisionRequest(randomUUID(), assertion.otherToken)); + assert.equal(replayedRead.status, 401); + assert.equal(replayedRead.headers.get('www-authenticate'), 'Bearer'); + assert.match(replayedRead.headers.get('content-type') ?? '', /application\/problem\+json/u); + assert.equal(reads, 1); + }, + ); }); test('correlation and idempotency are mandatory before the Core Action lifecycle', async () => { @@ -926,55 +924,51 @@ test('correlation and idempotency are mandatory before the Core Action lifecycle return harness.runtime.runAction(input); }, }; - const app = mounted( - harness, - assertion.environment, - undefined, - nonPersistingRedemption, - observingRuntime, + await withMountedApp( + mounted(harness, assertion.environment, undefined, nonPersistingRedemption, observingRuntime), + async (app) => { + const missingKey = await handle( + app, + commandRequest('request-search-rebuild', {}, assertion.token), + ); + assert.equal(missingKey.status, 428); + const missingKeyBody = await missingKey.json(); + assert.ok( + Schema.is(Schema.TaggedStruct('PartyCommandPreconditionRequiredProblem', {}))( + missingKeyBody, + ), + ); + assert.equal(runtimeCalls, 1); + const missingCorrelation = await handle( + app, + commandRequest('request-search-rebuild', {}, assertion.token, { + 'idempotency-key': 'correlation-test', + 'x-correlation-id': '', + }), + ); + assert.equal(missingCorrelation.status, 400); + const missingCorrelationBody = await missingCorrelation.json(); + assert.ok( + Schema.is(Schema.TaggedStruct('PartyCommandInvalidRequestProblem', {}))( + missingCorrelationBody, + ), + ); + assert.equal(runtimeCalls, 1); + const oversizedCorrelation = await handle( + app, + commandRequest('request-search-rebuild', {}, assertion.token, { + 'idempotency-key': 'oversized-correlation-test', + 'x-correlation-id': 'x'.repeat(201), + }), + ); + assert.equal(oversizedCorrelation.status, 400); + Schema.decodeUnknownSync(PartyCommandInvalidRequestProblemSchema)( + await oversizedCorrelation.json(), + ); + assert.equal(runtimeCalls, 1); + assert.equal(harness.snapshot().invocations.length, 0); + }, ); - try { - const missingKey = await handle( - app, - commandRequest('request-search-rebuild', {}, assertion.token), - ); - assert.equal(missingKey.status, 428); - const missingKeyBody = await missingKey.json(); - assert.ok( - Schema.is(Schema.TaggedStruct('PartyCommandPreconditionRequiredProblem', {}))(missingKeyBody), - ); - assert.equal(runtimeCalls, 1); - const missingCorrelation = await handle( - app, - commandRequest('request-search-rebuild', {}, assertion.token, { - 'idempotency-key': 'correlation-test', - 'x-correlation-id': '', - }), - ); - assert.equal(missingCorrelation.status, 400); - const missingCorrelationBody = await missingCorrelation.json(); - assert.ok( - Schema.is(Schema.TaggedStruct('PartyCommandInvalidRequestProblem', {}))( - missingCorrelationBody, - ), - ); - assert.equal(runtimeCalls, 1); - const oversizedCorrelation = await handle( - app, - commandRequest('request-search-rebuild', {}, assertion.token, { - 'idempotency-key': 'oversized-correlation-test', - 'x-correlation-id': 'x'.repeat(201), - }), - ); - assert.equal(oversizedCorrelation.status, 400); - Schema.decodeUnknownSync(PartyCommandInvalidRequestProblemSchema)( - await oversizedCorrelation.json(), - ); - assert.equal(runtimeCalls, 1); - assert.equal(harness.snapshot().invocations.length, 0); - } finally { - await app.dispose(); - } }); test('the governed runner passes safe transport metadata through one complete Action execution', async () => { @@ -984,8 +978,7 @@ test('the governed runner passes safe transport metadata through one complete Ac actionPermission: 'allowed', tenantPermission: 'allowed', }); - const app = mounted(harness, assertion.environment); - try { + await withMountedApp(mounted(harness, assertion.environment), async (app) => { const response = await handle( app, commandRequest('request-search-rebuild', {}, assertion.token, { @@ -1006,9 +999,7 @@ test('the governed runner passes safe transport metadata through one complete Ac }); assert.deepEqual(snapshot.committed[0]?.principal, principal); assert.equal(snapshot.committed[0]?.actionKey, 'party.registry.request-search-rebuild'); - } finally { - await app.dispose(); - } + }); }); test('a decoded relationship timestamp reaches the Action runtime exactly once', async () => { @@ -1026,27 +1017,21 @@ test('a decoded relationship timestamp reaches the Action runtime exactly once', return Effect.fail(failure); }, }; - const app = mounted( - harness, - assertion.environment, - undefined, - nonPersistingRedemption, - actionRuntime, + await withMountedApp( + mounted(harness, assertion.environment, undefined, nonPersistingRedemption, actionRuntime), + async (app) => { + const response = await handle( + app, + commandRequest('create-party-relationship', relationshipPayload, assertion.token, { + 'idempotency-key': 'relationship-timestamp-test', + }), + ); + assert.equal(response.status, 400); + const body = await response.json(); + assert.equal(body._tag, 'PartyCommandInvalidRequestProblem'); + assert.equal(runtimeCalls, 1); + }, ); - try { - const response = await handle( - app, - commandRequest('create-party-relationship', relationshipPayload, assertion.token, { - 'idempotency-key': 'relationship-timestamp-test', - }), - ); - assert.equal(response.status, 400); - const body = await response.json(); - assert.equal(body._tag, 'PartyCommandInvalidRequestProblem'); - assert.equal(runtimeCalls, 1); - } finally { - await app.dispose(); - } }); test('an unexpected runtime defect is sanitized by the governed outer HTTP seam', async () => { @@ -1057,39 +1042,39 @@ test('an unexpected runtime defect is sanitized by the governed outer HTTP seam' runAction: () => Effect.die('private governed runner defect'), }; const observedLogs: string[] = []; - const app = mounted( - harness, - assertion.environment, - undefined, - nonPersistingRedemption, - defectiveRuntime, - observedLogs, + await withMountedApp( + mounted( + harness, + assertion.environment, + undefined, + nonPersistingRedemption, + defectiveRuntime, + observedLogs, + ), + async (app) => { + const response = await handle( + app, + commandRequest('request-search-rebuild', {}, assertion.token, { + 'idempotency-key': 'runner-defect-test', + }), + ); + assert.equal(response.status, 500); + const body = await response.json(); + assert.equal(body._tag, 'PartyCommandInternalProblem'); + assert.equal(body.status, 500); + assert.equal(JSON.stringify(body).includes('private governed runner defect'), false); + assert.equal(harness.snapshot().invocations.length, 0); + assert.equal(observedLogs.length, 1); + const [entry] = observedLogs; + assert.ok(entry); + assert.match(entry, /Unexpected governed Action HTTP defect/u); + assert.match(entry, /private governed runner defect/u); + assert.match(entry, /party\.registry\.request-search-rebuild/u); + assert.match(entry, /party-command-test/u); + assert.doesNotMatch(entry, new RegExp(assertion.token, 'u')); + assert.doesNotMatch(entry, /runner-defect-test/u); + }, ); - try { - const response = await handle( - app, - commandRequest('request-search-rebuild', {}, assertion.token, { - 'idempotency-key': 'runner-defect-test', - }), - ); - assert.equal(response.status, 500); - const body = await response.json(); - assert.equal(body._tag, 'PartyCommandInternalProblem'); - assert.equal(body.status, 500); - assert.equal(JSON.stringify(body).includes('private governed runner defect'), false); - assert.equal(harness.snapshot().invocations.length, 0); - assert.equal(observedLogs.length, 1); - const [entry] = observedLogs; - assert.ok(entry); - assert.match(entry, /Unexpected governed Action HTTP defect/u); - assert.match(entry, /private governed runner defect/u); - assert.match(entry, /party\.registry\.request-search-rebuild/u); - assert.match(entry, /party-command-test/u); - assert.doesNotMatch(entry, new RegExp(assertion.token, 'u')); - assert.doesNotMatch(entry, /runner-defect-test/u); - } finally { - await app.dispose(); - } }); test('the endpoint-owned mapper preserves representative Core failure semantics', async () => { @@ -1200,26 +1185,20 @@ test('the endpoint-owned mapper preserves representative Core failure semantics' resolveActionCommit: harness.runtime.resolveActionCommit, runAction: () => Effect.fail(failure), }; - const app = mounted( - harness, - assertion.environment, - undefined, - nonPersistingRedemption, - failingRuntime, + await withMountedApp( + mounted(harness, assertion.environment, undefined, nonPersistingRedemption, failingRuntime), + async (app) => { + const response = await handle( + app, + commandRequest('request-search-rebuild', {}, assertion.token, { + 'idempotency-key': `mapping-${failure._tag}`, + }), + ); + assert.equal(response.status, expectedStatus, failure._tag); + const body = await response.json(); + assert.equal(body._tag, expectedTag, failure._tag); + }, ); - try { - const response = await handle( - app, - commandRequest('request-search-rebuild', {}, assertion.token, { - 'idempotency-key': `mapping-${failure._tag}`, - }), - ); - assert.equal(response.status, expectedStatus, failure._tag); - const body = await response.json(); - assert.equal(body._tag, expectedTag, failure._tag); - } finally { - await app.dispose(); - } }); }); @@ -1271,8 +1250,7 @@ test('real Core permission denial is a durable 403 and does not execute the comm actionPermission: 'denied', tenantPermission: 'allowed', }); - const app = mounted(harness, assertion.environment); - try { + await withMountedApp(mounted(harness, assertion.environment), async (app) => { const response = await handle( app, commandRequest('request-search-rebuild', {}, assertion.token, { @@ -1284,9 +1262,7 @@ test('real Core permission denial is a durable 403 and does not execute the comm assert.ok(Schema.is(Schema.TaggedStruct('PartyCommandForbiddenProblem', {}))(body)); assert.equal(harness.snapshot().invocations.length, 1); assert.equal(harness.snapshot().permissionDenials.length, 1); - } finally { - await app.dispose(); - } + }); }); test('the real handler translates domain conflicts and rolls back without successful evidence', async () => { @@ -1300,8 +1276,7 @@ test('the real handler translates domain conflicts and rolls back without succes }), ], }); - const app = mounted(harness, assertion.environment); - try { + await withMountedApp(mounted(harness, assertion.environment), async (app) => { const response = await handle( app, commandRequest('archive-party', archivePayload, assertion.token, { @@ -1314,9 +1289,7 @@ test('the real handler translates domain conflicts and rolls back without succes assert.equal(body.code, 'party_lifecycle_conflict'); assert.equal(harness.snapshot().invocations.length, 1); assert.equal(harness.snapshot().committed.length, 0); - } finally { - await app.dispose(); - } + }); }); test('alias conflicts preserve only safe canonical recovery metadata', async () => { @@ -1339,8 +1312,7 @@ test('alias conflicts preserve only safe canonical recovery metadata', async () }), ], }); - const app = mounted(harness, assertion.environment); - try { + await withMountedApp(mounted(harness, assertion.environment), async (app) => { const response = await handle( app, commandRequest('archive-party', archivePayload, assertion.token, { @@ -1354,9 +1326,7 @@ test('alias conflicts preserve only safe canonical recovery metadata', async () assert.deepEqual(body.canonicalPartyRef, canonicalPartyRef); assert.equal(JSON.stringify(body).includes('Private diagnostic'), false); assert.equal(harness.snapshot().committed.length, 0); - } finally { - await app.dispose(); - } + }); }); test('committed request replay stays a terminal 409 and does not execute or emit twice', async () => { @@ -1365,8 +1335,7 @@ test('committed request replay stays a terminal 409 and does not execute or emit actionPermission: 'allowed', tenantPermission: 'allowed', }); - const app = mounted(harness, assertion.environment); - try { + await withMountedApp(mounted(harness, assertion.environment), async (app) => { const first = await handle( app, commandRequest('request-search-rebuild', {}, assertion.token, { @@ -1393,9 +1362,7 @@ test('committed request replay stays a terminal 409 and does not execute or emit assert.equal(body.resolution, 'REFRESH_GOVERNED_READS'); assert.equal(harness.snapshot().invocations.length, 1); assert.deepEqual(harness.snapshot().committed, committed); - } finally { - await app.dispose(); - } + }); }); test('declared not-found, capability-unavailable and unexpected defects retain safe distinct HTTP statuses', async () => { @@ -1435,8 +1402,7 @@ test('declared not-found, capability-unavailable and unexpected defects retain s tenantPermission: 'allowed', services: [item.service], }); - const app = mounted(harness, assertion.environment); - try { + await withMountedApp(mounted(harness, assertion.environment), async (app) => { const response = await handle( app, commandRequest('archive-party', archivePayload, assertion.token, { @@ -1453,9 +1419,7 @@ test('declared not-found, capability-unavailable and unexpected defects retain s assert.equal(body.retryable, true); } assert.equal(harness.snapshot().committed.length, 0); - } finally { - await app.dispose(); - } + }); }); }); @@ -1476,8 +1440,7 @@ test('semantically insufficient Party evidence is a declared 422, not a server d }), ], }); - const app = mounted(harness, assertion.environment); - try { + await withMountedApp(mounted(harness, assertion.environment), async (app) => { const response = await handle( app, commandRequest('create-party', createPayload, assertion.token, { @@ -1490,9 +1453,7 @@ test('semantically insufficient Party evidence is a declared 422, not a server d assert.equal(body.status, 422); assert.equal(JSON.stringify(body).includes('Private evidence'), false); assert.equal(harness.snapshot().committed.length, 0); - } finally { - await app.dispose(); - } + }); }); test('the Core request hash rejects reuse of an idempotency key for a different command payload', async () => { @@ -1518,8 +1479,7 @@ test('the Core request hash rejects reuse of an idempotency key for a different }), ], }); - const app = mounted(harness, assertion.environment); - try { + await withMountedApp(mounted(harness, assertion.environment), async (app) => { const first = await handle( app, commandRequest('create-party', createPayload, assertion.token, { @@ -1541,16 +1501,13 @@ test('the Core request hash rejects reuse of an idempotency key for a different assert.equal(changedBody.code, 'action_request_hash_conflict'); assert.equal(executions, 1); assert.equal(harness.snapshot().committed.length, 1); - } finally { - await app.dispose(); - } + }); }); test('commit resolution requires authentication and a valid invocation without creating an Action', async () => { const assertion = await makeAssertion(); const harness = makeActionTestHarness(); - const app = mounted(harness, assertion.environment); - try { + await withMountedApp(mounted(harness, assertion.environment), async (app) => { const missingAuth = await handle(app, recoveryRequest(randomUUID())); assert.equal(missingAuth.status, 401); assert.equal(missingAuth.headers.get('www-authenticate'), 'Bearer'); @@ -1563,9 +1520,7 @@ test('commit resolution requires authentication and a valid invocation without c const absent = await handle(app, recoveryRequest(randomUUID(), assertion.token)); assert.equal(absent.status, 404); assert.equal(harness.snapshot().invocations.length, 0); - } finally { - await app.dispose(); - } + }); }); test('an open invocation resolves explicitly without authorizing automatic command retry', async () => { @@ -1579,8 +1534,7 @@ test('an open invocation resolves explicitly without authorizing automatic comma ], tenantPermission: 'allowed', }); - const app = mounted(harness, assertion.environment); - try { + await withMountedApp(mounted(harness, assertion.environment), async (app) => { const failed = await handle( app, commandRequest('archive-party', archivePayload, assertion.token, { @@ -1600,9 +1554,7 @@ test('an open invocation resolves explicitly without authorizing automatic comma }); assert.equal(harness.snapshot().invocations.length, 1); assert.equal(harness.snapshot().committed.length, 0); - } finally { - await app.dispose(); - } + }); }); test('actual Core commit acknowledgement loss resolves and the mounted governed Read returns the original decision without rerunning the Action', async () => { @@ -1698,8 +1650,7 @@ test('actual Core commit acknowledgement loss resolves and the mounted governed ); }), }; - const app = mounted(harness, assertion.environment, reads); - try { + await withMountedApp(mounted(harness, assertion.environment, reads), async (app) => { const uncertain = await handle( app, commandRequest('create-party', createPayload, assertion.token, { @@ -1749,7 +1700,5 @@ test('actual Core commit acknowledgement loss resolves and the mounted governed assert.equal(executions, 1); assert.deepEqual(harness.snapshot().committed, committedSnapshot.committed); assert.equal(harness.snapshot().invocations.length, 1); - } finally { - await app.dispose(); - } + }); }); diff --git a/app/verticals/party-registry/tests/unit/attach-engagement-handler.test.ts b/app/verticals/party-registry/tests/unit/attach-engagement-handler.test.ts new file mode 100644 index 000000000..d741ef458 --- /dev/null +++ b/app/verticals/party-registry/tests/unit/attach-engagement-handler.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { runEffectTestPromise } from '@app/core-runtime/testing/effect-runtime'; +import { Effect, Result } from 'effect'; +import { EngagementProfileConflict } from '../../shared/domain/engagement-profile.ts'; +import { handleAttachEngagement } from '../../src/actions/attach-engagement-handler.ts'; + +test('engagement creation runs only after successful validation and preserves the result', () => + runEffectTestPromise( + Effect.gen(function* createsAfterValidation() { + const calls: string[] = []; + const payload = { partyId: 'party' }; + const profile = { profileId: 'profile' }; + const result = yield* handleAttachEngagement(payload, { + services: { + validate: (input) => + Effect.sync(() => { + assert.equal(input, payload); + calls.push('validate'); + }), + create: (input) => + Effect.sync(() => { + assert.equal(input, payload); + calls.push('create'); + return profile; + }), + }, + }); + assert.equal(result, profile); + assert.deepEqual(calls, ['validate', 'create']); + }), + )); + +test('engagement validation failure retains its typed error and prevents persistence', () => + runEffectTestPromise( + Effect.gen(function* rejectsBeforeCreation() { + const conflict = new EngagementProfileConflict({ + code: 'contacts_party_type_mismatch', + reason: 'The Party has the wrong engagement type', + }); + let created = false; + const result = yield* handleAttachEngagement( + {}, + { + services: { + validate: () => Effect.fail(conflict), + create: () => + Effect.sync(() => { + created = true; + }), + }, + }, + ).pipe(Effect.result); + assert.equal(created, false); + assert.ok(Result.isFailure(result)); + assert.equal(result.failure, conflict); + }), + )); diff --git a/app/verticals/party-registry/tests/unit/catalog-contract.test.ts b/app/verticals/party-registry/tests/unit/catalog-contract.test.ts index 724d03a3c..7a9d7d1ad 100644 --- a/app/verticals/party-registry/tests/unit/catalog-contract.test.ts +++ b/app/verticals/party-registry/tests/unit/catalog-contract.test.ts @@ -15,3 +15,18 @@ test('reports exact Party Registry catalog differences', () => { unexpected: ['party.unexpected'], }); }); + +test('compares catalog sets without input order, duplicates, or previous results affecting differences', () => { + const actual = ['party.z_extra', 'party.a_extra', 'party.z_extra']; + const difference = comparePartyCatalog(actual); + assert.deepEqual(difference, { + missing: expectedPartyTableCatalog.toSorted(), + unexpected: ['party.a_extra', 'party.z_extra'], + }); + difference.missing.pop(); + assert.deepEqual(comparePartyCatalog(expectedPartyTableCatalog.toReversed()), { + missing: [], + unexpected: [], + }); + assert.deepEqual(actual, ['party.z_extra', 'party.a_extra', 'party.z_extra']); +}); diff --git a/app/verticals/party-registry/tests/unit/correction-contract.test.ts b/app/verticals/party-registry/tests/unit/correction-contract.test.ts index 823c57d50..51b2d7829 100644 --- a/app/verticals/party-registry/tests/unit/correction-contract.test.ts +++ b/app/verticals/party-registry/tests/unit/correction-contract.test.ts @@ -305,50 +305,6 @@ test('stale revision and foreign-tenant relationship correction fail before busi ); }); -test('UNRESOLVED Party Type enrichment is rejected before mutation by correction', async () => { - const h = transactionHarness([ - [], - [{ partyId }], - [], - [{ partyId }], - [ - { - assertionId, - factKind: 'PARTY_TYPE', - isCurrent: true, - normalizedValue: 'UNRESOLVED', - partyId, - state: 'ACTIVE', - }, - ], - ]); - const command = decode(PartyCorrectionCommandSchema)({ - ...evidence, - factKind: 'PARTY_TYPE', - partyId, - replacementValue: 'PERSON', - subjectEvidence: [ - { - basis: 'REVIEWED_DOCUMENT', - evidenceRef: 'record/42', - kind: 'ACTOR_ATTESTATION', - observedSubject: 'PERSON', - statement: 'Reviewed this external organization', - subjectKey: 'one-subject', - }, - ], - targetAssertionId: assertionId, - }); - const error = await runEffectTestPromise( - Effect.flip( - correctPartyFactRecord(h.transaction, tenantId, command, { actionInvocationId, principalId }), - ), - ); - assert.equal(error._tag, 'PartyCorrectionConflict'); - assert.match(error.reason, /enrichment/u); - assert.equal(h.updateSets.length, 0); -}); - test('detail exposes immutable original/result semantics, governance, and source distinct from actor', async () => { const h = transactionHarness([ [ @@ -472,62 +428,84 @@ test('correction history requires reviewer authority; ordinary identity read per assert.notDeepEqual(target, { kind: 'tenant', permission: 'read_party_identity' }); }); -test('Party Type correction reconciles newly eligible claims before superseding the original fact', async () => { - const h = transactionHarness([ - [], - [{ partyId }], - [], - [{ partyId }], - [ - { - assertionId, - factKind: 'PARTY_TYPE', - isCurrent: true, - normalizedValue: 'PERSON', - partyId, - state: 'ACTIVE', - }, - ], - [], - [ - { - identifierTypeKey: 'ICO', - namespace: 'CZ:ICO', - normalizedValue: '27074358', - officialIdentifierId: replacementId, - verificationState: 'VERIFIED', - }, - ], - [], - [{ partyId: organizationId }], - ]); - const command = decode(PartyCorrectionCommandSchema)({ - ...evidence, - factKind: 'PARTY_TYPE', - partyId, - replacementValue: 'ORGANIZATION', - subjectEvidence: [ - { - basis: 'REVIEWED_DOCUMENT', - evidenceRef: 'record/42', - kind: 'ACTOR_ATTESTATION', - observedSubject: 'ORGANIZATION', - statement: 'Reviewed this external organization', - subjectKey: 'one-subject', - }, +for (const scenario of [ + { + name: 'UNRESOLVED Party Type enrichment is rejected before mutation by correction', + original: 'UNRESOLVED', + replacement: 'PERSON', + claimReads: [], + reason: /enrichment/u, + }, + { + name: 'Party Type correction reconciles newly eligible claims before superseding the original fact', + original: 'PERSON', + replacement: 'ORGANIZATION', + claimReads: [ + [], + [ + { + identifierTypeKey: 'ICO', + namespace: 'CZ:ICO', + normalizedValue: '27074358', + officialIdentifierId: replacementId, + verificationState: 'VERIFIED', + }, + ], + [], + [{ partyId: organizationId }], ], - targetAssertionId: assertionId, + reason: /exclusive identifier claims/u, + }, +]) { + test(scenario.name, async () => { + const h = transactionHarness([ + [], + [{ partyId }], + [], + [{ partyId }], + [ + { + assertionId, + factKind: 'PARTY_TYPE', + isCurrent: true, + normalizedValue: scenario.original, + partyId, + state: 'ACTIVE', + }, + ], + ...scenario.claimReads, + ]); + const command = decode(PartyCorrectionCommandSchema)({ + ...evidence, + factKind: 'PARTY_TYPE', + partyId, + replacementValue: scenario.replacement, + subjectEvidence: [ + { + basis: 'REVIEWED_DOCUMENT', + evidenceRef: 'record/42', + kind: 'ACTOR_ATTESTATION', + observedSubject: scenario.replacement, + statement: 'Reviewed this external organization', + subjectKey: 'one-subject', + }, + ], + targetAssertionId: assertionId, + }); + const error = await runEffectTestPromise( + Effect.flip( + correctPartyFactRecord(h.transaction, tenantId, command, { + actionInvocationId, + principalId, + }), + ), + ); + assert.equal(error._tag, 'PartyCorrectionConflict'); + assert.match(error.reason, scenario.reason); + assert.equal(h.updateSets.length, 0); + assert.equal(h.insertValues.length, 0); }); - const error = await runEffectTestPromise( - Effect.flip( - correctPartyFactRecord(h.transaction, tenantId, command, { actionInvocationId, principalId }), - ), - ); - assert.equal(error._tag, 'PartyCorrectionConflict'); - assert.match(error.reason, /exclusive identifier claims/u); - assert.equal(h.updateSets.length, 0); - assert.equal(h.insertValues.length, 0); -}); +} test('type Correction cannot treat a reviewer decision or source label as subject evidence', async () => { const h = transactionHarness([ diff --git a/app/verticals/party-registry/tests/unit/counterparty-read-support.test.ts b/app/verticals/party-registry/tests/unit/counterparty-read-support.test.ts new file mode 100644 index 000000000..3dc660bad --- /dev/null +++ b/app/verticals/party-registry/tests/unit/counterparty-read-support.test.ts @@ -0,0 +1,80 @@ +import { ReadHandlerNotFound, ReadHandlerUnavailable } from '@app/core-runtime'; +import { runEffectTestPromise } from '@app/core-runtime/testing/effect-runtime'; +import { Effect, Schema } from 'effect'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { CounterpartyPersistenceUnavailable } from '../../shared/domain/counterparty-errors.ts'; +import { resolveCounterpartyRead } from '../../src/api/counterparty-read-support.ts'; + +const ref = { + moduleId: 'party.registry', + resourceType: 'party.registry.counterparty', + resourceId: 'counterparty-id', + tenantId: 'tenant-id', +} as const; +const reason = 'Counterparty persistence is temporarily unavailable'; + +test('cross-tenant counterparty reads never resolve the persistence service', () => + runEffectTestPromise( + Effect.gen(function* rejectCrossTenant() { + let calls = 0; + const failure = yield* resolveCounterpartyRead( + ref, + 'another-tenant', + () => { + calls += 1; + return Effect.succeed({ _tag: 'not_found' } as const); + }, + reason, + ).pipe(Effect.flip); + assert.equal(calls, 0); + assert.ok(Schema.is(ReadHandlerNotFound)(failure)); + assert.equal(failure.reason, 'The Counterparty does not exist in the trusted Tenant'); + }), + )); + +test('counterparty lookup preserves found values and authorized-context absence', () => + runEffectTestPromise( + Effect.gen(function* preserveLookupResult() { + const value = [{ role: 'CUSTOMER' }]; + const found = yield* resolveCounterpartyRead( + ref, + ref.tenantId, + (id) => { + assert.equal(id, ref.resourceId); + return Effect.succeed({ _tag: 'found', value } as const); + }, + reason, + ); + assert.equal(found, value); + const missing = yield* resolveCounterpartyRead( + ref, + ref.tenantId, + () => Effect.succeed({ _tag: 'not_found' } as const), + reason, + ).pipe(Effect.flip); + assert.ok(Schema.is(ReadHandlerNotFound)(missing)); + assert.equal(missing.reason, 'The Counterparty does not exist in the authorized context'); + }), + )); + +test('counterparty failures preserve the per-read reason and nonenumerable cause', () => + runEffectTestPromise( + Effect.gen(function* preserveFailureCause() { + const cause = new CounterpartyPersistenceUnavailable({ + code: 'counterparty_persistence_unavailable', + reason: 'database unavailable', + }); + const historyReason = 'Counterparty Role history is temporarily unavailable'; + const failure = yield* resolveCounterpartyRead( + ref, + ref.tenantId, + () => Effect.fail(cause), + historyReason, + ).pipe(Effect.flip); + assert.ok(Schema.is(ReadHandlerUnavailable)(failure)); + assert.equal(failure.reason, historyReason); + assert.equal(failure.cause, cause); + assert.equal(Object.getOwnPropertyDescriptor(failure, 'cause')?.enumerable, false); + }), + )); diff --git a/app/verticals/party-registry/tests/unit/engagement-catalog-contract.test.ts b/app/verticals/party-registry/tests/unit/engagement-catalog-contract.test.ts index a084e9881..dc5ffcd98 100644 --- a/app/verticals/party-registry/tests/unit/engagement-catalog-contract.test.ts +++ b/app/verticals/party-registry/tests/unit/engagement-catalog-contract.test.ts @@ -16,3 +16,18 @@ test('reports exact Contacts table catalog differences', () => { unexpected: [], }); }); + +test('keeps Contacts inventory separate while rejecting duplicate unknown tables once', () => { + assert.deepEqual( + compareContactsCatalog([ + ...expectedContactsTableCatalog, + 'party.counterparties', + 'party.counterparties', + ]), + { missing: [], unexpected: ['party.counterparties'] }, + ); + assert.deepEqual(compareContactsCatalog([]), { + missing: expectedContactsTableCatalog.toSorted(), + unexpected: [], + }); +}); diff --git a/app/verticals/party-registry/tests/unit/engagement-lifecycle-handler.test.ts b/app/verticals/party-registry/tests/unit/engagement-lifecycle-handler.test.ts new file mode 100644 index 000000000..7264d95ea --- /dev/null +++ b/app/verticals/party-registry/tests/unit/engagement-lifecycle-handler.test.ts @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Effect } from 'effect'; +import { runEffectTestPromise } from '@app/core-runtime/testing/effect-runtime'; +import { EngagementProfilePersistenceUnavailable } from '../../shared/domain/engagement-profile.ts'; +import { handleEngagementLifecycle } from '../../src/actions/engagement-lifecycle-handler.ts'; + +const payload = { profileRef: { resourceId: '10000000-0000-4000-8000-000000000001' } }; + +for (const state of ['active', 'archived'] as const) { + const handle = handleEngagementLifecycle(state); + + test(`engagement transition to ${state} forwards the reference and returns the persisted value`, () => + runEffectTestPromise( + Effect.gen(function* verifyTransition() { + const value = yield* handle(payload, { + services: { + transition: (profileId) => { + assert.equal(profileId, payload.profileRef.resourceId); + return Effect.succeed({ _tag: 'found', value: 'persisted-profile' }); + }, + }, + }); + assert.equal(value, 'persisted-profile'); + }), + )); + + test(`engagement conflict reports the requested ${state} state`, () => + runEffectTestPromise( + Effect.gen(function* verifyConflict() { + const reason = yield* handle(payload, { + services: { + transition: () => Effect.succeed({ _tag: 'conflict', value: 'existing-profile' }), + }, + }).pipe( + Effect.catchTag('EngagementProfileConflict', (error) => Effect.succeed(error.reason)), + ); + assert.equal(reason, `The engagement profile is already ${state}`); + }), + )); + + test(`engagement transition to ${state} retains the missing profile identity`, () => + runEffectTestPromise( + Effect.gen(function* verifyMissing() { + const profileId = yield* handle(payload, { + services: { transition: () => Effect.succeed({ _tag: 'not_found' }) }, + }).pipe( + Effect.catchTag('EngagementProfileNotFound', (error) => Effect.succeed(error.profileId)), + ); + assert.equal(profileId, payload.profileRef.resourceId); + }), + )); + + test(`engagement transition to ${state} preserves the typed persistence failure`, () => + runEffectTestPromise( + Effect.gen(function* verifyPersistenceFailure() { + const failure = new EngagementProfilePersistenceUnavailable({ + code: 'contacts_engagement_profile_persistence_unavailable', + reason: 'Storage unavailable', + }); + const result = yield* handle(payload, { + services: { transition: () => Effect.fail(failure) }, + }).pipe( + Effect.catchTag('EngagementProfilePersistenceUnavailable', (error) => + Effect.succeed(error), + ), + ); + assert.equal(result, failure); + }), + )); +} diff --git a/app/verticals/party-registry/tests/unit/engagement-profile-persistence-service.test.ts b/app/verticals/party-registry/tests/unit/engagement-profile-persistence-service.test.ts index 5a7d3c3a0..c1bcfed6f 100644 --- a/app/verticals/party-registry/tests/unit/engagement-profile-persistence-service.test.ts +++ b/app/verticals/party-registry/tests/unit/engagement-profile-persistence-service.test.ts @@ -2,11 +2,22 @@ import { runEffectTestPromise } from '@app/core-runtime/testing/effect-runtime'; // @effect-diagnostics asyncFunction:off -- Existing compatibility boundary; expires: 2026-12-31. /* eslint-disable anti-slop/no-chained-type-assertions -- Focused harness implements only the mutation insert's Drizzle seam. expires: 2026-12-31. */ import { DateTime, Effect } from 'effect'; +import type { SQL } from 'drizzle-orm'; +import { PgDialect } from 'drizzle-orm/pg-core'; +import { + organizationEngagementProfiles, + personEngagementProfiles, +} from '../../src/db/engagement-schema.ts'; import assert from 'node:assert/strict'; import test from 'node:test'; import type { OrganizationEngagementProfileRecord } from '../../src/db/engagement-schema.ts'; import { createOrganizationEngagementProfile, + createPersonEngagementProfile, + findOrganizationEngagementProfile, + findPersonEngagementProfile, + transitionOrganizationEngagementProfile, + transitionPersonEngagementProfile, ensureReferencesBelongToTenant, organizationEngagementProfileFromRecord, } from '../../src/services/engagement-profile-persistence.service.ts'; @@ -136,3 +147,126 @@ test('maps an unrelated uniqueness constraint to the existing persistence fallba 'Contacts engagement profile persistence is temporarily unavailable', ); }); + +const profileKinds = [ + { + table: organizationEngagementProfiles, + create: createOrganizationEngagementProfile, + find: findOrganizationEngagementProfile, + transition: transitionOrganizationEngagementProfile, + resourceType: 'party.registry.organization-engagement-profile', + }, + { + table: personEngagementProfiles, + create: createPersonEngagementProfile, + find: findPersonEngagementProfile, + transition: transitionPersonEngagementProfile, + resourceType: 'party.registry.person-engagement-profile', + }, +] as const; + +for (const kind of profileKinds) { + test(`${kind.resourceType} binds creation, lookup and lifecycle to its own tenant-qualified table`, () => + runEffectTestPromise( + Effect.gen(function* verifyProfilePersistence() { + let current: OrganizationEngagementProfileRecord | undefined = row; + let writes = 0; + let locks = 0; + const rows = () => (current === undefined ? [] : [current]); + const where = (predicate: SQL) => { + assert.deepEqual(new PgDialect().sqlToQuery(predicate).params, [ + tenantId, + row.engagementProfileId, + ]); + }; + // SAFETY: This focused double implements the factory's insert/select/update query chains. + const transaction = { + insert: (table: typeof kind.table) => { + assert.equal(table, kind.table); + return { + values: (values: typeof organizationEngagementProfiles.$inferInsert) => { + assert.deepEqual(values, { + counterpartyResourceId: refs.counterpartyRef.resourceId, + partyResourceId: refs.partyRef.resourceId, + tenantId, + }); + return { returning: () => Effect.succeed(rows()) }; + }, + }; + }, + select: () => ({ + from: (table: typeof kind.table) => { + assert.equal(table, kind.table); + return { + where: (predicate: SQL) => { + where(predicate); + return { + limit: () => + Object.assign(Effect.succeed(rows()), { + for: (mode: string) => { + assert.equal(mode, 'update'); + locks += 1; + return Effect.succeed(rows()); + }, + }), + }; + }, + }; + }, + }), + update: (table: typeof kind.table) => { + assert.equal(table, kind.table); + return { + set: ( + values: Pick, + ) => { + writes += 1; + current = { ...row, ...values }; + return { + where: (predicate: SQL) => { + where(predicate); + return { returning: () => Effect.succeed(rows()) }; + }, + }; + }, + }; + }, + } as unknown as Parameters[0]; + const created = yield* kind.create(transaction, { ...refs, tenantId }); + assert.equal(created.profileRef.resourceType, kind.resourceType); + assert.deepEqual(yield* kind.find(transaction, tenantId, row.engagementProfileId), { + _tag: 'found', + value: created, + }); + assert.deepEqual( + yield* kind.transition(transaction, tenantId, row.engagementProfileId, 'active'), + { _tag: 'conflict', value: created }, + ); + assert.equal(writes, 0); + const archived = yield* kind.transition( + transaction, + tenantId, + row.engagementProfileId, + 'archived', + ); + assert.deepEqual( + archived, + yield* kind.find(transaction, tenantId, row.engagementProfileId), + ); + assert.notEqual(current?.archivedAt, null); + yield* kind.transition(transaction, tenantId, row.engagementProfileId, 'active'); + assert.equal(current?.archivedAt, null); + assert.equal(writes, 2); + current = undefined; + assert.deepEqual(yield* kind.find(transaction, tenantId, row.engagementProfileId), { + _tag: 'not_found', + }); + assert.deepEqual( + yield* kind.transition(transaction, tenantId, row.engagementProfileId, 'archived'), + { _tag: 'not_found' }, + ); + assert.equal(writes, 2); + assert.equal(locks, 4); + }), + )); +} diff --git a/app/verticals/party-registry/tests/unit/identity-persistence.service.test.ts b/app/verticals/party-registry/tests/unit/identity-persistence.service.test.ts index f6ba39732..4a50594b4 100644 --- a/app/verticals/party-registry/tests/unit/identity-persistence.service.test.ts +++ b/app/verticals/party-registry/tests/unit/identity-persistence.service.test.ts @@ -142,6 +142,12 @@ const transactionHarness = ( }; /* eslint-enable anti-slop/no-unknown-parameters, anti-slop/no-unknown-returns, anti-slop/no-chained-type-assertions */ +const assertNoIdentityWrites = (harness: ReturnType) => { + assert.deepEqual(harness.insertedValues, []); + assert.deepEqual(harness.updateSets, []); + assert.deepEqual(harness.deletedTargets, []); +}; + const assertTenantLockIsFirst = (harness: ReturnType) => { // SAFETY: Every service under test first calls the tenant lock with one Drizzle SQL lock selection. const selection = harness.selectSelections[0] as { readonly lock: SQL }; @@ -683,9 +689,7 @@ test('Party type enrichment refuses another owner of a newly eligible identifier validFrom: '2026-01-01T00:00:00.000Z', }); assert.equal(result._tag, 'conflict'); - assert.deepEqual(harness.insertedValues, []); - assert.deepEqual(harness.updateSets, []); - assert.deepEqual(harness.deletedTargets, []); + assertNoIdentityWrites(harness); }), )); @@ -782,9 +786,7 @@ test('identity updates reject a historical end earlier than the assertion being validFrom: '2026-01-01T00:00:00.000Z', }); assert.equal(result._tag, 'conflict'); - assert.deepEqual(harness.insertedValues, []); - assert.deepEqual(harness.updateSets, []); - assert.deepEqual(harness.deletedTargets, []); + assertNoIdentityWrites(harness); }), )); diff --git a/app/verticals/party-registry/tests/unit/party-search-worker.test.ts b/app/verticals/party-registry/tests/unit/party-search-worker.test.ts new file mode 100644 index 000000000..103ba823f --- /dev/null +++ b/app/verticals/party-registry/tests/unit/party-search-worker.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { defineTenantModuleEntrypoint } from '@app/core-runtime'; +import type { OutboxWorkerHandlerContext } from '@app/core-runtime'; +import { runEffectTestPromise } from '@app/core-runtime/testing/effect-runtime'; +import { Effect, Schema } from 'effect'; +import { PartySearchProjectionUnavailable } from '../../shared/domain/search-projection-error.ts'; +import { PartySearchProjector } from '../../src/services/party-search-projection.service.ts'; +import { definePartySearchWorker } from '../../src/workers/party-search-worker.ts'; + +const context: OutboxWorkerHandlerContext = { + attemptNumber: 2, + claimId: 'claim', + deliveryId: 'delivery', + domainEventId: 'event', + messageId: 'message', + producerModuleKey: 'party.registry', + tenantId: 'tenant', + tenantSequenceNo: 7n, + topic: 'party.registry.worker-test.v1', + workerKey: 'party.registry.worker-test', +}; +const TestResourceIdSchema = Schema.String.pipe(Schema.brand('SearchWorkerTestResourceId')); +const payloadSchema = Schema.Struct({ resourceId: TestResourceIdSchema }); +const entrypoint = defineTenantModuleEntrypoint({ + access: 'background', + authorization: { kind: 'owner_local_background' }, + entrypointKey: context.workerKey, + moduleKey: 'party.registry', + role: 'worker', +}); + +for (const targetField of ['partyId', 'counterpartyId'] as const) { + test(`search worker forwards ${targetField}, trusted context, and typed retryable failure`, () => + runEffectTestPromise( + Effect.gen(function* forwardsSearchProjection() { + const { handle, worker } = definePartySearchWorker( + { + entrypoint, + payloadSchema, + producerModuleKey: 'party.registry', + topic: context.topic, + }, + { + spanName: 'PartySearchWorkerTest', + target: (payload) => + targetField === 'partyId' + ? { partyId: payload.resourceId } + : { counterpartyId: payload.resourceId }, + }, + ); + assert.deepEqual(worker.descriptor, { + consumerModuleKey: 'party.registry', + entrypoint, + leaseDurationMs: 30_000, + payloadSchema, + producerModuleKey: 'party.registry', + retryPolicy: { + initialBackoffMs: 1000, + maxAttempts: 5, + maxBackoffMs: 60_000, + multiplier: 2, + }, + topic: context.topic, + workerKey: context.workerKey, + }); + const failure = new PartySearchProjectionUnavailable({ + code: 'party_search_projection_unavailable', + reason: 'retry this projection', + }); + let calls = 0; + const result = yield* handle( + { resourceId: yield* Schema.decodeUnknownEffect(TestResourceIdSchema)('target') }, + context, + ).pipe( + Effect.provideService(PartySearchProjector, { + project: (receivedContext, target) => { + calls += 1; + assert.equal(receivedContext, context); + assert.deepEqual(target, { [targetField]: 'target' }); + return Effect.fail(failure); + }, + }), + Effect.catchTag('PartySearchProjectionUnavailable', (error) => Effect.succeed(error)), + ); + assert.equal(result, failure); + assert.equal(calls, 1); + }), + )); +} diff --git a/app/verticals/party-registry/tests/unit/read-outcome.test.ts b/app/verticals/party-registry/tests/unit/read-outcome.test.ts new file mode 100644 index 000000000..3a7b0d584 --- /dev/null +++ b/app/verticals/party-registry/tests/unit/read-outcome.test.ts @@ -0,0 +1,52 @@ +import { runEffectTestPromise } from '@app/core-runtime/testing/effect-runtime'; +import { Effect } from 'effect'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readDetailResult, readUnavailable, requireReadValue } from '../../src/api/read-outcome.ts'; + +test('detail lookup preserves the value and one-result evidence', () => { + const value = { revision: 7 }; + return runEffectTestPromise( + requireReadValue('Missing record')({ _tag: 'found', value }).pipe( + Effect.map(readDetailResult), + Effect.tap((output) => + Effect.sync(() => { + assert.equal(output.result, value); + assert.deepEqual(output.evidence, { resultCount: 1 }); + }), + ), + Effect.asVoid, + ), + ); +}); + +test('missing detail produces the caller-specific typed failure without result evidence', () => { + const reason = 'The Official Identifier does not exist'; + return runEffectTestPromise( + requireReadValue(reason)({ _tag: 'not_found' }).pipe( + Effect.map(() => assert.fail('Missing lookup must not succeed')), + Effect.catchTag('ReadHandlerNotFound', (failure) => + Effect.sync(() => { + assert.equal(failure.code, 'read_handler_not_found'); + assert.equal(failure.reason, reason); + }), + ), + ), + ); +}); + +test('unavailable mapping retains hidden diagnostic cause and descriptor policy', () => { + const cause = { diagnostic: 'private' }; + for (const configurable of [false, true]) { + const failure = readUnavailable('Read storage unavailable', configurable)(cause); + assert.equal(failure.code, 'read_handler_unavailable'); + assert.equal(failure.reason, 'Read storage unavailable'); + assert.deepEqual(Object.getOwnPropertyDescriptor(failure, 'cause'), { + configurable, + enumerable: false, + value: cause, + writable: false, + }); + assert.equal(JSON.stringify(failure).includes('private'), false); + } +}); diff --git a/app/verticals/party-registry/tests/unit/relationship-operation-contract.test.ts b/app/verticals/party-registry/tests/unit/relationship-operation-contract.test.ts index 46dabf04f..c6b0543e3 100644 --- a/app/verticals/party-registry/tests/unit/relationship-operation-contract.test.ts +++ b/app/verticals/party-registry/tests/unit/relationship-operation-contract.test.ts @@ -1,7 +1,9 @@ +import { runEffectTestSync } from '@app/core-runtime/testing/effect-runtime'; import assert from 'node:assert/strict'; import test from 'node:test'; import { DateTime, Option, Schema } from 'effect'; import { createPartyRelationshipAction } from '../../src/actions/create-party-relationship.action.ts'; +import { encodeRelationshipEventPayload } from '../../src/actions/relationship-event-payload.ts'; import { endPartyRelationshipAction } from '../../src/actions/end-party-relationship.action.ts'; import { updatePartyRelationshipAction } from '../../src/actions/update-party-relationship.action.ts'; import { partyRelationshipDetailRead } from '../../src/api/party-relationship-detail.read.ts'; @@ -96,6 +98,15 @@ test('relationship detail preserves canonical and stored alias endpoint context' validFrom: '2026-01-01T00:00:00.000Z', validTo: '2026-09-01T00:00:00.000Z', }); + assert.deepEqual(runEffectTestSync(encodeRelationshipEventPayload(detail)), { + fromPartyRef: canonicalFrom, + relationshipRef, + relationshipType: 'CONTACT_PERSON_OF', + revision: 4, + toPartyRef: to, + validFrom: '2026-01-01T00:00:00.000Z', + validTo: '2026-09-01T00:00:00.000Z', + }); assert.equal(detail.from.canonicalPartyRef.resourceId, canonicalFrom.resourceId); assert.equal(Option.getOrThrow(detail.from.requestedAlias).resourceId, storedFrom.resourceId); assert.equal(detail.state, 'HISTORICAL'); diff --git a/app/verticals/party-registry/tests/unit/schema-contract.test.ts b/app/verticals/party-registry/tests/unit/schema-contract.test.ts index 5a9640a67..2c35a3956 100644 --- a/app/verticals/party-registry/tests/unit/schema-contract.test.ts +++ b/app/verticals/party-registry/tests/unit/schema-contract.test.ts @@ -294,15 +294,18 @@ test('preserves bounded external observation evidence separately from trusted ac } }); -const checkSql = (checks: Readonly>, name: string) => checks[name] ?? ''; - -test('models typed contact point lifecycles with owner-local references', () => { - const contactChecks = Object.fromEntries( - configOf(partyContactPoints).checks.map((candidate) => [ +const checksOf = (table: (typeof configuredTables)[number]) => + Object.fromEntries( + configOf(table).checks.map((candidate) => [ candidate.name, dialect.sqlToQuery(candidate.value).sql, ]), ); + +const checkSql = (checks: Readonly>, name: string) => checks[name] ?? ''; + +test('models typed contact point lifecycles with owner-local references', () => { + const contactChecks = checksOf(partyContactPoints); assert.match(checkSql(contactChecks, 'party_contact_points_shape_ck'), /EMAIL/u); assert.match(checkSql(contactChecks, 'party_contact_points_shape_ck'), /PHONE/u); assert.match(checkSql(contactChecks, 'party_contact_points_shape_ck'), /ADDRESS/u); @@ -406,12 +409,7 @@ test('models contact point purpose lifecycles with owner-local references', () = }); test('models relationship lifecycles with owner-local references', () => { - const relationshipChecks = Object.fromEntries( - configOf(partyRelationships).checks.map((candidate) => [ - candidate.name, - dialect.sqlToQuery(candidate.value).sql, - ]), - ); + const relationshipChecks = checksOf(partyRelationships); assert.match(checkSql(relationshipChecks, 'party_relationships_type_ck'), /CONTACT_PERSON_OF/u); assert.doesNotMatch( checkSql(relationshipChecks, 'party_relationships_type_ck'), @@ -493,12 +491,7 @@ test('models Counterparty lifecycles with owner-local references', () => { column, ); } - const roleChecks = Object.fromEntries( - configOf(counterpartyRolePeriods).checks.map((candidate) => [ - candidate.name, - dialect.sqlToQuery(candidate.value).sql, - ]), - ); + const roleChecks = checksOf(counterpartyRolePeriods); assert.match(checkSql(roleChecks, 'party_counterparty_role_periods_type_ck'), /CUSTOMER/u); assert.match(checkSql(roleChecks, 'party_counterparty_role_periods_type_ck'), /SUPPLIER/u); assert.doesNotMatch( @@ -548,12 +541,7 @@ test('persists one recoverable match decision per Action and bounded duplicate r uniqueColumns(partyMatchDecisions, 'party_match_decisions_action_invocation_uk'), ['tenant_id', 'action_invocation_id'], ); - const decisionChecks = Object.fromEntries( - configOf(partyMatchDecisions).checks.map((candidate) => [ - candidate.name, - dialect.sqlToQuery(candidate.value).sql, - ]), - ); + const decisionChecks = checksOf(partyMatchDecisions); assert.match(decisionChecks['party_match_decisions_outcome_ck'] ?? '', /CREATED/u); assert.match(decisionChecks['party_match_decisions_outcome_ck'] ?? '', /MATCHED/u); assert.match(decisionChecks['party_match_decisions_outcome_ck'] ?? '', /AMBIGUOUS/u); diff --git a/app/verticals/party-registry/tests/unit/search-identifier-sync.test.ts b/app/verticals/party-registry/tests/unit/search-identifier-sync.test.ts index cbe050a12..baed0f539 100644 --- a/app/verticals/party-registry/tests/unit/search-identifier-sync.test.ts +++ b/app/verticals/party-registry/tests/unit/search-identifier-sync.test.ts @@ -158,21 +158,26 @@ const makeSearchFixture = (identifiers: readonly PartySearchSourceValue[]) => { }; }; +const assertIdentifierOutbox = ( + harness: ReturnType, + eventType: string, +) => { + const [commit] = harness.snapshot().committed; + assert.ok(commit); + assert.equal(commit.evidence.outboxMessages.length, 1); + const [outbox] = commit.evidence.outboxMessages; + assert.ok(outbox); + assert.equal(commit.evidence.domainEvents[outbox.domainEventIndex]?.eventType, eventType); + assert.deepEqual(outbox.message.payloadJson, { officialIdentifierRef, partyRef }); + return outbox; +}; + const assertAttachedIdentifierDelivery = ( harness: ReturnType, search: ReturnType, ) => Effect.gen(function* verifyCommittedIdentifierDelivery() { - const [commit] = harness.snapshot().committed; - assert.ok(commit); - assert.equal(commit.evidence.outboxMessages.length, 1); - const [outbox] = commit.evidence.outboxMessages; - assert.ok(outbox); - assert.equal( - commit.evidence.domainEvents[outbox.domainEventIndex]?.eventType, - 'party.registry.official-identifier-added.v1', - ); - assert.deepEqual(outbox.message.payloadJson, { officialIdentifierRef, partyRef }); + const outbox = assertIdentifierOutbox(harness, 'party.registry.official-identifier-added.v1'); assert.deepEqual(yield* search.query(), []); yield* search.deliver(outbox.message); const hits = yield* search.query(); @@ -324,16 +329,10 @@ test('END_VALIDITY refreshes search only after its committed identifier message registration: updatePartyOfficialIdentifierAction, transport: { correlationId: 'identifier-sync', idempotencyKey: 'end-identifier-1' }, }); - const [commit] = harness.snapshot().committed; - assert.ok(commit); - assert.equal(commit.evidence.outboxMessages.length, 1); - const [outbox] = commit.evidence.outboxMessages; - assert.ok(outbox); - assert.equal( - commit.evidence.domainEvents[outbox.domainEventIndex]?.eventType, + const outbox = assertIdentifierOutbox( + harness, 'party.registry.official-identifier-updated.v1', ); - assert.deepEqual(outbox.message.payloadJson, { officialIdentifierRef, partyRef }); assert.equal((yield* search.query()).length, 1); yield* search.deliver(outbox.message); assert.deepEqual(yield* search.query(), []); diff --git a/app/verticals/party-registry/tests/unit/search-provider.test.ts b/app/verticals/party-registry/tests/unit/search-provider.test.ts index 9b7079625..64d9276fb 100644 --- a/app/verticals/party-registry/tests/unit/search-provider.test.ts +++ b/app/verticals/party-registry/tests/unit/search-provider.test.ts @@ -77,3 +77,25 @@ test('Counterparty provider derives Legal Entity from trusted scope and never fr ]); }), )); + +test('Counterparty provider preserves typed normalization failures and omits an absent role', () => + runEffectTestPromise( + Effect.gen(function* invalidCounterpartyInstant() { + const calls: unknown[] = []; + const gateway: PartySearchProjectionGatewayService = { + searchCounterparties: (input) => { + calls.push(input); + return Effect.succeed([]); + }, + searchParties: () => Effect.succeed([]), + }; + const error = yield* Effect.flip( + loadCounterpartySearch(gateway, { legalEntityId, tenantId }, { query: 'ACME' }, 'invalid'), + ); + assert.equal(error.code, 'party_search_projection_unavailable'); + assert.equal(error.reason, 'Counterparty Search effective time is invalid'); + assert.deepEqual(calls, [ + { effectiveAt: 'invalid', includeArchived: false, legalEntityId, query: 'ACME', tenantId }, + ]); + }), + )); diff --git a/app/verticals/party-registry/tests/unit/timeline-resource-contract.test.ts b/app/verticals/party-registry/tests/unit/timeline-resource-contract.test.ts new file mode 100644 index 000000000..38fc6a1f5 --- /dev/null +++ b/app/verticals/party-registry/tests/unit/timeline-resource-contract.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Schema } from 'effect'; +import * as duplicateCase from '../../shared/resources/duplicate-candidate-case.ts'; +import * as correction from '../../shared/resources/party-correction.ts'; +import * as matchDecision from '../../shared/resources/party-match-decision.ts'; +import * as identifier from '../../shared/resources/party-official-identifier.ts'; + +const resources = [ + { + descriptor: duplicateCase.duplicateCandidateCaseResourceDescriptor, + makeRef: duplicateCase.makeDuplicateCandidateCaseRef, + schema: duplicateCase.DuplicateCandidateCaseRefSchema, + slug: 'duplicate-candidate-case', + }, + { + descriptor: correction.partyCorrectionResourceDescriptor, + makeRef: correction.makePartyCorrectionRef, + schema: correction.PartyCorrectionRefSchema, + slug: 'party-correction', + }, + { + descriptor: matchDecision.partyMatchDecisionResourceDescriptor, + makeRef: matchDecision.makePartyMatchDecisionRef, + schema: matchDecision.PartyMatchDecisionRefSchema, + slug: 'party-match-decision', + }, + { + descriptor: identifier.partyOfficialIdentifierResourceDescriptor, + makeRef: identifier.makePartyOfficialIdentifierRef, + schema: identifier.PartyOfficialIdentifierRefSchema, + slug: 'party-official-identifier', + }, +]; + +for (const { descriptor, makeRef, schema, slug } of resources) { + test(`${slug} retains its own resource identity and timeline-only capabilities`, () => { + const tenantId = '10000000-0000-4000-8000-000000000001'; + const reference = makeRef(tenantId, 'resource-1'); + assert.deepEqual(reference, { + moduleId: 'party.registry', + resourceId: 'resource-1', + resourceType: `party.registry.${slug}`, + tenantId, + }); + assert.equal(Schema.is(schema)(reference), true); + for (const other of resources.filter((resource) => resource.slug !== slug)) { + assert.equal(Schema.is(schema)(other.makeRef(tenantId, 'resource-1')), false); + } + assert.equal(Schema.is(schema)({ ...reference, resourceId: '' }), false); + assert.equal(Schema.is(schema)({ ...reference, tenantId: 'not-a-uuid' }), false); + assert.equal(descriptor.key, reference.resourceType); + assert.equal(descriptor.owningModuleId, reference.moduleId); + assert.equal(descriptor.description, `${descriptor.label} resource.`); + assert.deepEqual(descriptor.capabilities, { + graphVisible: false, + linkable: false, + mediaAttachable: false, + searchable: false, + timelineVisible: true, + }); + }); +} From 897a9f80512f4293adbd3f6cd21b4bbded52eabf Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Tue, 8 Sep 2026 12:15:25 +0200 Subject: [PATCH 03/13] fix: reconcile purge contracts and shared build boundaries Validate mounted gateway issuer bindings and exact quality command wiring. Expose the shared build identity helper through its supported package boundary, retain precise auth catalog types, and restore historical specification references.\n\nFocused boundary and helper tests: 54 passed; workspace contract and scoped typed lint passed. Full production release remains blocked by source-revision metadata; fixture cleanup and final audit integration remain pending. Co-Authored-By: Claude Fable 5.1 --- .../shell-super-app/api/auth/db/catalog.ts | 9 +++- .../shared/ultramodern-build.ts | 2 +- app/packages/shared-contracts/package.json | 1 + .../tests/unit/ultramodern-build.test.ts | 2 +- .../check-module-entrypoint-boundaries.mts | 35 +++++++++++---- .../module-entrypoint-boundaries.test.mts | 45 ++++++++++++++----- .../validate-ultramodern-workspace.mts | 9 ++-- .../feature-universal-module-state-gate.md | 3 +- .../shared/ultramodern-build.ts | 2 +- .../tests/support/database-boundary.ts | 2 +- 10 files changed, 82 insertions(+), 28 deletions(-) diff --git a/app/apps/shell-super-app/api/auth/db/catalog.ts b/app/apps/shell-super-app/api/auth/db/catalog.ts index 73793a546..b053e68c6 100644 --- a/app/apps/shell-super-app/api/auth/db/catalog.ts +++ b/app/apps/shell-super-app/api/auth/db/catalog.ts @@ -4,7 +4,14 @@ export const expectedAuthTableCatalog = AUTH_TABLE_INVENTORY.map( (tableName) => `${AUTH_SCHEMA_NAME}.${tableName}`, ); -export const compareAuthCatalog = (qualifiedTableNames: readonly string[]) => { +interface AuthCatalogDifference { + readonly missing: string[]; + readonly unexpected: string[]; +} + +export const compareAuthCatalog = ( + qualifiedTableNames: readonly string[], +): AuthCatalogDifference => { const actual = new Set(qualifiedTableNames); const expected = new Set(expectedAuthTableCatalog); diff --git a/app/apps/shell-super-app/shared/ultramodern-build.ts b/app/apps/shell-super-app/shared/ultramodern-build.ts index c4e3a9add..1c604a65b 100644 --- a/app/apps/shell-super-app/shared/ultramodern-build.ts +++ b/app/apps/shell-super-app/shared/ultramodern-build.ts @@ -1,4 +1,4 @@ -import { withUltramodernBuildIdentity } from '../../../packages/shared-contracts/src/ultramodern-build.ts'; +import { withUltramodernBuildIdentity } from '@app/shared-contracts/ultramodern-build'; import { Predicate } from 'effect'; declare const ULTRAMODERN_BUILD_MARKER: string; diff --git a/app/packages/shared-contracts/package.json b/app/packages/shared-contracts/package.json index 84292c5dd..f7430c9dc 100644 --- a/app/packages/shared-contracts/package.json +++ b/app/packages/shared-contracts/package.json @@ -6,6 +6,7 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./ultramodern-build": "./src/ultramodern-build.ts", "./problem-details": "./src/problem-details.ts", "./client-runtime": "./src/client-runtime.ts", "./server/effect-bff-runtime": "./src/effect-bff-runtime.ts" diff --git a/app/packages/shared-contracts/tests/unit/ultramodern-build.test.ts b/app/packages/shared-contracts/tests/unit/ultramodern-build.test.ts index 5c464fa2c..5dfd726b8 100644 --- a/app/packages/shared-contracts/tests/unit/ultramodern-build.test.ts +++ b/app/packages/shared-contracts/tests/unit/ultramodern-build.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { withUltramodernBuildIdentity } from '../../src/ultramodern-build.ts'; +import { withUltramodernBuildIdentity } from '@app/shared-contracts/ultramodern-build'; test('injected build identity updates all surfaces without mutating generated metadata', () => { const deliveryUnit = { diff --git a/app/scripts/check-module-entrypoint-boundaries.mts b/app/scripts/check-module-entrypoint-boundaries.mts index be54782af..03d05a338 100644 --- a/app/scripts/check-module-entrypoint-boundaries.mts +++ b/app/scripts/check-module-entrypoint-boundaries.mts @@ -1,4 +1,6 @@ #!/usr/bin/env node +import { maskNonCode } from './scaffolding/shared.mts'; +import { topLevelSeparators } from './boundary-source-structure.mts'; import { NodeRuntime, NodeServices } from '@effect/platform-node'; import { LanguageVariant, SyntaxKind, createScanner } from '@typescript/native/unstable/ast'; import { @@ -1133,25 +1135,40 @@ const validateGatewayRuntime = (shellApiContract: string, shellApiRuntime: strin } }); +const gatewayDeclaration = (source: string, name: string): string => { + const structure = maskNonCode(source); + const declaration = new RegExp(`export\\s+const\\s+${name}\\s*=`, 'u').exec(structure); + if (declaration === null) { + return ''; + } + const start = declaration.index + declaration[0].length; + const end = topLevelSeparators(structure, ';', start)[0] ?? source.length; + return maskNonCode(source.slice(start, end), true).trim(); +}; + const hasMountedIssuerPath = ( source: string, + gatewaySource: string, issuer: (typeof gatewayContextAuthorizationEntrypoints)[number], ): boolean => { - if (source.includes(`'${issuer.path}'`)) { - return true; - } const name = issuer.authorization.credential === 'session' ? 'issueGatewayContext' : 'issueApiKeyGatewayContext'; + const endpointPath = issuer.path.slice(shellGatewayContextContract.apiPrefix.length); + const group = gatewayDeclaration(gatewaySource, 'GatewayContextApiGroup'); + const shellApi = gatewayDeclaration(source, 'ShellAuthenticationApi'); return ( - source.includes("import { GatewayContextApiGroup } from '@app/shared-contracts'") && - source.includes('.add(GatewayContextApiGroup)') && - source.includes(`\`/shell-super-app-api\${endpoint.path}\``) && + maskNonCode(source, true).includes( + "import { GatewayContextApiGroup } from '@app/shared-contracts'", + ) && + shellApi.startsWith('HttpApi.make(') && + /\.add\(\s*GatewayContextApiGroup\s*\)/u.test(shellApi) && + group.startsWith("HttpApiGroup.make('gatewayContext')") && new RegExp( - `${name}Path:\\s*authenticationEndpointPath\\(\\s*ShellAuthenticationApi\\.groups\\.gatewayContext\\.endpoints\\.${name}\\s*,?\\s*\\)`, + `\\.add\\(\\s*HttpApiEndpoint\\.post\\(\\s*'${name}'\\s*,\\s*'${endpointPath}'\\s*,`, 'u', - ).test(source) + ).test(group) ); }; @@ -1167,7 +1184,7 @@ const validateGatewayContract = (state: BoundaryCheckState) => for (const issuer of gatewayContextAuthorizationEntrypoints) { if ( !gatewayContract.includes(`'${issuer.path}'`) || - !hasMountedIssuerPath(shellApiContract, issuer) + !hasMountedIssuerPath(shellApiContract, gatewayContract, issuer) ) { yield* fail( 'apps/shell-super-app/shared/api.ts', diff --git a/app/scripts/tests/module-entrypoint-boundaries.test.mts b/app/scripts/tests/module-entrypoint-boundaries.test.mts index 22b25eaa3..e27d186c0 100644 --- a/app/scripts/tests/module-entrypoint-boundaries.test.mts +++ b/app/scripts/tests/module-entrypoint-boundaries.test.mts @@ -29,6 +29,7 @@ nodeTest.after(async () => { const AUTHENTICATE_PRINCIPAL_BINDING = 'authenticatePrincipal: authenticateOperationPrincipal'; const STOCK_LIST_STEM = 'stock-list'; +const GATEWAY_CONTRACT_FILE = 'packages/shared-contracts/src/gateway-context.ts'; const STOCK_LIST_READ_BINDING = 'registration: stockListRead'; const STOCK_LIST_READ_FILE = 'verticals/inventory-stock/src/api/stock-list.read.ts'; const ACTION_HEADER_FOR_TEST = '// @generated by OntOS Codesmith Action v1'; @@ -306,7 +307,8 @@ export const routeMeta = { ownerAppId: 'shell-super-app', entrypoint: defineSyst await write( root, 'apps/shell-super-app/shared/api.ts', - `export const api = HttpApi.make('shell').add(GatewayContextApiGroup); + `import { GatewayContextApiGroup } from '@app/shared-contracts'; +export const ShellAuthenticationApi = HttpApi.make('shell').add(GatewayContextApiGroup); export const paths = ['/shell-super-app-api/auth/gateway-context', '/shell-super-app-api/auth/api-key/gateway-context'];`, ); await write( @@ -332,8 +334,11 @@ export const routeMeta = { moduleId: 'inventory.stock', ownerAppId: 'inventory-s await write(root, WORKER_FILE, validWorker); await write( root, - 'packages/shared-contracts/src/gateway-context.ts', - `export const shellGatewayContextContract = { issueGatewayContextPath: '/shell-super-app-api/auth/gateway-context', issueApiKeyGatewayContextPath: '/shell-super-app-api/auth/api-key/gateway-context' };`, + GATEWAY_CONTRACT_FILE, + `export const GatewayContextApiGroup = HttpApiGroup.make('gatewayContext') + .add(HttpApiEndpoint.post('issueGatewayContext', '/auth/gateway-context', {})) + .add(HttpApiEndpoint.post('issueApiKeyGatewayContext', '/auth/api-key/gateway-context', {})); +export const shellGatewayContextContract = { issueGatewayContextPath: '/shell-super-app-api/auth/gateway-context', issueApiKeyGatewayContextPath: '/shell-super-app-api/auth/api-key/gateway-context' };`, ); await write(root, 'packages/core-runtime/src/index.ts', `export const core = true;`); return root; @@ -2197,12 +2202,15 @@ test('accepts private entrypoint declarations while requiring the registered rea } }); -test('typed issuer paths require the mounted gateway group and exact endpoint references', async () => { +test('shared issuer paths require the mounted gateway group and exact endpoint bindings', async () => { const root = await makeFixture(); const contractFile = 'apps/shell-super-app/shared/api.ts'; + const gatewayFile = GATEWAY_CONTRACT_FILE; const source = await readFile(new URL(`../../${contractFile}`, import.meta.url), 'utf-8'); + const gatewaySource = await readFile(new URL(`../../${gatewayFile}`, import.meta.url), 'utf-8'); try { await write(root, contractFile, source); + await write(root, gatewayFile, gatewaySource); await checkModuleEntrypointBoundaries(root); await write( root, @@ -2210,13 +2218,30 @@ test('typed issuer paths require the mounted gateway group and exact endpoint re source.replace('.add(GatewayContextApiGroup)', '.add(UnrelatedApiGroup)'), ); await assert.rejects(checkModuleEntrypointBoundaries(root), /mounted gateway contract/u); - await write( - root, - contractFile, - source.replace('endpoints.issueGatewayContext', 'endpoints.unusedNeighbor'), - ); - await assert.rejects(checkModuleEntrypointBoundaries(root), /mounted gateway contract/u); + await write(root, contractFile, source); } finally { await rm(root, { force: true, recursive: true }); } }); + +for (const [before, after] of [ + ["post('issueGatewayContext'", "post('unusedNeighbor'"], + ["post('issueApiKeyGatewayContext'", "post('unusedNeighbor'"], + ["'/auth/gateway-context'", "'/auth/tampered'"], + ["'/auth/api-key/gateway-context'", "'/auth/tampered'"], + ["make('gatewayContext')", "make('unrelated')"], + ['export const GatewayContextApiGroup =', 'export const UnmountedApiGroup ='], +] as const) { + test(`rejects shared issuer binding tampering: ${before}`, async () => { + const root = await makeFixture(); + const gatewayFile = GATEWAY_CONTRACT_FILE; + const gatewaySource = await readFile(new URL(`../../${gatewayFile}`, import.meta.url), 'utf-8'); + try { + assert.ok(gatewaySource.includes(before)); + await write(root, gatewayFile, gatewaySource.replace(before, after)); + await assert.rejects(checkModuleEntrypointBoundaries(root), /mounted gateway contract/u); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); +} diff --git a/app/scripts/validate-ultramodern-workspace.mts b/app/scripts/validate-ultramodern-workspace.mts index f5f0b3009..8d047ef44 100644 --- a/app/scripts/validate-ultramodern-workspace.mts +++ b/app/scripts/validate-ultramodern-workspace.mts @@ -524,13 +524,16 @@ const workspaceValidationContractDefinition = { ciEvidenceScripts: { 'action:test:integration': 'pnpm --filter @app/core-runtime action:test:integration', 'deployment-impact:plan': 'node ./scripts/plan-deployment-impact.mts', + 'quality:audit': 'node ./scripts/quality-audit.mts', + 'quality:audit:gate': 'node ./scripts/quality-audit-gate.mts', + 'quality:check': 'pnpm quality:audit && pnpm quality:audit:gate', 'test:deployment-impact': 'node scripts/generate-outbox-worker-deployment.mjs && node --test scripts/tests/plan-deployment-impact.test.mts scripts/tests/outbox-worker-delivery.test.mts', 'test:generation': 'node --test scripts/scaffolding/tests/module-contract-generator.test.mts scripts/scaffolding/tests/resource-generator.test.mts scripts/scaffolding/tests/retire-contribution.test.mts scripts/scaffolding/tests/scaffold-generators.test.mts', 'test:integration': 'pnpm -r --if-present run test:integration', 'test:scripts': - 'node --test scripts/local-environment-values.test.mts scripts/tests/audit-database-trust-boundaries.test.mts scripts/tests/authorization-rollout-contract.test.mts scripts/tests/check-authorization-readiness.test.mts scripts/tests/database-access-boundaries.test.mts scripts/tests/initialize-local-development.test.mts scripts/tests/locki-feature.test.mts scripts/tests/migrate-contacts-authorization.test.mts scripts/tests/module-entrypoint-boundaries.test.mts scripts/tests/plan-deployment-impact.test.mts scripts/tests/protected-entrypoint-inventory.test.mts scripts/tests/provision-current-action-authorization.test.mts scripts/tests/report-fail-closed-authorization-impact.test.mts scripts/tests/api-only-tooling.test.mts scripts/tests/root-environment.test.mts scripts/tests/typecheck-project-references.test.mts scripts/tests/ultramodern-command.test.mts', + 'node --test scripts/tests/boundary-source-structure.test.mts scripts/local-environment-values.test.mts scripts/tests/audit-database-trust-boundaries.test.mts scripts/tests/authorization-rollout-contract.test.mts scripts/tests/check-authorization-readiness.test.mts scripts/tests/database-access-boundaries.test.mts scripts/tests/initialize-local-development.test.mts scripts/tests/locki-feature.test.mts scripts/tests/migrate-contacts-authorization.test.mts scripts/tests/module-entrypoint-boundaries.test.mts scripts/tests/plan-deployment-impact.test.mts scripts/tests/protected-entrypoint-inventory.test.mts scripts/tests/provision-current-action-authorization.test.mts scripts/tests/report-fail-closed-authorization-impact.test.mts scripts/tests/api-only-tooling.test.mts scripts/tests/root-environment.test.mts scripts/tests/typecheck-project-references.test.mts scripts/tests/ultramodern-command.test.mts', 'test:unit': 'pnpm -r --if-present run test:unit && pnpm -r --if-present run test:component', }, cloudflareSecurity: createCloudflareSecurityContract(), @@ -6796,8 +6799,8 @@ assert( !rootPackage.scripts.check.includes('pnpm node:proof') && rootPackage.scripts.check.endsWith( bridgeConfig - ? '&& pnpm performance:readiness && pnpm bridge:check' - : '&& pnpm performance:readiness', + ? '&& pnpm performance:readiness && pnpm bridge:check && pnpm quality:check' + : '&& pnpm performance:readiness && pnpm quality:check', ), 'Root check must remain static while running default-on performance readiness diagnostics and bridge gates when configured', ); diff --git a/app/specs/feature-universal-module-state-gate.md b/app/specs/feature-universal-module-state-gate.md index 444a97e95..2b32b784a 100644 --- a/app/specs/feature-universal-module-state-gate.md +++ b/app/specs/feature-universal-module-state-gate.md @@ -171,7 +171,8 @@ Use these files to implement the feature: - `apps/shell-super-app/api/index.ts` — Shell strict Effect BFF composition where a trusted request-scoped batch/snapshot layer may be provided without making the browser authoritative. - `apps/shell-super-app/api/verticals/installed-verticals.ts` — authoritative topology-derived installed business-module inventory used by Shell gateway decisions. - `apps/shell-super-app/src/routes/[lang]/page.data.ts` — existing Shell request-loader boundary and reference point for request-scoped state acquisition rather than per-component calls. -- `apps/shell-super-app/src/routes/shell-frame.tsx` — Shell composition surface where future remote loads must be lazy and gateway-owned in both the browser and Worker SSR renders. +- `apps/shell-super-app/src/routes/vertical-components.tsx` — generated Shell browser composition surface where future remote loads must be lazy and gateway-owned. +- `apps/shell-super-app/src/routes/vertical-components.worker.tsx` — Worker SSR composition surface that must not bypass the same structured load contract. - `scripts/scaffolding/action/scaffold.mts` — must emit tenant `write` entrypoints and explicit Core system entrypoints. - `scripts/scaffolding/microvertical-page/scaffold.mts` — must emit governed `read` route metadata rather than an owner id alone. - `scripts/scaffolding/outbox-worker/scaffold.mts` — must emit structured `background` descriptors and consistent generated catalogs/registries. diff --git a/app/verticals/party-registry/shared/ultramodern-build.ts b/app/verticals/party-registry/shared/ultramodern-build.ts index 33e72685a..3771777a6 100644 --- a/app/verticals/party-registry/shared/ultramodern-build.ts +++ b/app/verticals/party-registry/shared/ultramodern-build.ts @@ -1,4 +1,4 @@ -import { withUltramodernBuildIdentity } from '../../../packages/shared-contracts/src/ultramodern-build.ts'; +import { withUltramodernBuildIdentity } from '@app/shared-contracts/ultramodern-build'; declare const ULTRAMODERN_BUILD_MARKER: string; declare const ULTRAMODERN_SOURCE_REVISION: string; diff --git a/app/verticals/party-registry/tests/support/database-boundary.ts b/app/verticals/party-registry/tests/support/database-boundary.ts index 2f7babdbb..80feba755 100644 --- a/app/verticals/party-registry/tests/support/database-boundary.ts +++ b/app/verticals/party-registry/tests/support/database-boundary.ts @@ -24,7 +24,7 @@ export const openBoundaryDatabases = ( readonly runtimePool: Pool; }> => runEffectTestPromise( - Effect.gen(function* () { + Effect.gen(function* openDatabases() { const connections = yield* loadDatabaseConnectionPair(); const adminPool = new Pool({ connectionString: connections.admin.connectionString }); const runtimePool = new Pool({ From 2b434415af8bf88ccc9ad1f7c354b090e68f65f7 Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Tue, 8 Sep 2026 12:23:37 +0200 Subject: [PATCH 04/13] refactor: delete orphaned Party payload type surfaces Remove six unused type declarations and collapse schema-only forwarding bindings. Preserve live schemas, action behavior, and the shared payload type with real consumers. Focused matching/correction Node tests: 19 passed; scoped typed lint passed. Co-Authored-By: Claude Fable 5.1 --- .../shared/actions/dismiss-duplicate-candidate.ts | 9 ++++----- .../mark-duplicate-candidate-needs-evidence.ts | 11 ++++------- .../shared/domain/relationship-contract.ts | 2 -- .../src/actions/confirm-duplicate-parties.action.ts | 1 - .../src/actions/dismiss-duplicate-candidate.action.ts | 1 - .../mark-duplicate-candidate-needs-evidence.action.ts | 1 - 6 files changed, 8 insertions(+), 17 deletions(-) diff --git a/app/verticals/party-registry/shared/actions/dismiss-duplicate-candidate.ts b/app/verticals/party-registry/shared/actions/dismiss-duplicate-candidate.ts index 55aea1519..ec811e149 100644 --- a/app/verticals/party-registry/shared/actions/dismiss-duplicate-candidate.ts +++ b/app/verticals/party-registry/shared/actions/dismiss-duplicate-candidate.ts @@ -1,6 +1,5 @@ // Canonical schema-only contract extracted from the generated dismiss-duplicate-candidate Action. -import { DuplicateCaseResolutionPayloadSchema } from '../domain/matching-contracts.ts'; - -export const DismissDuplicateCandidatePayloadSchema = DuplicateCaseResolutionPayloadSchema; -export type DismissDuplicateCandidatePayload = typeof DismissDuplicateCandidatePayloadSchema.Type; -export { DuplicateCaseResolutionResultSchema as DismissDuplicateCandidateResultSchema } from '../domain/matching-contracts.ts'; +export { + DuplicateCaseResolutionPayloadSchema as DismissDuplicateCandidatePayloadSchema, + DuplicateCaseResolutionResultSchema as DismissDuplicateCandidateResultSchema, +} from '../domain/matching-contracts.ts'; diff --git a/app/verticals/party-registry/shared/actions/mark-duplicate-candidate-needs-evidence.ts b/app/verticals/party-registry/shared/actions/mark-duplicate-candidate-needs-evidence.ts index a651c27bb..be97248c5 100644 --- a/app/verticals/party-registry/shared/actions/mark-duplicate-candidate-needs-evidence.ts +++ b/app/verticals/party-registry/shared/actions/mark-duplicate-candidate-needs-evidence.ts @@ -1,8 +1,5 @@ // Canonical schema-only contract extracted from the generated mark-duplicate-candidate-needs-evidence Action. -import { DuplicateCaseResolutionPayloadSchema } from '../domain/matching-contracts.ts'; - -export const MarkDuplicateCandidateNeedsEvidencePayloadSchema = - DuplicateCaseResolutionPayloadSchema; -export type MarkDuplicateCandidateNeedsEvidencePayload = - typeof MarkDuplicateCandidateNeedsEvidencePayloadSchema.Type; -export { DuplicateCaseResolutionResultSchema as MarkDuplicateCandidateNeedsEvidenceResultSchema } from '../domain/matching-contracts.ts'; +export { + DuplicateCaseResolutionPayloadSchema as MarkDuplicateCandidateNeedsEvidencePayloadSchema, + DuplicateCaseResolutionResultSchema as MarkDuplicateCandidateNeedsEvidenceResultSchema, +} from '../domain/matching-contracts.ts'; diff --git a/app/verticals/party-registry/shared/domain/relationship-contract.ts b/app/verticals/party-registry/shared/domain/relationship-contract.ts index e6c6f1d50..8d8a66553 100644 --- a/app/verticals/party-registry/shared/domain/relationship-contract.ts +++ b/app/verticals/party-registry/shared/domain/relationship-contract.ts @@ -182,8 +182,6 @@ export const PartyRelationshipLifecycleEventPayloadSchema = Schema.Struct({ validFrom: Schema.OptionFromNullOr(RelationshipIsoTimestampSchema), validTo: Schema.OptionFromNullOr(RelationshipIsoTimestampSchema), }); -export type PartyRelationshipLifecycleEventPayload = - typeof PartyRelationshipLifecycleEventPayloadSchema.Type; export const PartyRelationshipLifecycleEventPayloadJsonSchema = Schema.toEncoded( PartyRelationshipLifecycleEventPayloadSchema, ); diff --git a/app/verticals/party-registry/src/actions/confirm-duplicate-parties.action.ts b/app/verticals/party-registry/src/actions/confirm-duplicate-parties.action.ts index 44621de69..ee11eddaf 100644 --- a/app/verticals/party-registry/src/actions/confirm-duplicate-parties.action.ts +++ b/app/verticals/party-registry/src/actions/confirm-duplicate-parties.action.ts @@ -13,7 +13,6 @@ import { } from './duplicate-case-resolution-service.ts'; import { handleDuplicateCaseResolution } from './duplicate-case-resolution-handler.ts'; -export type { ConfirmDuplicatePartiesPayload } from '../../shared/actions/confirm-duplicate-parties.ts'; export const confirmDuplicatePartiesAction = defineAction( { accessEvidencePolicy: { diff --git a/app/verticals/party-registry/src/actions/dismiss-duplicate-candidate.action.ts b/app/verticals/party-registry/src/actions/dismiss-duplicate-candidate.action.ts index 9b65dc936..d7ebe5041 100644 --- a/app/verticals/party-registry/src/actions/dismiss-duplicate-candidate.action.ts +++ b/app/verticals/party-registry/src/actions/dismiss-duplicate-candidate.action.ts @@ -13,7 +13,6 @@ import { } from './duplicate-case-resolution-service.ts'; import { handleDuplicateCaseResolution } from './duplicate-case-resolution-handler.ts'; -export type { DismissDuplicateCandidatePayload } from '../../shared/actions/dismiss-duplicate-candidate.ts'; export const dismissDuplicateCandidateAction = defineAction( { accessEvidencePolicy: { diff --git a/app/verticals/party-registry/src/actions/mark-duplicate-candidate-needs-evidence.action.ts b/app/verticals/party-registry/src/actions/mark-duplicate-candidate-needs-evidence.action.ts index 6dfa3beda..ffc5a4721 100644 --- a/app/verticals/party-registry/src/actions/mark-duplicate-candidate-needs-evidence.action.ts +++ b/app/verticals/party-registry/src/actions/mark-duplicate-candidate-needs-evidence.action.ts @@ -13,7 +13,6 @@ import { } from './duplicate-case-resolution-service.ts'; import { handleDuplicateCaseResolution } from './duplicate-case-resolution-handler.ts'; -export type { MarkDuplicateCandidateNeedsEvidencePayload } from '../../shared/actions/mark-duplicate-candidate-needs-evidence.ts'; export const markDuplicateCandidateNeedsEvidenceAction = defineAction( { accessEvidencePolicy: { From 08d7b47c3acf65c80d94912675c609ba0734c0f1 Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Tue, 8 Sep 2026 12:29:18 +0200 Subject: [PATCH 05/13] refactor: keep fixture cleanup Effect-native and ordered Return typed sequential cleanup Effects; run them only at existing managed Node test boundaries. Preserve first-failure short circuit and child-before-parent order, with three independent regression controls. Replace six preexisting manual error-tag assertions with Effect predicates. Focused cleanup tests and scoped typed lint pass; implementation root typecheck passed before concurrent generated-contract changes. Live database integration remains pending and no shared databases were used for this checkpoint. Co-Authored-By: Claude Fable 5.1 --- .../tests/integration/auth-runtime.test.ts | 118 ++++++++++-------- .../identity-modes-runtime.test.ts | 50 ++++---- .../tests/integration/outbox-runtime.test.ts | 14 ++- .../integration/principal-management.test.ts | 22 ++-- .../tests/support/fixture-cleanup.ts | 8 +- .../tests/unit/fixture-cleanup.test.ts | 64 ++++++++++ .../integration/database-boundary.test.ts | 42 ++++--- .../engagement-database-boundary.test.ts | 8 +- .../integration/identity-concurrency.test.ts | 22 ++-- 9 files changed, 219 insertions(+), 129 deletions(-) create mode 100644 app/packages/core-runtime/tests/unit/fixture-cleanup.test.ts diff --git a/app/apps/shell-super-app/tests/integration/auth-runtime.test.ts b/app/apps/shell-super-app/tests/integration/auth-runtime.test.ts index f837a56bc..f9b490d0c 100644 --- a/app/apps/shell-super-app/tests/integration/auth-runtime.test.ts +++ b/app/apps/shell-super-app/tests/integration/auth-runtime.test.ts @@ -288,43 +288,49 @@ test('creates, resolves, persists, revokes, and signs out a Better Auth session' const generatedFixtureRoot = await mkdtemp(path.join(tmpdir(), 'ontos-auth-runtime-')); const cleanup = async () => { - await purgeFixtureRows([ - coreDatabase.delete(dataAccessEvents).where(eq(dataAccessEvents.tenantId, tenantId)), - coreDatabase.delete(auditEvents).where(eq(auditEvents.tenantId, tenantId)), - coreDatabase.delete(actionInvocations).where(eq(actionInvocations.tenantId, tenantId)), - ]); + await runEffectTestPromise( + purgeFixtureRows([ + coreDatabase.delete(dataAccessEvents).where(eq(dataAccessEvents.tenantId, tenantId)), + coreDatabase.delete(auditEvents).where(eq(auditEvents.tenantId, tenantId)), + coreDatabase.delete(actionInvocations).where(eq(actionInvocations.tenantId, tenantId)), + ]), + ); const existingUsers = await runEffectTestPromise( authDatabase.select({ id: user.id }).from(user).where(eq(user.email, email)), ); await Promise.all( existingUsers.map(async (existingUser) => { - await purgeFixtureRows([ - coreDatabase - .delete(principalAuthBindings) - .where(eq(principalAuthBindings.providerSubjectId, existingUser.id)), - authDatabase.delete(session).where(eq(session.userId, existingUser.id)), - authDatabase.delete(account).where(eq(account.userId, existingUser.id)), - authDatabase.delete(user).where(eq(user.id, existingUser.id)), - ]); + await runEffectTestPromise( + purgeFixtureRows([ + coreDatabase + .delete(principalAuthBindings) + .where(eq(principalAuthBindings.providerSubjectId, existingUser.id)), + authDatabase.delete(session).where(eq(session.userId, existingUser.id)), + authDatabase.delete(account).where(eq(account.userId, existingUser.id)), + authDatabase.delete(user).where(eq(user.id, existingUser.id)), + ]), + ); }), ); - await purgeFixtureRows([ - coreDatabase - .delete(principalAuthBindings) - .where(eq(principalAuthBindings.principalId, principalId)), - coreDatabase.delete(tenantModuleStates).where(eq(tenantModuleStates.tenantId, tenantId)), - coreDatabase - .delete(tenantModuleStates) - .where(eq(tenantModuleStates.tenantId, foreignTenantId)), - coreDatabase.delete(principals).where(eq(principals.principalId, principalId)), - coreDatabase - .delete(legalEntities) - .where(eq(legalEntities.legalEntityId, fixtureLegalEntityId)), - coreDatabase.delete(tenants).where(eq(tenants.tenantId, tenantId)), - coreDatabase.delete(tenants).where(eq(tenants.tenantId, foreignTenantId)), - ]); + await runEffectTestPromise( + purgeFixtureRows([ + coreDatabase + .delete(principalAuthBindings) + .where(eq(principalAuthBindings.principalId, principalId)), + coreDatabase.delete(tenantModuleStates).where(eq(tenantModuleStates.tenantId, tenantId)), + coreDatabase + .delete(tenantModuleStates) + .where(eq(tenantModuleStates.tenantId, foreignTenantId)), + coreDatabase.delete(principals).where(eq(principals.principalId, principalId)), + coreDatabase + .delete(legalEntities) + .where(eq(legalEntities.legalEntityId, fixtureLegalEntityId)), + coreDatabase.delete(tenants).where(eq(tenants.tenantId, tenantId)), + coreDatabase.delete(tenants).where(eq(tenants.tenantId, foreignTenantId)), + ]), + ); }; try { @@ -1168,37 +1174,43 @@ test('selects, lists, switches, revalidates, and upgrades a multi-tenant session const fixtureTenants = [firstTenantId, secondTenantId]; // Ordered child-before-parent so every delete respects the owned foreign keys. const cleanup = async (): Promise => { - await purgeFixtureRows([ - coreDatabase - .delete(dataAccessEvents) - .where(inArray(dataAccessEvents.tenantId, fixtureTenants)), - ]); + await runEffectTestPromise( + purgeFixtureRows([ + coreDatabase + .delete(dataAccessEvents) + .where(inArray(dataAccessEvents.tenantId, fixtureTenants)), + ]), + ); const existingUsers = await runEffectTestPromise( authDatabase.select({ id: user.id }).from(user).where(eq(user.email, multiEmail)), ); const existingUserIds = existingUsers.map(({ id }) => id); if (existingUserIds.length > 0) { - await purgeFixtureRows([ - coreDatabase - .delete(principalAuthBindings) - .where(inArray(principalAuthBindings.providerSubjectId, existingUserIds)), - authDatabase.delete(session).where(inArray(session.userId, existingUserIds)), - authDatabase.delete(account).where(inArray(account.userId, existingUserIds)), - authDatabase.delete(user).where(inArray(user.id, existingUserIds)), - ]); + await runEffectTestPromise( + purgeFixtureRows([ + coreDatabase + .delete(principalAuthBindings) + .where(inArray(principalAuthBindings.providerSubjectId, existingUserIds)), + authDatabase.delete(session).where(inArray(session.userId, existingUserIds)), + authDatabase.delete(account).where(inArray(account.userId, existingUserIds)), + authDatabase.delete(user).where(inArray(user.id, existingUserIds)), + ]), + ); } - await purgeFixtureRows([ - coreDatabase - .delete(tenantModuleStates) - .where(inArray(tenantModuleStates.tenantId, fixtureTenants)), - coreDatabase - .delete(principals) - .where(inArray(principals.principalId, [firstPrincipalId, secondPrincipalId])), - coreDatabase - .delete(legalEntities) - .where(inArray(legalEntities.legalEntityId, [firstLegalEntityId, secondLegalEntityId])), - coreDatabase.delete(tenants).where(inArray(tenants.tenantId, fixtureTenants)), - ]); + await runEffectTestPromise( + purgeFixtureRows([ + coreDatabase + .delete(tenantModuleStates) + .where(inArray(tenantModuleStates.tenantId, fixtureTenants)), + coreDatabase + .delete(principals) + .where(inArray(principals.principalId, [firstPrincipalId, secondPrincipalId])), + coreDatabase + .delete(legalEntities) + .where(inArray(legalEntities.legalEntityId, [firstLegalEntityId, secondLegalEntityId])), + coreDatabase.delete(tenants).where(inArray(tenants.tenantId, fixtureTenants)), + ]), + ); }; try { diff --git a/app/apps/shell-super-app/tests/integration/identity-modes-runtime.test.ts b/app/apps/shell-super-app/tests/integration/identity-modes-runtime.test.ts index 25cdc1c7e..6091fc84b 100644 --- a/app/apps/shell-super-app/tests/integration/identity-modes-runtime.test.ts +++ b/app/apps/shell-super-app/tests/integration/identity-modes-runtime.test.ts @@ -173,26 +173,30 @@ void test('verifies provider keys and completes live support impersonation with const ids = [originalUserId, targetUserId, secondAdministratorUserId].filter( (id) => id.length > 0, ); - await purgeFixtureRows([ - authDatabase - .delete(supportImpersonationRecovery) - .where(eq(supportImpersonationRecovery.tenantId, tenantId)), - authDatabase.delete(apikey).where(inArray(apikey.referenceId, ids)), - authDatabase.delete(session).where(inArray(session.userId, ids)), - authDatabase.delete(account).where(inArray(account.userId, ids)), - authDatabase.delete(user).where(inArray(user.id, ids)), - ]); + await runEffectTestPromise( + purgeFixtureRows([ + authDatabase + .delete(supportImpersonationRecovery) + .where(eq(supportImpersonationRecovery.tenantId, tenantId)), + authDatabase.delete(apikey).where(inArray(apikey.referenceId, ids)), + authDatabase.delete(session).where(inArray(session.userId, ids)), + authDatabase.delete(account).where(inArray(account.userId, ids)), + authDatabase.delete(user).where(inArray(user.id, ids)), + ]), + ); } - await purgeFixtureRows([ - coreDatabase.delete(dataAccessEvents).where(eq(dataAccessEvents.tenantId, tenantId)), - coreDatabase.delete(auditEvents).where(eq(auditEvents.tenantId, tenantId)), - coreDatabase.delete(actionInvocations).where(eq(actionInvocations.tenantId, tenantId)), - coreDatabase - .delete(principalAuthBindings) - .where(eq(principalAuthBindings.tenantId, tenantId)), - coreDatabase.delete(principals).where(eq(principals.tenantId, tenantId)), - coreDatabase.delete(tenants).where(eq(tenants.tenantId, tenantId)), - ]); + await runEffectTestPromise( + purgeFixtureRows([ + coreDatabase.delete(dataAccessEvents).where(eq(dataAccessEvents.tenantId, tenantId)), + coreDatabase.delete(auditEvents).where(eq(auditEvents.tenantId, tenantId)), + coreDatabase.delete(actionInvocations).where(eq(actionInvocations.tenantId, tenantId)), + coreDatabase + .delete(principalAuthBindings) + .where(eq(principalAuthBindings.tenantId, tenantId)), + coreDatabase.delete(principals).where(eq(principals.tenantId, tenantId)), + coreDatabase.delete(tenants).where(eq(tenants.tenantId, tenantId)), + ]), + ); }; try { @@ -454,7 +458,7 @@ void test('verifies provider keys and completes live support impersonation with ); await runEffectTestPromise(keys.setEnabled(verified.providerKeyId, false)); const invalidKey = await runEffectTestPromise(Effect.flip(keys.verify(issued.secret))); - assert.equal(invalidKey._tag, 'ApiKeyCredentialInvalidError'); + assert.ok(Predicate.isTagged(invalidKey, 'ApiKeyCredentialInvalidError')); const managedPrincipal = await runEffectTestPromise( providePrincipalManagementRepository( @@ -571,7 +575,7 @@ void test('verifies provider keys and completes live support impersonation with const incompleteImpersonation = await runEffectTestPromise( Effect.flip(provideContextAccess(authentication.resolveTenantContext(impersonatedHeaders))), ); - assert.equal(incompleteImpersonation._tag, 'OntosIdentityForbiddenError'); + assert.ok(Predicate.isTagged(incompleteImpersonation, 'OntosIdentityForbiddenError')); await runEffectTestPromise( authDatabase .update(session) @@ -587,7 +591,7 @@ void test('verifies provider keys and completes live support impersonation with const mismatchedImpersonationReason = await runEffectTestPromise( Effect.flip(provideContextAccess(authentication.resolveTenantContext(impersonatedHeaders))), ); - assert.equal(mismatchedImpersonationReason._tag, 'OntosIdentityForbiddenError'); + assert.ok(Predicate.isTagged(mismatchedImpersonationReason, 'OntosIdentityForbiddenError')); await runEffectTestPromise( authDatabase .update(session) @@ -617,7 +621,7 @@ void test('verifies provider keys and completes live support impersonation with const revokedImpersonation = await runEffectTestPromise( Effect.flip(provideContextAccess(authentication.resolveTenantContext(impersonatedHeaders))), ); - assert.equal(revokedImpersonation._tag, 'OntosIdentityForbiddenError'); + assert.ok(Predicate.isTagged(revokedImpersonation, 'OntosIdentityForbiddenError')); const stopped = await runEffectTestPromise( provideContextAccess( providePrincipalManagementRepository( diff --git a/app/packages/core-runtime/tests/integration/outbox-runtime.test.ts b/app/packages/core-runtime/tests/integration/outbox-runtime.test.ts index 3918f0ff6..760b6b129 100644 --- a/app/packages/core-runtime/tests/integration/outbox-runtime.test.ts +++ b/app/packages/core-runtime/tests/integration/outbox-runtime.test.ts @@ -189,12 +189,14 @@ const cleanupTenant = async (database: CoreDatabaseExecutor, tenantId: string): database.delete(outboxDeliveries).where(eq(outboxDeliveries.outboxMessageId, messageId)), ); }); - await purgeFixtureRows([ - database.delete(outboxMessages).where(eq(outboxMessages.tenantId, tenantId)), - database.delete(domainEvents).where(eq(domainEvents.tenantId, tenantId)), - database.delete(tenantModuleStates).where(eq(tenantModuleStates.tenantId, tenantId)), - database.delete(tenants).where(eq(tenants.tenantId, tenantId)), - ]); + await runEffectTestPromise( + purgeFixtureRows([ + database.delete(outboxMessages).where(eq(outboxMessages.tenantId, tenantId)), + database.delete(domainEvents).where(eq(domainEvents.tenantId, tenantId)), + database.delete(tenantModuleStates).where(eq(tenantModuleStates.tenantId, tenantId)), + database.delete(tenants).where(eq(tenants.tenantId, tenantId)), + ]), + ); }; /** diff --git a/app/packages/core-runtime/tests/integration/principal-management.test.ts b/app/packages/core-runtime/tests/integration/principal-management.test.ts index f46953555..4a386080f 100644 --- a/app/packages/core-runtime/tests/integration/principal-management.test.ts +++ b/app/packages/core-runtime/tests/integration/principal-management.test.ts @@ -5,7 +5,7 @@ import { // @effect-diagnostics asyncFunction:off -- Existing compatibility boundary; expires: 2026-12-31. import { eq } from 'drizzle-orm'; -import { Effect, Exit as NativeExit, Scope as NativeScope } from 'effect'; +import { Effect, Exit as NativeExit, Predicate, Scope as NativeScope } from 'effect'; import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; import test, { after as afterNativeDatabase } from 'node:test'; @@ -37,13 +37,15 @@ void test('persists managed key lifecycle without credential material and enforc makeTestDatabaseFromPool(pool, coreRelations).pipe(NativeScope.provide(nativeDatabaseScope)), ); const cleanup = async () => { - await purgeFixtureRows([ - database - .delete(principalAuthBindings) - .where(eq(principalAuthBindings.providerSubjectId, providerKeyId)), - database.delete(principals).where(eq(principals.tenantId, tenantId)), - database.delete(tenants).where(eq(tenants.tenantId, tenantId)), - ]); + await runEffectTestPromise( + purgeFixtureRows([ + database + .delete(principalAuthBindings) + .where(eq(principalAuthBindings.providerSubjectId, providerKeyId)), + database.delete(principals).where(eq(principals.tenantId, tenantId)), + database.delete(tenants).where(eq(tenants.tenantId, tenantId)), + ]), + ); }; try { @@ -117,7 +119,7 @@ void test('persists managed key lifecycle without credential material and enforc ), ), ); - assert.equal(duplicate._tag, 'IdentityLifecycleConflictError'); + assert.ok(Predicate.isTagged(duplicate, 'IdentityLifecycleConflictError')); const missingReason = await runEffectTestPromise( database.transaction((transaction) => @@ -138,7 +140,7 @@ void test('persists managed key lifecycle without credential material and enforc ), ), ); - assert.equal(missingReason._tag, 'IdentityTargetInvalidError'); + assert.ok(Predicate.isTagged(missingReason, 'IdentityTargetInvalidError')); await runEffectTestPromise( database.transaction((transaction) => diff --git a/app/packages/core-runtime/tests/support/fixture-cleanup.ts b/app/packages/core-runtime/tests/support/fixture-cleanup.ts index 793f4b6b9..b65cce827 100644 --- a/app/packages/core-runtime/tests/support/fixture-cleanup.ts +++ b/app/packages/core-runtime/tests/support/fixture-cleanup.ts @@ -1,10 +1,10 @@ import { Effect } from 'effect'; -import { runEffectTestPromise } from './effect-runtime.ts'; /** * Runs fixture deletions in call order so every child row drops before its parent, failing on - * the first deletion that rejects. Callers build the delete Effects inline, which keeps the + * the first deletion that fails. Callers build the delete Effects inline, which keeps the * owned table order explicit at the call site instead of behind a generic cascade. */ -export const purgeFixtureRows = (deletions: readonly Effect.Effect[]): Promise => - runEffectTestPromise(Effect.all(deletions, { discard: true })); +export const purgeFixtureRows = ( + deletions: readonly Effect.Effect[], +): Effect.Effect => Effect.all(deletions, { concurrency: 1, discard: true }); diff --git a/app/packages/core-runtime/tests/unit/fixture-cleanup.test.ts b/app/packages/core-runtime/tests/unit/fixture-cleanup.test.ts new file mode 100644 index 000000000..a5ba0586f --- /dev/null +++ b/app/packages/core-runtime/tests/unit/fixture-cleanup.test.ts @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { Effect, Schema } from 'effect'; +import { makeEffectTestCallback } from '../support/effect-runtime.ts'; +import { purgeFixtureRows } from '../support/fixture-cleanup.ts'; + +class FixtureDeletionError extends Schema.TaggedError()( + 'FixtureDeletionError', + {}, +) {} + +void test( + 'purges fixture rows sequentially in child-before-parent order', + makeEffectTestCallback( + Effect.gen(function* verifyDeletionOrder() { + const deleted: string[] = []; + yield* purgeFixtureRows([ + Effect.yieldNow.pipe( + Effect.andThen( + Effect.sync(() => { + deleted.push('child'); + }), + ), + ), + Effect.sync(() => { + assert.deepEqual(deleted, ['child']); + deleted.push('parent'); + }), + ]); + assert.deepEqual(deleted, ['child', 'parent']); + }), + ), +); + +void test( + 'stops fixture cleanup at the first failed deletion', + makeEffectTestCallback( + Effect.gen(function* verifyFirstFailure() { + const deleted: string[] = []; + const failure = new FixtureDeletionError(); + const error = yield* purgeFixtureRows([ + Effect.sync(() => { + deleted.push('child'); + }), + Effect.fail(failure), + Effect.sync(() => { + deleted.push('parent'); + }), + ]).pipe(Effect.flip); + assert.equal(error, failure); + assert.deepEqual(deleted, ['child']); + }), + ), +); + +void test( + 'accepts an empty fixture cleanup', + makeEffectTestCallback( + Effect.gen(function* verifyEmptyCleanup() { + const result = yield* purgeFixtureRows([]); + assert.equal(result, undefined); + }), + ), +); diff --git a/app/verticals/party-registry/tests/integration/database-boundary.test.ts b/app/verticals/party-registry/tests/integration/database-boundary.test.ts index 98777e87e..625c4912a 100644 --- a/app/verticals/party-registry/tests/integration/database-boundary.test.ts +++ b/app/verticals/party-registry/tests/integration/database-boundary.test.ts @@ -76,26 +76,28 @@ test('enforces Party owner invariants, tenant isolation, and independent fact li // Ordered child-before-parent so every delete respects the owned foreign keys. const cleanup = async (): Promise => { - await purgeFixtureRows( - [ - partyCorrections, - partyAliases, - partyMerges, - partyMatchDecisions, - duplicateCandidateCaseParties, - duplicateCandidateCases, - counterpartyRoleAdminReadModels, - counterpartyAdminReadModels, - counterpartyRolePeriods, - counterparties, - partyRelationships, - partyContactPointPurposes, - partyContactPoints, - partyIdentifierClaims, - partyOfficialIdentifiers, - partyFactAssertions, - parties, - ].map((table) => admin.delete(table).where(inArray(table.tenantId, fixtureTenants))), + await runEffectTestPromise( + purgeFixtureRows( + [ + partyCorrections, + partyAliases, + partyMerges, + partyMatchDecisions, + duplicateCandidateCaseParties, + duplicateCandidateCases, + counterpartyRoleAdminReadModels, + counterpartyAdminReadModels, + counterpartyRolePeriods, + counterparties, + partyRelationships, + partyContactPointPurposes, + partyContactPoints, + partyIdentifierClaims, + partyOfficialIdentifiers, + partyFactAssertions, + parties, + ].map((table) => admin.delete(table).where(inArray(table.tenantId, fixtureTenants))), + ), ); }; diff --git a/app/verticals/party-registry/tests/integration/engagement-database-boundary.test.ts b/app/verticals/party-registry/tests/integration/engagement-database-boundary.test.ts index 231191f8d..eb0acac56 100644 --- a/app/verticals/party-registry/tests/integration/engagement-database-boundary.test.ts +++ b/app/verticals/party-registry/tests/integration/engagement-database-boundary.test.ts @@ -42,9 +42,11 @@ test('enforces tenant isolation and canonical-reference uniqueness without cross await openBoundaryDatabases(openContactsDatabase); // Ordered child-before-parent so every delete respects the owned foreign keys. const cleanup = async (): Promise => { - await purgeFixtureRows( - [personEngagementProfiles, organizationEngagementProfiles].map((table) => - admin.delete(table).where(inArray(table.tenantId, fixtureTenants)), + await runEffectTestPromise( + purgeFixtureRows( + [personEngagementProfiles, organizationEngagementProfiles].map((table) => + admin.delete(table).where(inArray(table.tenantId, fixtureTenants)), + ), ), ); }; diff --git a/app/verticals/party-registry/tests/integration/identity-concurrency.test.ts b/app/verticals/party-registry/tests/integration/identity-concurrency.test.ts index 0c6466e92..c94a162b8 100644 --- a/app/verticals/party-registry/tests/integration/identity-concurrency.test.ts +++ b/app/verticals/party-registry/tests/integration/identity-concurrency.test.ts @@ -54,16 +54,18 @@ test('real PostgreSQL identity locks serialize concurrent exact creates and repe ); // Ordered child-before-parent so every delete respects the owned foreign keys. const cleanup = async (): Promise => { - await purgeFixtureRows( - [ - partyMatchDecisions, - duplicateCandidateCaseParties, - duplicateCandidateCases, - partyIdentifierClaims, - partyOfficialIdentifiers, - partyFactAssertions, - parties, - ].map((table) => admin.delete(table).where(eq(table.tenantId, tenantId))), + await runEffectTestPromise( + purgeFixtureRows( + [ + partyMatchDecisions, + duplicateCandidateCaseParties, + duplicateCandidateCases, + partyIdentifierClaims, + partyOfficialIdentifiers, + partyFactAssertions, + parties, + ].map((table) => admin.delete(table).where(eq(table.tenantId, tenantId))), + ), ); }; const scoped = ( From b890900ad4425b28c7ee76ca6c376e7d2c8e98e5 Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Tue, 8 Sep 2026 12:41:48 +0200 Subject: [PATCH 06/13] refactor: consolidate engagement lifecycle registration Share exact governed write registration across four distinct action entrypoints while preserving concrete schemas, permission targets, transaction services and archive state transitions. Add registration contract assertions alongside existing lifecycle and command regressions. Validation: 36 focused tests pass; scoped lint and format pass. Generated transport and strict CLI test integration continue separately; no full-tree completion claim. Co-Authored-By: Claude Fable 5.1 --- .../archive-organization-engagement.action.ts | 46 ++------------ .../archive-person-engagement.action.ts | 46 ++------------ .../engagement-lifecycle-registration.ts | 47 ++++++++++++++ ...narchive-organization-engagement.action.ts | 46 ++------------ .../unarchive-person-engagement.action.ts | 46 ++------------ .../engagement-lifecycle-registration.test.ts | 62 +++++++++++++++++++ 6 files changed, 133 insertions(+), 160 deletions(-) create mode 100644 app/verticals/party-registry/src/actions/engagement-lifecycle-registration.ts create mode 100644 app/verticals/party-registry/tests/unit/engagement-lifecycle-registration.test.ts diff --git a/app/verticals/party-registry/src/actions/archive-organization-engagement.action.ts b/app/verticals/party-registry/src/actions/archive-organization-engagement.action.ts index 21a2b9e7d..c23323351 100644 --- a/app/verticals/party-registry/src/actions/archive-organization-engagement.action.ts +++ b/app/verticals/party-registry/src/actions/archive-organization-engagement.action.ts @@ -1,12 +1,7 @@ // @generated by OntOS Codesmith Action v1 // @ontos-action-owner party.registry // @ontos-action-slug archive-organization-engagement -import { - defineAction, - defineActionResourcePermission, - defineTenantModuleEntrypoint, - OperationContextUnavailable, -} from '@app/core-runtime'; +import { defineAction, OperationContextUnavailable } from '@app/core-runtime'; import { Effect } from 'effect'; import { OrganizationEngagementLifecyclePayloadSchema, @@ -17,45 +12,16 @@ import type { OrganizationEngagementProfile, } from '../../shared/domain/engagement-profile.ts'; import { transitionOrganizationEngagementProfile } from '../services/engagement-profile-persistence.service.ts'; -import { - EngagementLifecycleErrorSchema, - handleEngagementLifecycle, -} from './engagement-lifecycle-handler.ts'; +import { handleEngagementLifecycle } from './engagement-lifecycle-handler.ts'; +import { engagementLifecycleRegistration } from './engagement-lifecycle-registration.ts'; export const archiveOrganizationEngagementAction = defineAction( { - accessEvidencePolicy: { - captureMode: 'metadata_only', - policyKey: 'party.registry.archive-organization-engagement.access.v1', - }, - actionKey: 'party.registry.archive-organization-engagement', - auditProfile: 'standard', - domainErrorSchema: EngagementLifecycleErrorSchema, - domainEvents: {}, - entrypoint: defineTenantModuleEntrypoint({ - access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, - entrypointKey: 'party.registry.archive-organization-engagement', - moduleKey: 'party.registry', - role: 'action', - }), - idempotency: 'required', - legalEntityScope: 'required', - owningModuleKey: 'party.registry', - payloadSchema: OrganizationEngagementLifecyclePayloadSchema, - policies: [], - resourcePermission: defineActionResourcePermission( - (payload) => ({ - permission: 'write', - resource: { - moduleId: payload.profileRef.moduleId, - resourceId: payload.profileRef.resourceId, - resourceType: payload.profileRef.resourceType, - }, - }), + ...engagementLifecycleRegistration( + 'party.registry.archive-organization-engagement', ), + payloadSchema: OrganizationEngagementLifecyclePayloadSchema, resultSchema: OrganizationEngagementProfileSchema, - schemaVersion: '1', }, handleEngagementLifecycle( 'archived', diff --git a/app/verticals/party-registry/src/actions/archive-person-engagement.action.ts b/app/verticals/party-registry/src/actions/archive-person-engagement.action.ts index 91b5b4155..9df1d4435 100644 --- a/app/verticals/party-registry/src/actions/archive-person-engagement.action.ts +++ b/app/verticals/party-registry/src/actions/archive-person-engagement.action.ts @@ -1,12 +1,7 @@ // @generated by OntOS Codesmith Action v1 // @ontos-action-owner party.registry // @ontos-action-slug archive-person-engagement -import { - defineAction, - defineActionResourcePermission, - defineTenantModuleEntrypoint, - OperationContextUnavailable, -} from '@app/core-runtime'; +import { defineAction, OperationContextUnavailable } from '@app/core-runtime'; import { Effect } from 'effect'; import { PersonEngagementLifecyclePayloadSchema, @@ -17,45 +12,16 @@ import type { PersonEngagementProfile, } from '../../shared/domain/engagement-profile.ts'; import { transitionPersonEngagementProfile } from '../services/engagement-profile-persistence.service.ts'; -import { - EngagementLifecycleErrorSchema, - handleEngagementLifecycle, -} from './engagement-lifecycle-handler.ts'; +import { handleEngagementLifecycle } from './engagement-lifecycle-handler.ts'; +import { engagementLifecycleRegistration } from './engagement-lifecycle-registration.ts'; export const archivePersonEngagementAction = defineAction( { - accessEvidencePolicy: { - captureMode: 'metadata_only', - policyKey: 'party.registry.archive-person-engagement.access.v1', - }, - actionKey: 'party.registry.archive-person-engagement', - auditProfile: 'standard', - domainErrorSchema: EngagementLifecycleErrorSchema, - domainEvents: {}, - entrypoint: defineTenantModuleEntrypoint({ - access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, - entrypointKey: 'party.registry.archive-person-engagement', - moduleKey: 'party.registry', - role: 'action', - }), - idempotency: 'required', - legalEntityScope: 'required', - owningModuleKey: 'party.registry', - payloadSchema: PersonEngagementLifecyclePayloadSchema, - policies: [], - resourcePermission: defineActionResourcePermission( - (payload) => ({ - permission: 'write', - resource: { - moduleId: payload.profileRef.moduleId, - resourceId: payload.profileRef.resourceId, - resourceType: payload.profileRef.resourceType, - }, - }), + ...engagementLifecycleRegistration( + 'party.registry.archive-person-engagement', ), + payloadSchema: PersonEngagementLifecyclePayloadSchema, resultSchema: PersonEngagementProfileSchema, - schemaVersion: '1', }, handleEngagementLifecycle('archived'), (transaction, scope) => { diff --git a/app/verticals/party-registry/src/actions/engagement-lifecycle-registration.ts b/app/verticals/party-registry/src/actions/engagement-lifecycle-registration.ts new file mode 100644 index 000000000..fd6f3bc9a --- /dev/null +++ b/app/verticals/party-registry/src/actions/engagement-lifecycle-registration.ts @@ -0,0 +1,47 @@ +import { defineActionResourcePermission, defineTenantModuleEntrypoint } from '@app/core-runtime'; +import type { + OrganizationEngagementLifecyclePayload, + PersonEngagementLifecyclePayload, +} from '../../shared/domain/engagement-profile.ts'; +import { EngagementLifecycleErrorSchema } from './engagement-lifecycle-handler.ts'; + +type EngagementLifecyclePayload = + | OrganizationEngagementLifecyclePayload + | PersonEngagementLifecyclePayload; +type EngagementLifecycleActionKey = + `party.registry.${'archive' | 'unarchive'}-${'person' | 'organization'}-engagement`; + +/** The shared governed-write contract; schemas and transaction services stay owner-specific. */ +export const engagementLifecycleRegistration = ( + actionKey: EngagementLifecycleActionKey, +) => + ({ + accessEvidencePolicy: { + captureMode: 'metadata_only', + policyKey: `${actionKey}.access.v1`, + }, + actionKey, + auditProfile: 'standard', + domainErrorSchema: EngagementLifecycleErrorSchema, + domainEvents: {}, + entrypoint: defineTenantModuleEntrypoint({ + access: 'write', + authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + entrypointKey: actionKey, + moduleKey: 'party.registry', + role: 'action', + }), + idempotency: 'required', + legalEntityScope: 'required', + owningModuleKey: 'party.registry', + policies: [], + resourcePermission: defineActionResourcePermission((payload) => ({ + permission: 'write', + resource: { + moduleId: payload.profileRef.moduleId, + resourceId: payload.profileRef.resourceId, + resourceType: payload.profileRef.resourceType, + }, + })), + schemaVersion: '1', + }) as const; diff --git a/app/verticals/party-registry/src/actions/unarchive-organization-engagement.action.ts b/app/verticals/party-registry/src/actions/unarchive-organization-engagement.action.ts index 017b781eb..81f00251a 100644 --- a/app/verticals/party-registry/src/actions/unarchive-organization-engagement.action.ts +++ b/app/verticals/party-registry/src/actions/unarchive-organization-engagement.action.ts @@ -1,12 +1,7 @@ // @generated by OntOS Codesmith Action v1 // @ontos-action-owner party.registry // @ontos-action-slug unarchive-organization-engagement -import { - defineAction, - defineActionResourcePermission, - defineTenantModuleEntrypoint, - OperationContextUnavailable, -} from '@app/core-runtime'; +import { defineAction, OperationContextUnavailable } from '@app/core-runtime'; import { Effect } from 'effect'; import { OrganizationEngagementLifecyclePayloadSchema, @@ -17,45 +12,16 @@ import type { OrganizationEngagementProfile, } from '../../shared/domain/engagement-profile.ts'; import { transitionOrganizationEngagementProfile } from '../services/engagement-profile-persistence.service.ts'; -import { - EngagementLifecycleErrorSchema, - handleEngagementLifecycle, -} from './engagement-lifecycle-handler.ts'; +import { handleEngagementLifecycle } from './engagement-lifecycle-handler.ts'; +import { engagementLifecycleRegistration } from './engagement-lifecycle-registration.ts'; export const unarchiveOrganizationEngagementAction = defineAction( { - accessEvidencePolicy: { - captureMode: 'metadata_only', - policyKey: 'party.registry.unarchive-organization-engagement.access.v1', - }, - actionKey: 'party.registry.unarchive-organization-engagement', - auditProfile: 'standard', - domainErrorSchema: EngagementLifecycleErrorSchema, - domainEvents: {}, - entrypoint: defineTenantModuleEntrypoint({ - access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, - entrypointKey: 'party.registry.unarchive-organization-engagement', - moduleKey: 'party.registry', - role: 'action', - }), - idempotency: 'required', - legalEntityScope: 'required', - owningModuleKey: 'party.registry', - payloadSchema: OrganizationEngagementLifecyclePayloadSchema, - policies: [], - resourcePermission: defineActionResourcePermission( - (payload) => ({ - permission: 'write', - resource: { - moduleId: payload.profileRef.moduleId, - resourceId: payload.profileRef.resourceId, - resourceType: payload.profileRef.resourceType, - }, - }), + ...engagementLifecycleRegistration( + 'party.registry.unarchive-organization-engagement', ), + payloadSchema: OrganizationEngagementLifecyclePayloadSchema, resultSchema: OrganizationEngagementProfileSchema, - schemaVersion: '1', }, handleEngagementLifecycle( 'active', diff --git a/app/verticals/party-registry/src/actions/unarchive-person-engagement.action.ts b/app/verticals/party-registry/src/actions/unarchive-person-engagement.action.ts index 0863b817e..194889e77 100644 --- a/app/verticals/party-registry/src/actions/unarchive-person-engagement.action.ts +++ b/app/verticals/party-registry/src/actions/unarchive-person-engagement.action.ts @@ -1,12 +1,7 @@ // @generated by OntOS Codesmith Action v1 // @ontos-action-owner party.registry // @ontos-action-slug unarchive-person-engagement -import { - defineAction, - defineActionResourcePermission, - defineTenantModuleEntrypoint, - OperationContextUnavailable, -} from '@app/core-runtime'; +import { defineAction, OperationContextUnavailable } from '@app/core-runtime'; import { Effect } from 'effect'; import { PersonEngagementLifecyclePayloadSchema, @@ -17,45 +12,16 @@ import type { PersonEngagementProfile, } from '../../shared/domain/engagement-profile.ts'; import { transitionPersonEngagementProfile } from '../services/engagement-profile-persistence.service.ts'; -import { - EngagementLifecycleErrorSchema, - handleEngagementLifecycle, -} from './engagement-lifecycle-handler.ts'; +import { handleEngagementLifecycle } from './engagement-lifecycle-handler.ts'; +import { engagementLifecycleRegistration } from './engagement-lifecycle-registration.ts'; export const unarchivePersonEngagementAction = defineAction( { - accessEvidencePolicy: { - captureMode: 'metadata_only', - policyKey: 'party.registry.unarchive-person-engagement.access.v1', - }, - actionKey: 'party.registry.unarchive-person-engagement', - auditProfile: 'standard', - domainErrorSchema: EngagementLifecycleErrorSchema, - domainEvents: {}, - entrypoint: defineTenantModuleEntrypoint({ - access: 'write', - authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, - entrypointKey: 'party.registry.unarchive-person-engagement', - moduleKey: 'party.registry', - role: 'action', - }), - idempotency: 'required', - legalEntityScope: 'required', - owningModuleKey: 'party.registry', - payloadSchema: PersonEngagementLifecyclePayloadSchema, - policies: [], - resourcePermission: defineActionResourcePermission( - (payload) => ({ - permission: 'write', - resource: { - moduleId: payload.profileRef.moduleId, - resourceId: payload.profileRef.resourceId, - resourceType: payload.profileRef.resourceType, - }, - }), + ...engagementLifecycleRegistration( + 'party.registry.unarchive-person-engagement', ), + payloadSchema: PersonEngagementLifecyclePayloadSchema, resultSchema: PersonEngagementProfileSchema, - schemaVersion: '1', }, handleEngagementLifecycle('active'), (transaction, scope) => { diff --git a/app/verticals/party-registry/tests/unit/engagement-lifecycle-registration.test.ts b/app/verticals/party-registry/tests/unit/engagement-lifecycle-registration.test.ts new file mode 100644 index 000000000..4ba57b422 --- /dev/null +++ b/app/verticals/party-registry/tests/unit/engagement-lifecycle-registration.test.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + OrganizationEngagementLifecyclePayloadSchema, + OrganizationEngagementProfileSchema, + PersonEngagementLifecyclePayloadSchema, + PersonEngagementProfileSchema, +} from '../../shared/domain/engagement-profile.ts'; +import { archiveOrganizationEngagementAction } from '../../src/actions/archive-organization-engagement.action.ts'; +import { archivePersonEngagementAction } from '../../src/actions/archive-person-engagement.action.ts'; +import { unarchiveOrganizationEngagementAction } from '../../src/actions/unarchive-organization-engagement.action.ts'; +import { unarchivePersonEngagementAction } from '../../src/actions/unarchive-person-engagement.action.ts'; + +const cases = [ + [ + 'archive-person', + archivePersonEngagementAction, + PersonEngagementLifecyclePayloadSchema, + PersonEngagementProfileSchema, + ], + [ + 'unarchive-person', + unarchivePersonEngagementAction, + PersonEngagementLifecyclePayloadSchema, + PersonEngagementProfileSchema, + ], + [ + 'archive-organization', + archiveOrganizationEngagementAction, + OrganizationEngagementLifecyclePayloadSchema, + OrganizationEngagementProfileSchema, + ], + [ + 'unarchive-organization', + unarchiveOrganizationEngagementAction, + OrganizationEngagementLifecyclePayloadSchema, + OrganizationEngagementProfileSchema, + ], +] as const; + +for (const [slug, action, payloadSchema, resultSchema] of cases) { + test(`${slug} engagement retains its governed registration and exact schemas`, () => { + const { descriptor } = action; + const key = `party.registry.${slug}-engagement`; + assert.equal(descriptor.actionKey, key); + assert.equal(descriptor.entrypoint.entrypointKey, key); + assert.deepEqual(descriptor.accessEvidencePolicy, { + captureMode: 'metadata_only', + policyKey: `${key}.access.v1`, + }); + assert.equal(descriptor.payloadSchema, payloadSchema); + assert.equal(descriptor.resultSchema, resultSchema); + assert.equal(descriptor.legalEntityScope, 'required'); + assert.equal(descriptor.idempotency, 'required'); + assert.equal(descriptor.resourcePermission?.kind, 'resource'); + assert.equal(descriptor.entrypoint.access, 'write'); + assert.deepEqual(descriptor.entrypoint.authorization, { + kind: 'action_execution', + provisioning: 'tenant_membership_default', + }); + }); +} From f7bf54b731df56ac488f94f0c66bfeea319f35b2 Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Tue, 8 Sep 2026 12:42:05 +0200 Subject: [PATCH 07/13] test: use a real server environment subprocess fixture Replace evaluated child-source imports with a static-import fixture while retaining the foreign working directory and all three configuration-path assertions. Keep the authentication ROOT_ENV_PATH export: the actual test consumes it, so deletion would be incorrect. Validation: all four root environment tests, scoped lint and format pass. Fresh combined analyzer verification follows remaining in-flight integration. Co-Authored-By: Claude Fable 5.1 --- app/scripts/tests/root-environment.test.mts | 26 +++---------------- .../server-environment-paths.fixture.mts | 11 ++++++++ 2 files changed, 14 insertions(+), 23 deletions(-) create mode 100644 app/scripts/tests/server-environment-paths.fixture.mts diff --git a/app/scripts/tests/root-environment.test.mts b/app/scripts/tests/root-environment.test.mts index 593b249a6..1a2bebeeb 100644 --- a/app/scripts/tests/root-environment.test.mts +++ b/app/scripts/tests/root-environment.test.mts @@ -4,7 +4,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import test from 'node:test'; -import { pathToFileURL } from 'node:url'; +import { fileURLToPath } from 'node:url'; const appRoot = path.resolve(import.meta.dirname, '../..'); const repositoryRoot = path.dirname(appRoot); @@ -45,25 +45,7 @@ void test('workspace discovery resolves repository, app, shell, and microvertica }); void test('all server configuration resolves the app-root .env from any invocation directory', () => { - const databaseConfigUrl = pathToFileURL( - path.join(appRoot, 'packages/core-runtime/src/db/config.ts'), - ).href; - const permissionConfigUrl = pathToFileURL( - path.join(appRoot, 'packages/core-runtime/src/permissions/config.ts'), - ).href; - const authConfigUrl = pathToFileURL( - path.join(appRoot, 'apps/shell-super-app/api/auth/config.ts'), - ).href; - const source = ` - const database = await import(${JSON.stringify(databaseConfigUrl)}); - const permissions = await import(${JSON.stringify(permissionConfigUrl)}); - const auth = await import(${JSON.stringify(authConfigUrl)}); - console.log(JSON.stringify([ - database.ROOT_ENV_PATH, - permissions.SPICEDB_ROOT_ENV_PATH, - auth.ROOT_ENV_PATH, - ])); - `; + const probe = new URL('server-environment-paths.fixture.mts', import.meta.url); const child = spawnSync( '/usr/bin/env', [ @@ -71,9 +53,7 @@ void test('all server configuration resolves the app-root .env from any invocati 'ULTRAMODERN_WORKSPACE_ROOT', `INIT_CWD=${repositoryRoot}`, process.execPath, - '--input-type=module', - '--eval', - source, + fileURLToPath(probe), ], { cwd: '/', diff --git a/app/scripts/tests/server-environment-paths.fixture.mts b/app/scripts/tests/server-environment-paths.fixture.mts new file mode 100644 index 000000000..1901901fe --- /dev/null +++ b/app/scripts/tests/server-environment-paths.fixture.mts @@ -0,0 +1,11 @@ +import { NodeRuntime } from '@effect/platform-node'; +import { Console } from 'effect'; +import { ROOT_ENV_PATH as databaseEnvironmentPath } from '../../packages/core-runtime/src/db/config.ts'; +import { SPICEDB_ROOT_ENV_PATH } from '../../packages/core-runtime/src/permissions/config.ts'; +import { ROOT_ENV_PATH as authenticationEnvironmentPath } from '../../apps/shell-super-app/api/auth/config.ts'; + +NodeRuntime.runMain( + Console.log( + JSON.stringify([databaseEnvironmentPath, SPICEDB_ROOT_ENV_PATH, authenticationEnvironmentPath]), + ), +); From df81475c439ffd4e7ac0a75b32f8ff586700a756 Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Tue, 8 Sep 2026 13:09:54 +0200 Subject: [PATCH 08/13] refactor: consolidate governed transport and preserve strict owner contracts Share problem mapping and Effect BFF transport across 36 generated adapters, keep authorization and schema provenance fail-closed, and recognize owner-local lifecycle registrations. Move private search normalization outside the generated provider surface.\n\nCo-Authored-By: Claude Fable 5.1 --- .../shared-contracts/src/client-runtime.ts | 54 +++ .../src/effect-bff-runtime.ts | 95 +++++ .../tests/unit/governed-runtime.test.ts | 209 ++++++++++ .../check-module-entrypoint-boundaries.mts | 79 +++- .../generated-governed-http-boundary.mts | 235 ++++++----- app/scripts/generated-module-api-boundary.mts | 373 +++++++++--------- .../governed-contribution/scaffold.mts | 133 +++---- .../tests/scaffold-generators.test.mts | 95 ++++- .../module-entrypoint-boundaries.test.mts | 331 +++++++++++++--- .../api/ares-lookup-read-server.ts | 79 +--- .../api/counterparties-search-server.ts | 79 +--- .../api/counterparty-read-read-server.ts | 79 +--- .../counterparty-role-history-read-server.ts | 79 +--- .../duplicate-candidate-detail-read-server.ts | 79 +--- ...nization-engagement-profile-read-server.ts | 79 +--- .../api/parties-search-server.ts | 79 +--- .../party-contact-point-detail-read-server.ts | 79 +--- .../api/party-contact-points-read-server.ts | 79 +--- .../api/party-correction-read-server.ts | 79 +--- .../api/party-detail-read-server.ts | 79 +--- .../api/party-match-decision-read-server.ts | 79 +--- .../api/party-match-read-server.ts | 79 +--- .../api/party-merge-readiness-read-server.ts | 79 +--- ...-official-identifier-detail-read-server.ts | 79 +--- ...official-identifier-history-read-server.ts | 79 +--- .../party-relationship-detail-read-server.ts | 79 +--- .../person-engagement-profile-read-server.ts | 79 +--- app/verticals/party-registry/shared/api.ts | 2 - .../src/api/ares-lookup-client.ts | 21 +- .../src/api/counterparties-search-client.ts | 21 +- .../src/api/counterparty-read-client.ts | 21 +- .../src/api/counterparty-read.read.ts | 6 +- .../api/counterparty-role-history-client.ts | 21 +- .../src/api/counterparty-role-history.read.ts | 6 +- .../api/duplicate-candidate-detail-client.ts | 21 +- .../organization-engagement-profile-client.ts | 21 +- .../src/api/parties-search-client.ts | 21 +- .../api/party-contact-point-detail-client.ts | 21 +- .../src/api/party-contact-points-client.ts | 21 +- .../src/api/party-correction-client.ts | 21 +- .../src/api/party-detail-client.ts | 21 +- .../src/api/party-match-client.ts | 21 +- .../src/api/party-match-decision-client.ts | 21 +- .../src/api/party-merge-readiness-client.ts | 21 +- ...party-official-identifier-detail-client.ts | 21 +- ...arty-official-identifier-history-client.ts | 21 +- .../api/party-relationship-detail-client.ts | 21 +- .../api/person-engagement-profile-client.ts | 21 +- .../src/{search => }/search-normalization.ts | 4 +- .../src/search/counterparties.provider.ts | 2 +- .../src/search/parties.provider.ts | 2 +- 51 files changed, 1579 insertions(+), 1847 deletions(-) create mode 100644 app/packages/shared-contracts/tests/unit/governed-runtime.test.ts rename app/verticals/party-registry/src/{search => }/search-normalization.ts (72%) diff --git a/app/packages/shared-contracts/src/client-runtime.ts b/app/packages/shared-contracts/src/client-runtime.ts index 6da501a10..e25614b78 100644 --- a/app/packages/shared-contracts/src/client-runtime.ts +++ b/app/packages/shared-contracts/src/client-runtime.ts @@ -4,6 +4,7 @@ import type { HttpApi, HttpApiGroup, } from '@modern-js/plugin-bff/effect-client'; +import { Redacted } from 'effect'; import { Headers as HttpHeaders, HttpClient, HttpClientRequest } from 'effect/unstable/http'; const EffectBffOperationContextSchema = Schema.Struct({ @@ -98,3 +99,56 @@ export const makeEffectBffClient = { + readonly api: HttpApi.HttpApi; + readonly credential: Redacted.Redacted; + readonly defaultApiPrefix: string | URL; + readonly requestCorrelation: string; +} + +const isGovernedBaseUrl = (value: string): boolean => { + const url = URL.parse(value, 'https://relative-owner.invalid'); + return ( + value.trim() === value && + !value.includes('\\') && + !value.startsWith('//') && + (value.startsWith('/') || /^https?:\/\//u.test(value)) && + url !== null && + (url.protocol === 'https:' || url.protocol === 'http:') && + url.username === '' && + url.password === '' + ); +}; + +/** Fresh per-invocation transport; credentials remain redacted until HTTP header construction. */ +export const makeGovernedEffectBffClient = < + ApiId extends string, + Groups extends HttpApiGroup.Constraint, +>( + { + api, + credential, + defaultApiPrefix, + requestCorrelation, + }: GovernedEffectBffClientConfig, + options: Pick, +) => { + const baseUrl = String(options.baseUrl ?? defaultApiPrefix); + const clientConfig = { + api, + baseUrl, + defaultApiPrefix, + transportHeaders: { + authorization: Redacted.value(credential), + 'x-correlation-id': requestCorrelation, + }, + }; + return Schema.decodeUnknownEffect(Schema.Literal(true))(isGovernedBaseUrl(baseUrl)).pipe( + Effect.map(() => clientConfig), + Effect.flatMap(makeEffectBffClient), + ); +}; diff --git a/app/packages/shared-contracts/src/effect-bff-runtime.ts b/app/packages/shared-contracts/src/effect-bff-runtime.ts index 0e44feb70..e67d6ed8d 100644 --- a/app/packages/shared-contracts/src/effect-bff-runtime.ts +++ b/app/packages/shared-contracts/src/effect-bff-runtime.ts @@ -1,5 +1,6 @@ /** Server-only assembly for the invariant tail of a strict Effect BFF runtime factory. */ /* oxlint-disable effect-native/no-dependency-parameters -- The approved BFF assembly seam intentionally accepts caller-composed Layers; expires: 2027-09-07. */ +import { governedReadHttpStatus } from '@app/core-runtime/http/governed-read'; import { defineEffectBff, HttpApiBuilder, Layer } from '@modern-js/plugin-bff/effect-edge'; import type { EffectRuntimeRequirements, @@ -33,3 +34,97 @@ export const assembleEffectBffRuntime = < return defineEffectBff({ api, layer }); }; + +interface GovernedProblemFields { + readonly detail: string; + readonly status: Status; + readonly title: string; + readonly type: string; +} + +interface GovernedProblemConstructor { + readonly make: (fields: GovernedProblemFields) => Problem; +} + +/** Keeps endpoint-specific error types while centralizing sanitized governed-read responses. */ +export const makeGovernedReadProblems = < + Authentication extends { readonly status: 401 }, + Forbidden extends { readonly status: 403 }, + Internal extends { readonly status: 500 }, + Invalid extends { readonly status: 400 }, + NotFound extends { readonly status: 404 }, + PolicyConflict extends { readonly status: 409 }, + PolicyIneligible extends { readonly status: 422 }, + Unavailable extends { readonly retryable: true; readonly status: 503 }, +>(schemas: { + readonly authentication: GovernedProblemConstructor<401, Authentication>; + readonly forbidden: GovernedProblemConstructor<403, Forbidden>; + readonly internal: GovernedProblemConstructor<500, Internal>; + readonly invalid: GovernedProblemConstructor<400, Invalid>; + readonly notFound: GovernedProblemConstructor<404, NotFound>; + readonly policyConflict: GovernedProblemConstructor<409, PolicyConflict>; + readonly policyIneligible: GovernedProblemConstructor<422, PolicyIneligible>; + readonly unavailable: { + readonly make: ( + fields: GovernedProblemFields<503> & { readonly retryable: true }, + ) => Unavailable; + }; +}) => ({ + authentication: () => + schemas.authentication.make({ + detail: 'A valid audience-scoped Bearer assertion is required.', + status: governedReadHttpStatus.authentication, + title: 'Authentication required', + type: 'https://ontos.dev/problems/operation-authentication-required', + }), + forbidden: () => + schemas.forbidden.make({ + detail: 'The principal is not permitted to perform this read.', + status: governedReadHttpStatus.forbidden, + title: 'Read forbidden', + type: 'https://ontos.dev/problems/read-forbidden', + }), + internal: () => + schemas.internal.make({ + detail: 'The governed read could not be completed.', + status: governedReadHttpStatus.internal, + title: 'Read failed', + type: 'https://ontos.dev/problems/read-failed', + }), + invalid: () => + schemas.invalid.make({ + detail: 'The governed read request is invalid.', + status: governedReadHttpStatus.invalid, + title: 'Invalid read request', + type: 'https://ontos.dev/problems/read-invalid', + }), + notFound: () => + schemas.notFound.make({ + detail: 'The requested resource was not found.', + status: governedReadHttpStatus.notFound, + title: 'Resource not found', + type: 'https://ontos.dev/problems/read-not-found', + }), + policyConflict: () => + schemas.policyConflict.make({ + detail: 'The read conflicts with the current business state.', + status: governedReadHttpStatus.policyConflict, + title: 'Read conflict', + type: 'https://ontos.dev/problems/read-policy-conflict', + }), + policyIneligible: () => + schemas.policyIneligible.make({ + detail: 'The read is not eligible under the current business policy.', + status: governedReadHttpStatus.policyIneligible, + title: 'Read ineligible', + type: 'https://ontos.dev/problems/read-policy-denied', + }), + unavailable: () => + schemas.unavailable.make({ + detail: 'The governed read is temporarily unavailable.', + retryable: true, + status: governedReadHttpStatus.unavailable, + title: 'Read unavailable', + type: 'https://ontos.dev/problems/read-unavailable', + }), +}); diff --git a/app/packages/shared-contracts/tests/unit/governed-runtime.test.ts b/app/packages/shared-contracts/tests/unit/governed-runtime.test.ts new file mode 100644 index 000000000..e1eead8b6 --- /dev/null +++ b/app/packages/shared-contracts/tests/unit/governed-runtime.test.ts @@ -0,0 +1,209 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Effect, Redacted, Schema, Result, flow } from 'effect'; +import { HttpApi, HttpApiEndpoint, HttpApiGroup } from '@modern-js/plugin-bff/effect-client'; +import { FetchHttpClient } from 'effect/unstable/http'; +import { + makeEffectTestCallback, + runEffectTestPromise, +} from '../../../core-runtime/src/testing/effect-runtime.ts'; +import { makeGovernedReadProblems } from '../../src/effect-bff-runtime.ts'; +import { makeGovernedEffectBffClient } from '../../src/client-runtime.ts'; +import { + makeProblemDetailsSchema, + makeRetryableProblemDetailsSchema, +} from '../../src/problem-details.ts'; + +const schemas = { + authentication: makeProblemDetailsSchema('AuthenticationProblem', 401), + forbidden: makeProblemDetailsSchema('ForbiddenProblem', 403), + internal: makeProblemDetailsSchema('InternalProblem', 500), + invalid: makeProblemDetailsSchema('InvalidProblem', 400), + notFound: makeProblemDetailsSchema('NotFoundProblem', 404), + policyConflict: makeProblemDetailsSchema('PolicyConflictProblem', 409), + policyIneligible: makeProblemDetailsSchema('PolicyProblem', 422), + unavailable: makeRetryableProblemDetailsSchema('UnavailableProblem', 503), +}; +const problems = makeGovernedReadProblems(schemas); +const statuses = { + authentication: 401, + forbidden: 403, + internal: 500, + invalid: 400, + notFound: 404, + policyConflict: 409, + policyIneligible: 422, + unavailable: 503, +} as const; + +test('shared problem factories preserve concrete schemas, statuses, retryability and sanitized values', () => { + for (const key of Object.keys(problems)) { + // Decode the key rather than casting away the concrete schema/factory contract. + const kind = Schema.decodeUnknownSync( + Schema.Literals([ + 'authentication', + 'forbidden', + 'internal', + 'invalid', + 'notFound', + 'policyConflict', + 'policyIneligible', + 'unavailable', + ]), + )(key); + const problem = problems[kind](); + assert.equal(Schema.is(schemas[kind])(problem), true); + assert.equal(problem.status, statuses[kind]); + assert.equal('retryable' in problem, kind === 'unavailable'); + assert.equal(problem.type.startsWith('https://ontos.dev/problems/'), true); + assert.notEqual(problems[kind](), problem); + } + const unavailable: typeof schemas.unavailable.Type = problems.unavailable(); + assert.equal(unavailable.retryable, true); + assert.deepEqual( + problems.authentication(), + schemas.authentication.make({ + detail: 'A valid audience-scoped Bearer assertion is required.', + status: 401, + title: 'Authentication required', + type: 'https://ontos.dev/problems/operation-authentication-required', + }), + ); + assert.deepEqual( + problems.internal(), + schemas.internal.make({ + detail: 'The governed read could not be completed.', + status: 500, + title: 'Read failed', + type: 'https://ontos.dev/problems/read-failed', + }), + ); +}); + +const api = HttpApi.make('GovernedTransportTest').add( + HttpApiGroup.make('read').add( + HttpApiEndpoint.get('execute', '/read', { error: schemas.unavailable, success: Schema.String }), + ), +); +const makeClient = (credential: string, requestCorrelation: string, baseUrl: string | URL) => + makeGovernedEffectBffClient( + { + api, + credential: Redacted.make(credential), + defaultApiPrefix: '/owner-api', + requestCorrelation, + }, + { baseUrl }, + ); + +test( + 'shared transport is lazy and keeps each invocation credential, correlation and trusted URL', + makeEffectTestCallback( + Effect.gen(function* checkTransport() { + const requests: Request[] = []; + const fetch: typeof globalThis.fetch = flow( + (input: RequestInfo | URL, init?: RequestInit) => + Effect.sync(() => { + requests.push(new Request(input, init)); + return Response.json('ok'); + }), + runEffectTestPromise, + ); + const url = new URL('https://owner.example/custom'); + const first = makeClient('Bearer first', 'first-correlation', url); + url.protocol = 'ftp:'; + url.hostname = 'attacker.example'; + const second = makeClient( + 'Bearer second', + 'second-correlation', + 'https://owner.example/custom', + ); + assert.equal(requests.length, 0); + for (const client of [first, second]) { + const result = yield* client.pipe( + Effect.flatMap((value) => value.read.execute({})), + Effect.provideService(FetchHttpClient.Fetch, fetch), + ); + assert.equal(result, 'ok'); + } + assert.deepEqual( + requests.map((request) => [ + request.url, + request.headers.get('authorization'), + request.headers.get('x-correlation-id'), + ]), + [ + ['https://owner.example/custom/read', 'Bearer first', 'first-correlation'], + ['https://owner.example/custom/read', 'Bearer second', 'second-correlation'], + ], + ); + }), + ), +); + +test( + 'shared transport retains the concrete retryable backend error union', + makeEffectTestCallback( + Effect.gen(function* checkTypedFailure() { + const fetch: typeof globalThis.fetch = flow( + () => + Effect.sync(() => + Response.json(problems.unavailable(), { + headers: { 'content-type': 'application/problem+json' }, + status: 503, + }), + ), + runEffectTestPromise, + ); + const result = yield* makeClient( + 'Bearer proof', + 'correlation', + 'https://owner.example/api', + ).pipe( + Effect.flatMap((client) => client.read.execute({})), + Effect.provideService(FetchHttpClient.Fetch, fetch), + Effect.result, + ); + assert.equal(Result.isFailure(result), true); + if (Result.isFailure(result)) { + assert.equal(Schema.is(schemas.unavailable)(result.failure), true); + } + }), + ), +); + +for (const baseUrl of [ + 'data:text/plain,unsafe', + 'https://user:password@owner.example/api', + '//attacker.example/api', +]) { + test( + `shared transport rejects unsafe URL ${baseUrl} before fetch`, + makeEffectTestCallback( + Effect.gen(function* checkUnsafeUrl() { + let calls = 0; + const fetch: typeof globalThis.fetch = flow( + () => + Effect.sync(() => { + calls += 1; + return Response.json('unsafe'); + }), + runEffectTestPromise, + ); + const result = yield* makeClient('Bearer secret', 'correlation', baseUrl).pipe( + Effect.flatMap((client) => client.read.execute({})), + Effect.provideService(FetchHttpClient.Fetch, fetch), + Effect.result, + ); + assert.equal(Result.isFailure(result), true); + if (Result.isFailure(result)) { + assert.equal(Schema.isSchemaError(result.failure), true); + if (Schema.isSchemaError(result.failure)) { + assert.doesNotMatch(result.failure.message, /password|Bearer secret|owner\.example/u); + } + } + assert.equal(calls, 0); + }), + ), + ); +} diff --git a/app/scripts/check-module-entrypoint-boundaries.mts b/app/scripts/check-module-entrypoint-boundaries.mts index 03d05a338..b78840594 100644 --- a/app/scripts/check-module-entrypoint-boundaries.mts +++ b/app/scripts/check-module-entrypoint-boundaries.mts @@ -37,6 +37,7 @@ import { generatedProviderIdentities, hasExactGeneratedProviderIdentityTopology, hasGeneratedGovernedClientContract, + hasEngagementLifecycleRegistrationContract, hasGeneratedOperationGatewayContract, hasMatchingGeneratedProviderAuthorization, hasGeneratedProviderApiContract, @@ -50,6 +51,7 @@ import { } from './generated-governed-http-boundary.mts'; const SOURCE_EXTENSIONS = new Set(['.js', '.jsx', '.mjs', '.mts', '.ts', '.tsx']); +const ACTION_EXTENSION = '.action.ts'; const ACTION_HEADER = '// @generated by OntOS Codesmith Action v1'; const WORKER_HEADER = '// @generated by OntOS Codesmith Outbox Worker v1'; const APPROVED_REMOTE_LOADER = 'apps/shell-super-app/src/routes/module-entrypoint-loader.ts'; @@ -410,14 +412,59 @@ const authorizationEquals = ( return true; }; +const sourceOrEmpty = (sourceMap: ReadonlyMap, file: string): string => + sourceMap.get(file) ?? ''; + +const readActionEntrypoints = ( + sourceMap: ReadonlyMap, + file: string, + source: string, +): readonly ParsedEntrypoint[] => { + const inline = readEntrypoints(source); + const action = + /^verticals\/party-registry\/src\/actions\/(?(?:archive|unarchive)-(?:organization|person)-engagement)\.action\.ts$/u.exec( + file, + )?.groups?.action; + if ( + action === undefined && + !containsIdentifier(source, new Set(['engagementLifecycleRegistration'])) + ) { + return inline; + } + if ( + action === undefined || + inline.length !== 0 || + !hasEngagementLifecycleRegistrationContract( + source, + sourceOrEmpty( + sourceMap, + 'verticals/party-registry/src/actions/engagement-lifecycle-registration.ts', + ), + action, + ) + ) { + return []; + } + return [ + { + access: 'write', + authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + entrypointKey: `party.registry.${action}`, + moduleKey: 'party.registry', + role: 'action', + scope: 'Tenant', + }, + ]; +}; + const requireExactEntrypoint = ( file: string, source: string, expected: Required, remediation: string, + entrypoints = readEntrypoints(source), ): Effect.Effect => Effect.gen(function* requireExactEntrypointEffect() { - const entrypoints = readEntrypoints(source); const entrypoint = yield* requireDefined(entrypoints[0], file, remediation); if ( entrypoints.length !== 1 || @@ -433,7 +480,11 @@ const requireExactEntrypoint = ( return entrypoint; }); -const requireGeneratedActionEntrypoint = (file: string, source: string) => +const requireGeneratedActionEntrypoint = ( + sourceMap: ReadonlyMap, + file: string, + source: string, +) => Effect.gen(function* requireGeneratedActionEntrypointEffect() { if (!source.startsWith(`${ACTION_HEADER}\n`)) { yield* fail(file, 'Actions must be created and maintained with scaffold:action'); @@ -444,7 +495,8 @@ const requireGeneratedActionEntrypoint = (file: string, source: string) => 'regenerate this Action with scaffold:action so it has its governed action/write entrypoint'; const definedOwner = yield* requireDefined(owner, file, descriptorMessage); const definedAction = yield* requireDefined(action, file, descriptorMessage); - const rawAuthorization = readEntrypoints(source)[0]?.authorization; + const entrypoints = readActionEntrypoints(sourceMap, file, source); + const rawAuthorization = entrypoints[0]?.authorization; const authorization = yield* rawAuthorization?.kind === 'action_execution' ? Effect.succeed(rawAuthorization) : fail( @@ -463,6 +515,7 @@ const requireGeneratedActionEntrypoint = (file: string, source: string) => scope: definedOwner.startsWith('core.') ? 'System' : 'Tenant', }, descriptorMessage, + entrypoints, ); }); @@ -687,9 +740,6 @@ const generatedProviderLocation = ( return undefined; }; -const sourceOrEmpty = (sourceMap: ReadonlyMap, file: string): string => - sourceMap.get(file) ?? ''; - const providerModuleId = (manifest: string): string => /@ontos-module-id (?[a-z0-9]+(?:\.[a-z0-9]+)*)/u.exec(manifest)?.groups?.moduleId ?? ''; @@ -747,7 +797,11 @@ const validateGeneratedProviderClient = ( hasGeneratedProviderApiContract(contractSource, ownerApiValue, moduleId, name, kind), hasGeneratedProviderReadContract(providerSource, moduleId, name, kind), hasMatchingGeneratedProviderAuthorization(providerSource, manifest, moduleId, name, kind), - hasGeneratedGovernedServerContract(serverSource, `${camel}ReadApiLive`), + hasGeneratedGovernedServerContract( + serverSource, + `${camel}ReadApiLive`, + sourceOrEmpty(sourceMap, `${vertical}/shared/api.ts`), + ), hasGeneratedOperationGatewayContract(gateway, deploymentAppId), hasGeneratedProviderManifest(manifest, moduleId, name, kind), hasGeneratedProviderRegistration(registration, name, kind), @@ -963,7 +1017,7 @@ const validateGeneralSource = ( }); const isInventoryDescriptorSource = (file: string): boolean => - file.endsWith('.action.ts') || + file.endsWith(ACTION_EXTENSION) || (file.endsWith('.worker.ts') && file.includes('/src/workers/')) || file.endsWith('/route.meta.ts') || (file.endsWith('.read.ts') && file.includes('/src/api/')) || @@ -978,7 +1032,10 @@ const appendInventoryEntries = (state: BoundaryCheckState, file: string, source: const deployment = [...state.owners.entries()].find(([appPath]) => file.startsWith(`${appPath}/`))?.[1] ?? 'shell-super-app'; - for (const entrypoint of readEntrypoints(source)) { + const entrypoints = file.endsWith(ACTION_EXTENSION) + ? readActionEntrypoints(state.sourceMap, file, source) + : readEntrypoints(source); + for (const entrypoint of entrypoints) { const descriptorMessage = 'every runtime entrypoint must declare exactly one valid authorization'; const authorization = yield* requireDefined( @@ -1005,8 +1062,8 @@ const appendInventoryEntries = (state: BoundaryCheckState, file: string, source: const validateProductionSource = (state: BoundaryCheckState, file: string, source: string) => Effect.gen(function* validateProductionSourceEffect() { - if (file.endsWith('.action.ts')) { - yield* requireGeneratedActionEntrypoint(file, source); + if (file.endsWith(ACTION_EXTENSION)) { + yield* requireGeneratedActionEntrypoint(state.sourceMap, file, source); } if (file.endsWith('.worker.ts') && file.includes('/src/workers/')) { yield* requireGeneratedWorkerEntrypoint(file, source); diff --git a/app/scripts/generated-governed-http-boundary.mts b/app/scripts/generated-governed-http-boundary.mts index d1209653f..9c1a36e02 100644 --- a/app/scripts/generated-governed-http-boundary.mts +++ b/app/scripts/generated-governed-http-boundary.mts @@ -14,6 +14,9 @@ import { } from './generated-module-api-boundary.mts'; import { Schema } from 'effect'; +const GOVERNED_API_SLOT_END = '// '; +const GOVERNED_API_SLOT_START = '// '; +const GOVERNED_HTTP_RUNTIME_MODULE = '@app/shared-contracts/server/effect-bff-runtime'; const GOVERNED_READ_HTTP_MODULE = '@app/core-runtime/http/governed-read'; const MANIFEST_API_SLOT_START = '// '; const MANIFEST_API_SLOT_END = '// '; @@ -24,7 +27,6 @@ const REPORT_KIND = 'report'; const SEARCH_PROVIDER_KIND = 'search-provider'; const GOVERNED_HANDLER_LAYER_SLOT_START = '// '; const GOVERNED_HANDLER_LAYER_SLOT_END = '// '; -const HTTP_API_MAKE = 'HttpApi.make('; const HTTP_API_CONTRACT_MODULE = 'effect/unstable/httpapi'; const GovernedReadKindSchema = Schema.Literals([ @@ -545,11 +547,7 @@ const slotIsMountedByAssembler = ( definition.includes('...') || objectPropertyValue(definition, 'api') !== expectedApi || !hasExactlyOne(maskNonCode(source), /\bassembleEffectBffRuntime\(/gu) || - !hasExactValueImport( - source, - 'assembleEffectBffRuntime', - '@app/shared-contracts/server/effect-bff-runtime', - ) + !hasExactValueImport(source, 'assembleEffectBffRuntime', GOVERNED_HTTP_RUNTIME_MODULE) ) { return false; } @@ -658,76 +656,80 @@ const slotIsInsideMountedLayer = ( return legacyRuntimeMount(source, code, runtimeSource, closing, layerName, expectedApi); }; -const effectiveApiRootRange = ( - source: string, - governed: SourceRange, - apiRoot: SourceRange, -): SourceRange => { - const governedDeclaration = maskNonCode(source, true).indexOf('export const governedHttpApi'); - const aliasedStatementEnd = - governed.value.startsWith(HTTP_API_MAKE) || governedDeclaration === -1 - ? -1 - : source.lastIndexOf(';', governedDeclaration); - return aliasedStatementEnd > apiRoot.start - ? { - end: aliasedStatementEnd, - start: apiRoot.start, - value: source.slice(apiRoot.start, aliasedStatementEnd).trimEnd(), - } - : apiRoot; +// A trailing slot comment may precede an ASI-terminated root. Stop at the next +// top-level export rather than swallowing that declaration into the fluent expression. +const apiStatementEnd = (source: string, start: number): number | undefined => { + const [semicolon] = topLevelSeparators(maskNonCode(source), ';', start); + const nextExport = [...maskComments(source).matchAll(/\bexport\s/gu)].find( + (match) => match.index > start && isTopLevelCodePosition(source, match.index), + )?.index; + if (semicolon === undefined) { + return nextExport; + } + return nextExport === undefined ? semicolon : Math.min(semicolon, nextExport); }; -const resolveGovernedApiRoot = (source: string, governed: SourceRange): SourceRange | undefined => { - let apiRoot: SourceRange | undefined; - if (governed.value.startsWith(HTTP_API_MAKE)) { - apiRoot = governed; - } else if (/^[A-Za-z][A-Za-z0-9]*$/u.test(governed.value)) { - apiRoot = assignedExpressionRange( - source, - new RegExp(`export const ${escapeRegExp(governed.value)}\\s*=\\s*`, 'u'), - ); +/** Resolve the actual exported root containing the generated slot, never an alias or decoy. */ +export const governedApiBinding = (source: string): string | undefined => { + const slot = generatedSlotRange(source, GOVERNED_API_SLOT_START, GOVERNED_API_SLOT_END); + if (slot === undefined) { + return undefined; } - return apiRoot; + const candidates = [ + ...maskComments(source).matchAll( + /export const (?[A-Za-z][A-Za-z0-9]*)\s*=\s*HttpApi\.make\(/gu, + ), + ] + .filter((match) => isTopLevelCodePosition(source, match.index)) + .map((match) => match.groups?.name) + .filter((name) => { + if (name === undefined) { + return false; + } + const root = assignedExpressionRange( + source, + new RegExp(`export const ${escapeRegExp(name)}\\s*=\\s*`, 'u'), + ); + const statementEnd = root === undefined ? undefined : apiStatementEnd(source, root.start); + return ( + root !== undefined && + statementEnd !== undefined && + slot.markerStart > root.start && + slot.markerEnd < statementEnd + ); + }); + return candidates.length === 1 ? candidates[0] : undefined; }; const governedSharedApiRoot = (source: string): SourceRange | undefined => { - const governed = assignedExpressionRange(source, /export const governedHttpApi\s*=\s*/u); - if (governed === undefined) { - return undefined; - } - const apiRoot = resolveGovernedApiRoot(source, governed); - const slot = generatedSlotRange( - source, - '// ', - '// ', - ); - if ( - apiRoot === undefined || - !apiRoot.value.startsWith(HTTP_API_MAKE) || - slot === undefined || - slot.markerStart <= apiRoot.start - ) { + const binding = governedApiBinding(source); + const apiRoot = + binding === undefined + ? undefined + : assignedExpressionRange( + source, + new RegExp(`export const ${escapeRegExp(binding)}\\s*=\\s*`, 'u'), + ); + const slot = generatedSlotRange(source, GOVERNED_API_SLOT_START, GOVERNED_API_SLOT_END); + if (apiRoot === undefined || slot === undefined) { return undefined; } - const effectiveRoot = effectiveApiRootRange(source, governed, apiRoot); - if (slot.markerEnd >= effectiveRoot.end) { + const statementEnd = apiStatementEnd(source, apiRoot.start); + if (statementEnd === undefined) { return undefined; } const additions = maskNonCode(source.slice(slot.bodyStart, slot.bodyEnd), true).trim(); - const trailing = source - .slice(slot.markerEnd + '// '.length, effectiveRoot.end) - .trim(); + const trailing = maskComments( + source + .slice(slot.markerEnd + GOVERNED_API_SLOT_END.length, statementEnd) + .replace(/^;(?=\r?\n)/u, ''), + ).trim(); return /^(?:\.addHttpApi\([A-Za-z][A-Za-z0-9]*\)\s*)*$/u.test(additions) && (trailing === '' || trailing === '.pipe(identity)') - ? effectiveRoot + ? apiRoot : undefined; }; -const governedApiBinding = (source: string): string | undefined => { - const value = assignedExpression(source, /export const governedHttpApi\s*=\s*/u); - return value?.startsWith(HTTP_API_MAKE) === true ? 'governedHttpApi' : value; -}; - const hasGovernedSharedApiRoot = (source: string): boolean => governedSharedApiRoot(source) !== undefined; @@ -1082,12 +1084,13 @@ const hasClientContract = ( const hasProblemSet = (source: string, schemaStem: string, contractImport: string): boolean => { const expression = assignedExpression(source, /const problems\s*=\s*/u); + const call = /^makeGovernedReadProblems\(/u; + const schemas = objectArgument(expression, call); if ( expression === undefined || - !expression.startsWith('{') || - matchingDelimiterEnd(expression, 0, '{', '}') !== expression.length - 1 || - expression.includes('...') || - !hasExactValueImport(source, 'governedReadHttpStatus', GOVERNED_READ_HTTP_MODULE) + schemas === undefined || + !isWholeCallExpression(expression, call) || + !hasExactValueImport(source, 'makeGovernedReadProblems', GOVERNED_HTTP_RUNTIME_MODULE) ) { return false; } @@ -1101,28 +1104,15 @@ const hasProblemSet = (source: string, schemaStem: string, contractImport: strin policyIneligible: 'PolicyProblemSchema', unavailable: 'UnavailableProblemSchema', } as const; - const entries = topLevelObjectEntries(expression); + const entries = topLevelObjectEntries(schemas); return ( entries !== undefined && entries.length === Object.keys(constructors).length && - Object.entries(constructors).every(([key, suffix]) => { - const entry = entries.find((candidate) => new RegExp(`^${key}:`, 'u').test(candidate)); - const prefix = new RegExp( - `^${key}:\\s*\\(\\)\\s*=>\\s*(?${schemaStem}${suffix}\\.make\\([\\s\\S]*)$`, - 'u', - ).exec(entry ?? ''); - const factory = prefix?.groups?.factory; - const problem = objectArgument(factory, new RegExp(`^${schemaStem}${suffix}\\.make\\(`, 'u')); - return ( - factory !== undefined && - problem !== undefined && - isWholeCallExpression(factory, new RegExp(`^${schemaStem}${suffix}\\.make\\(`, 'u')) && - !problem.includes('...') && - hasExactValueImport(source, `${schemaStem}${suffix}`, contractImport) && - objectProperty(problem, 'status') === `governedReadHttpStatus.${key}` && - (key !== 'unavailable' || objectProperty(problem, 'retryable') === 'true') - ); - }) + Object.entries(constructors).every( + ([key, suffix]) => + objectProperty(schemas, key) === `${schemaStem}${suffix}` && + hasExactValueImport(source, `${schemaStem}${suffix}`, contractImport), + ) ); }; @@ -1183,10 +1173,11 @@ const optionsFromHandlerCallback = (callbackSource: string | undefined): string const serverHandlerOptions = ( layerExpression: string | undefined, expectedGroup: string, + apiBinding: string, ): string | undefined => { if ( !wholeCall(layerExpression, /^HttpApiBuilder\.group\(/u) || - callArgument(layerExpression, /^HttpApiBuilder\.group\(/u) !== 'governedHttpApi' || + callArgument(layerExpression, /^HttpApiBuilder\.group\(/u) !== apiBinding || callArgument(layerExpression, /^HttpApiBuilder\.group\(/u, 1) !== `'${expectedGroup}'` ) { return undefined; @@ -1211,13 +1202,14 @@ const hasServerContract = ( readImport: string, schemaStem: string, contractImport: string, + apiBinding: string, ): boolean => { const code = maskComments(source); const layerExpression = assignedExpression( code, new RegExp(`export const ${escapedCamel}ReadApiLive\\s*=\\s*`, 'u'), ); - const options = serverHandlerOptions(layerExpression, escapedGroup); + const options = serverHandlerOptions(layerExpression, escapedGroup, apiBinding); return ( options !== undefined && hasServerOptions(options, `${escapedCamel}Read`) && @@ -1226,6 +1218,7 @@ const hasServerContract = ( `${escapedCamel}ReadApiLive`, new Set([ GOVERNED_READ_HTTP_MODULE, + GOVERNED_HTTP_RUNTIME_MODULE, '@modern-js/plugin-bff/effect-edge', './auth/action-principal.ts', '../shared/api.ts', @@ -1235,8 +1228,8 @@ const hasServerContract = ( ) && hasContractImports(source, { [`${escapedCamel}Read`]: readImport, + [apiBinding]: '../shared/api.ts', authenticateOperationPrincipal: './auth/action-principal.ts', - governedHttpApi: '../shared/api.ts', HttpApiBuilder: '@modern-js/plugin-bff/effect-edge', makeGovernedReadHttpHandler: GOVERNED_READ_HTTP_MODULE, }) && @@ -1487,8 +1480,8 @@ const publishesSharedApiContribution = ( ) && slotHasExactlyOneCodeMatch( sharedApi, - '// ', - '// ', + GOVERNED_API_SLOT_START, + GOVERNED_API_SLOT_END, new RegExp(`\\.addHttpApi\\(${escapedApiValue}\\)`, 'gu'), ); @@ -1653,6 +1646,43 @@ const generatedReadContributions = ( const governedOwnerModuleId = (manifest: string | undefined): string | undefined => /^\/\/ @ontos-module-id (?[^\s]+)$/mu.exec(manifest ?? '')?.groups?.moduleId; +const loadContributionSources = ( + sources: ReadonlyMap, + verticalPath: string, + contribution: GovernedReadContribution, + contractSource: string, +): + | { + readonly clientSource: string; + readonly readImport: string; + readonly readSource: string; + readonly serverSource: string; + } + | undefined => { + const readDirectory = contributionReadDirectory(contribution.kind); + const { readSuffix } = contributionProfiles[contribution.kind]; + const readPath = `src/${readDirectory}/${contribution.name}${readSuffix}.ts`; + const readSource = sources.get(`${verticalPath}/${readPath}`); + const clientSource = sources.get( + `${verticalPath}/src/api/${contribution.contractStem}-client.ts`, + ); + const serverSource = sources.get( + `${verticalPath}/api/${contributionServerStem(contribution)}-server.ts`, + ); + const header = generatedHeader(contribution.kind); + if ( + readSource === undefined || + clientSource === undefined || + serverSource === undefined || + ![contractSource, readSource, clientSource, serverSource].every((source) => + source.startsWith(header), + ) + ) { + return undefined; + } + return { clientSource, readImport: `../${readPath}`, readSource, serverSource }; +}; + export const hasCompleteGeneratedModuleApiSeam = ( sources: ReadonlyMap, sharedApiFile: string, @@ -1698,27 +1728,11 @@ export const hasCompleteGeneratedModuleApiSeam = ( const camel = toCamelCase(contribution.name); const group = contributionGroup(contribution.kind, contribution.name); const apiValue = contributionApiValue(contribution.kind, contribution.name); - const readDirectory = contributionReadDirectory(contribution.kind); - const { readSuffix } = contributionProfiles[contribution.kind]; - const serverStem = contributionServerStem(contribution); - const readSource = sources.get( - `${verticalPath}/src/${readDirectory}/${contribution.name}${readSuffix}.ts`, - ); - const clientSource = sources.get( - `${verticalPath}/src/api/${contribution.contractStem}-client.ts`, - ); - const serverSource = sources.get(`${verticalPath}/api/${serverStem}-server.ts`); - const header = generatedHeader(contribution.kind); - if ( - readSource === undefined || - clientSource === undefined || - serverSource === undefined || - ![contractSource, readSource, clientSource, serverSource].every((source) => - source.startsWith(header), - ) - ) { + const loaded = loadContributionSources(sources, verticalPath, contribution, contractSource); + if (loaded === undefined) { return false; } + const { clientSource, readImport, readSource, serverSource } = loaded; const escapedCamel = escapeRegExp(camel); const escapedGroup = escapeRegExp(group); @@ -1746,9 +1760,10 @@ export const hasCompleteGeneratedModuleApiSeam = ( serverSource, escapedCamel, escapedGroup, - `../src/${readDirectory}/${contribution.name}${readSuffix}.ts`, + readImport, schemaStem, `../shared/apis/${contribution.contractStem}.ts`, + governedApiBinding(sharedApi) ?? '', ), }; return Object.values(checks).every(Boolean); @@ -1764,13 +1779,14 @@ const readDirectoryKinds = new Map([ export const hasGeneratedGovernedServerContract = ( source: string, exportedName: string, + sharedApi: string, ): boolean => { const camel = exportedName.replace(/ReadApiLive$/u, ''); const readImport = /from '(?\.\.\/src\/(?api|search|reports)\/(?[a-z0-9-]+)\.(?:read|provider)\.ts)'/u.exec( source, ); - const { directory, name, path: readPath } = readImport?.groups ?? {}; + const [, readPath, directory, name] = readImport ?? []; if ( readPath === undefined || name === undefined || @@ -1794,6 +1810,7 @@ export const hasGeneratedGovernedServerContract = ( readPath, schemaStem, `../shared/apis/${stem}.ts`, + governedApiBinding(sharedApi) ?? '', ) ); }; diff --git a/app/scripts/generated-module-api-boundary.mts b/app/scripts/generated-module-api-boundary.mts index d70046cda..13168e04a 100644 --- a/app/scripts/generated-module-api-boundary.mts +++ b/app/scripts/generated-module-api-boundary.mts @@ -390,7 +390,6 @@ interface GovernedClientExpectation { } const MODULE_API_INVOCATION_KIND = 'module-api'; -const CORRELATION_HEADER = 'x-correlation-id'; export const hasUniqueExactNamedImport = ( source: string, @@ -516,7 +515,7 @@ const hasExactGeneratedImports = ( [ [SyntaxKind.ImportKeyword], [SyntaxKind.OpenBraceToken], - [SyntaxKind.Identifier, 'makeEffectBffClient'], + [SyntaxKind.Identifier, 'makeGovernedEffectBffClient'], [SyntaxKind.CloseBraceToken], [SyntaxKind.FromKeyword], [SyntaxKind.StringLiteral, '@app/shared-contracts/client-runtime'], @@ -639,14 +638,15 @@ const clientHelperAt = ( if (name === undefined || parametersClose === undefined) { return undefined; } - const bodyOpen = parametersClose + 2; - const bodyClose = findClosingBrace(tokens, bodyOpen); + const bodyStart = parametersClose + 2; + const bodyClose = findClosingParenthesis(tokens, bodyStart + 1, end); if (bodyClose === undefined || bodyClose + 1 >= end) { return undefined; } return matchesSequence(tokens, parametersClose + 1, [ [SyntaxKind.EqualsGreaterThanToken], - [SyntaxKind.OpenBraceToken], + [SyntaxKind.Identifier, 'makeGovernedEffectBffClient'], + [SyntaxKind.OpenParenToken], ]) && tokenKind(tokens, bodyClose + 1) === SyntaxKind.SemicolonToken ? { declarationStart: index, @@ -654,7 +654,7 @@ const clientHelperAt = ( name, parametersEnd: parametersClose, parametersStart: index + 4, - start: bodyOpen + 1, + start: bodyStart, } : undefined; }; @@ -665,20 +665,7 @@ const findClientHelper = ( ): GovernedClientHelper | undefined => { for (let index = 0; index < authorizedExportStart; index += 1) { const helper = clientHelperAt(tokens, index, authorizedExportStart); - if ( - helper !== undefined && - findTopLevelSequence( - tokens, - [ - [SyntaxKind.ConstKeyword], - [SyntaxKind.Identifier, 'clientConfig'], - [SyntaxKind.EqualsToken], - [SyntaxKind.OpenBraceToken], - ], - helper.start, - helper.end, - ) !== undefined - ) { + if (helper !== undefined) { return helper; } } @@ -764,181 +751,39 @@ const hasOnlyAllowedModuleStatements = ( return acceptsFrom(0) && optionsInterfaceSeen; }; -const hasExactTransportHeaders = ( - tokens: readonly GovernedClientToken[], - headersOpen: number, - configClose: number, -): boolean => { - const headersClose = findClosingBrace(tokens, headersOpen); - if ( - headersClose === undefined || - headersClose > configClose || - !hasExactProperties( - directObjectPropertyNames(tokens, headersOpen, headersClose), - new Set(['authorization', CORRELATION_HEADER]), - ) - ) { - return false; - } - const authorizationValue = findObjectPropertyValue( - tokens, - headersOpen, - headersClose, - 'authorization', - ); - const correlationValue = findObjectPropertyValue( - tokens, - headersOpen, - headersClose, - CORRELATION_HEADER, - ); - return ( - hasExactObjectPropertyValue(tokens, authorizationValue, [ - [SyntaxKind.Identifier, 'Redacted'], - [SyntaxKind.DotToken], - [SyntaxKind.Identifier, 'value'], - [SyntaxKind.OpenParenToken], - [SyntaxKind.Identifier, 'credential'], - [SyntaxKind.CloseParenToken], - ]) && - hasExactObjectPropertyValue(tokens, correlationValue, [ - [SyntaxKind.Identifier, 'requestCorrelation'], - ]) - ); -}; - -const hasClientConfigValues = ( - tokens: readonly GovernedClientToken[], - configOpen: number, - configClose: number, - ownerApiValue: string, - defaultApiPrefix: string, -): boolean => { - const apiValue = findObjectPropertyValue(tokens, configOpen, configClose, 'api'); - const prefixValue = findObjectPropertyValue(tokens, configOpen, configClose, 'defaultApiPrefix'); - const headersValue = findObjectPropertyValue(tokens, configOpen, configClose, 'transportHeaders'); - if ( - headersValue === undefined || - !hasExactObjectPropertyValue(tokens, apiValue, [[SyntaxKind.Identifier, ownerApiValue]]) || - !hasExactObjectPropertyValue(tokens, prefixValue, [ - [SyntaxKind.StringLiteral, defaultApiPrefix], - ]) || - tokenKind(tokens, headersValue) !== SyntaxKind.OpenBraceToken - ) { - return false; - } - const headersClose = findClosingBrace(tokens, headersValue); - return ( - headersClose !== undefined && - (tokenKind(tokens, headersClose + 1) === SyntaxKind.CommaToken || - tokenKind(tokens, headersClose + 1) === SyntaxKind.CloseBraceToken) && - hasExactTransportHeaders(tokens, headersValue, configClose) - ); -}; - -const hasClientConfig = ( +// The imported shared transport owns credential extraction, correlation headers and URL options. +// Validate its entire invocation, not merely a decoy property or helper name. +const hasGovernedTransportInvocation = ( tokens: readonly GovernedClientToken[], helper: GovernedClientHelper, ownerApiValue: string, defaultApiPrefix: string, ): boolean => { - const configDeclaration = findTopLevelSequence( - tokens, - [ - [SyntaxKind.ConstKeyword], - [SyntaxKind.Identifier, 'clientConfig'], - [SyntaxKind.EqualsToken], - [SyntaxKind.OpenBraceToken], - ], - helper.start, - helper.end, - ); - if (configDeclaration === undefined) { - return false; - } - const openBrace = configDeclaration + 3; - const closeBrace = findClosingBrace(tokens, openBrace); - if ( - closeBrace === undefined || - closeBrace >= helper.end || - configDeclaration !== helper.start || - tokenKind(tokens, closeBrace + 1) !== SyntaxKind.SemicolonToken || - !hasExactProperties( - directObjectPropertyNames(tokens, openBrace, closeBrace), - new Set(['api', 'defaultApiPrefix', 'transportHeaders']), - ) - ) { - return false; - } - return hasClientConfigValues(tokens, openBrace, closeBrace, ownerApiValue, defaultApiPrefix); -}; - -const factoryConsumesClientConfig = ( - tokens: readonly GovernedClientToken[], - helper: GovernedClientHelper, -): boolean => { - const factoryReturn = findTopLevelSequence( - tokens, - [ - [SyntaxKind.ReturnKeyword], - [SyntaxKind.Identifier, 'makeEffectBffClient'], - [SyntaxKind.OpenParenToken], - [SyntaxKind.Identifier, 'options'], - [SyntaxKind.DotToken], - [SyntaxKind.Identifier, 'baseUrl'], - [SyntaxKind.EqualsEqualsEqualsToken], - [SyntaxKind.UndefinedKeyword], - [SyntaxKind.QuestionToken], - [SyntaxKind.Identifier, 'clientConfig'], - [SyntaxKind.ColonToken], - [SyntaxKind.OpenBraceToken], - [SyntaxKind.DotDotDotToken], - [SyntaxKind.Identifier, 'clientConfig'], - [SyntaxKind.CommaToken], - [SyntaxKind.Identifier, 'baseUrl'], - [SyntaxKind.ColonToken], - [SyntaxKind.Identifier, 'options'], - [SyntaxKind.DotToken], - [SyntaxKind.Identifier, 'baseUrl'], - [SyntaxKind.CloseBraceToken], - [SyntaxKind.CommaToken], - [SyntaxKind.CloseParenToken], - [SyntaxKind.SemicolonToken], - [SyntaxKind.CloseBraceToken], - [SyntaxKind.SemicolonToken], - ], - helper.start, - helper.end, - ); - const helperTokens = tokens.slice(helper.start, helper.end); - const configDeclaration = findTopLevelSequence( - tokens, - [ - [SyntaxKind.ConstKeyword], - [SyntaxKind.Identifier, 'clientConfig'], - [SyntaxKind.EqualsToken], - [SyntaxKind.OpenBraceToken], - ], - helper.start, - helper.end, - ); - const configClose = - configDeclaration === undefined ? undefined : findClosingBrace(tokens, configDeclaration + 3); - const forbiddenControlFlow = new Set([ - SyntaxKind.DoKeyword, - SyntaxKind.ForKeyword, - SyntaxKind.IfKeyword, - SyntaxKind.SwitchKeyword, - SyntaxKind.ThrowKeyword, - SyntaxKind.TryKeyword, - SyntaxKind.WhileKeyword, - ]); + const expected = [ + [SyntaxKind.Identifier, 'makeGovernedEffectBffClient'], + [SyntaxKind.OpenParenToken], + [SyntaxKind.OpenBraceToken], + [SyntaxKind.Identifier, 'api'], + [SyntaxKind.ColonToken], + [SyntaxKind.Identifier, ownerApiValue], + [SyntaxKind.CommaToken], + [SyntaxKind.Identifier, 'credential'], + [SyntaxKind.CommaToken], + [SyntaxKind.Identifier, 'defaultApiPrefix'], + [SyntaxKind.ColonToken], + [SyntaxKind.StringLiteral, defaultApiPrefix], + [SyntaxKind.CommaToken], + [SyntaxKind.Identifier, 'requestCorrelation'], + [SyntaxKind.CommaToken], + [SyntaxKind.CloseBraceToken], + [SyntaxKind.CommaToken], + [SyntaxKind.Identifier, 'options'], + [SyntaxKind.CommaToken], + [SyntaxKind.CloseParenToken], + [SyntaxKind.SemicolonToken], + ] satisfies readonly ExpectedToken[]; return ( - factoryReturn !== undefined && - configClose !== undefined && - factoryReturn === configClose + 2 && - helperTokens.filter(({ kind }) => kind === SyntaxKind.ReturnKeyword).length === 1 && - !helperTokens.some(({ kind }) => forbiddenControlFlow.has(kind)) + helper.end === helper.start + expected.length && matchesSequence(tokens, helper.start, expected) ); }; @@ -1214,7 +1059,7 @@ const clientHelperShadowsImports = ( const requiredHelperImports = new Set([ 'Effect', 'Redacted', - 'makeEffectBffClient', + 'makeGovernedEffectBffClient', expectation.ownerApiValue, ]); return ( @@ -1582,6 +1427,144 @@ const topLevelCallObject = ( : undefined; }; +// This is a source contract, not an executable import from a deployment. Accept only +// the owner-local factory's complete declaration, including its imported constructors. +const engagementLifecycleRegistrationContract = ` +import { defineActionResourcePermission, defineTenantModuleEntrypoint } from '@app/core-runtime'; +import type { OrganizationEngagementLifecyclePayload, PersonEngagementLifecyclePayload } from '../../shared/domain/engagement-profile.ts'; +import { EngagementLifecycleErrorSchema } from './engagement-lifecycle-handler.ts'; +type EngagementLifecyclePayload = | OrganizationEngagementLifecyclePayload | PersonEngagementLifecyclePayload; +type EngagementLifecycleActionKey = \`party.registry.\${'archive' | 'unarchive'}-\${'person' | 'organization'}-engagement\`; +export const engagementLifecycleRegistration = (actionKey: EngagementLifecycleActionKey) => ({ + accessEvidencePolicy: { captureMode: 'metadata_only', policyKey: \`\${actionKey}.access.v1\` }, + actionKey, + auditProfile: 'standard', + domainErrorSchema: EngagementLifecycleErrorSchema, + domainEvents: {}, + entrypoint: defineTenantModuleEntrypoint({ + access: 'write', + authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + entrypointKey: actionKey, + moduleKey: 'party.registry', + role: 'action', + }), + idempotency: 'required', + legalEntityScope: 'required', + owningModuleKey: 'party.registry', + policies: [], + resourcePermission: defineActionResourcePermission((payload) => ({ + permission: 'write', + resource: { + moduleId: payload.profileRef.moduleId, + resourceId: payload.profileRef.resourceId, + resourceType: payload.profileRef.resourceType, + }, + })), + schemaVersion: '1', +}) as const; +`; + +const withoutTrailingCommas = (tokens: readonly GovernedClientToken[]) => + tokens.filter( + (token, index) => + token.kind !== SyntaxKind.CommaToken || + tokenKind(tokens, index - 1) === SyntaxKind.OpenBracketToken || + tokenKind(tokens, index - 1) === SyntaxKind.CommaToken || + ![ + SyntaxKind.CloseBraceToken, + SyntaxKind.CloseParenToken, + SyntaxKind.CloseBracketToken, + ].includes(tokens[index + 1]?.kind ?? SyntaxKind.Unknown), + ); + +const SOURCE_VALUE_TOKEN_KINDS = new Set([ + SyntaxKind.Identifier, + SyntaxKind.StringLiteral, + SyntaxKind.NumericLiteral, + SyntaxKind.BigIntLiteral, + SyntaxKind.NoSubstitutionTemplateLiteral, + SyntaxKind.TemplateHead, + SyntaxKind.TemplateMiddle, + SyntaxKind.TemplateTail, + SyntaxKind.RegularExpressionLiteral, +]); + +const hasExactSourceTokens = ( + tokens: readonly GovernedClientToken[], + expected: string, +): boolean => { + const actualTokens = withoutTrailingCommas(tokens); + const expectedTokens = withoutTrailingCommas(tokenizeGovernedClient(expected)); + return ( + actualTokens.length === expectedTokens.length && + expectedTokens.every( + (token, index) => + actualTokens[index]?.kind === token.kind && + (!SOURCE_VALUE_TOKEN_KINDS.has(token.kind) || actualTokens[index]?.value === token.value), + ) + ); +}; + +export const hasEngagementLifecycleRegistrationContract = ( + source: string, + registrationSource: string, + action: string, +): boolean => { + const identity = + /^(?archive|unarchive)-(?organization|person)-engagement$/u.exec( + action, + )?.groups; + if ( + identity === undefined || + !hasExactSourceTokens( + tokenizeGovernedClient(registrationSource), + engagementLifecycleRegistrationContract, + ) + ) { + return false; + } + const subject = identity.subject === 'organization' ? 'Organization' : 'Person'; + const exportedName = `${toCamelCase(action)}Action`; + const payload = `${subject}EngagementLifecyclePayload`; + const result = `${subject}EngagementProfile`; + const tokens = tokenizeGovernedClient(source); + const declaration = topLevelCallObject(tokens, exportedName, 'defineAction'); + if (declaration === undefined) { + return false; + } + const [open, close, end] = declaration; + return ( + end + 1 === tokens.length && + hasExactSourceTokens( + tokens.slice(0, open), + ` + import { defineAction, OperationContextUnavailable } from '@app/core-runtime'; + import { Effect } from 'effect'; + import { ${payload}Schema, ${result}Schema } from '../../shared/domain/engagement-profile.ts'; + import type { ${payload}, ${result} } from '../../shared/domain/engagement-profile.ts'; + import { transition${subject}EngagementProfile } from '../services/engagement-profile-persistence.service.ts'; + import { handleEngagementLifecycle } from './engagement-lifecycle-handler.ts'; + import { engagementLifecycleRegistration } from './engagement-lifecycle-registration.ts'; + export const ${exportedName} = defineAction( + `, + ) && + hasExactSourceTokens( + tokens.slice(open, close + 1), + `{ + ...engagementLifecycleRegistration<${payload}>('party.registry.${action}'), + payloadSchema: ${payload}Schema, + resultSchema: ${result}Schema, + }`, + ) && + [ + 'defineAction', + 'engagementLifecycleRegistration', + `${payload}Schema`, + `${result}Schema`, + ].every((binding) => identifierOccurrences(tokens, binding) === 2) + ); +}; + const objectHasExactString = ( tokens: readonly GovernedClientToken[], open: number, @@ -1889,12 +1872,16 @@ export const hasGeneratedGovernedClientContract = ( return ( helper !== undefined && hasExactGeneratedImports(tokens, expectation) && - hasClientConfig(tokens, helper, expectation.ownerApiValue, expectation.defaultApiPrefix) && - factoryConsumesClientConfig(tokens, helper) && + hasGovernedTransportInvocation( + tokens, + helper, + expectation.ownerApiValue, + expectation.defaultApiPrefix, + ) && exportedOperationsUseClientHelperAndGateway(tokens, helper, expectation) && hasOnlyAllowedModuleStatements(tokens, helper, expectation) && [ - 'makeEffectBffClient', + 'makeGovernedEffectBffClient', 'operationGateway', expectation.ownerApiValue, 'Effect', diff --git a/app/scripts/scaffolding/governed-contribution/scaffold.mts b/app/scripts/scaffolding/governed-contribution/scaffold.mts index 5dc1b886a..d970a677c 100644 --- a/app/scripts/scaffolding/governed-contribution/scaffold.mts +++ b/app/scripts/scaffolding/governed-contribution/scaffold.mts @@ -57,7 +57,10 @@ import { updateMutation, withExactDependencies, } from '../shared.mts'; -import { hasValidGovernedHttpCompositionRoot } from '../../generated-governed-http-boundary.mts'; +import { + governedApiBinding, + hasValidGovernedHttpCompositionRoot, +} from '../../generated-governed-http-boundary.mts'; import { planActionBoundaryScaffold } from '../microvertical-action-boundary/scaffold.mts'; import { hasGeneratedOperationGatewayContract, @@ -366,19 +369,16 @@ const renderGovernedClientConstruction = ( credential: Redacted.Redacted, requestCorrelation: string, options: ${optionsType}, -) => { - const clientConfig = { - api: ${apiValue}, - defaultApiPrefix: '/${vertical.appId}-api', - transportHeaders: { - authorization: Redacted.value(credential), - 'x-correlation-id': requestCorrelation, +) => + makeGovernedEffectBffClient( + { + api: ${apiValue}, + credential, + defaultApiPrefix: '/${vertical.appId}-api', + requestCorrelation, }, - }; - return makeEffectBffClient( - options.baseUrl === undefined ? clientConfig : { ...clientConfig, baseUrl: options.baseUrl }, - ); -};`; + options, + );`; const renderApiClient = (vertical: OntosVerticalMetadata, name: string): string => { const type = toPascalCase(name); @@ -388,7 +388,7 @@ const renderApiClient = (vertical: OntosVerticalMetadata, name: string): string const authorizedInvocationType = `${type}AuthorizedInvocation`; const operationInvocationType = `${type}OperationInvocation`; return `${generatedHeader(MODULE_API_KIND)} -import { makeEffectBffClient } from '@app/shared-contracts/client-runtime'; +import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; import { ${value} } from '../../shared/apis/${name}.ts'; import type { ${type}Request } from '../../shared/apis/${name}.ts'; @@ -505,7 +505,7 @@ const renderProviderClient = ( const optionsType = `${type}ClientOptions`; const invocationTypePrefix = `${type}${kind === REPORT_KIND ? 'Report' : 'Search'}`; return `${generatedHeader(kind)} -import { makeEffectBffClient } from '@app/shared-contracts/client-runtime'; +import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; import { ${apiValue} } from '../../shared/apis/${name}-${kind === REPORT_KIND ? REPORT_KIND : 'search'}.ts'; import type { ${type}ProviderRequest } from '../../shared/apis/${name}-${kind === REPORT_KIND ? REPORT_KIND : 'search'}.ts'; @@ -627,6 +627,7 @@ export const ${apiValue} = HttpApi.make('${apiValue}').add( }; const renderGovernedServer = ( + apiBinding: string, kind: Exclude, name: string, ): string => { @@ -655,11 +656,11 @@ const renderGovernedServer = ( const readValue = `${toCamelCase(name)}Read`; return `${generatedHeader(kind)} import { - governedReadHttpStatus, makeGovernedReadHttpHandler, } from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; -import { governedHttpApi } from '../shared/api.ts'; +import { ${apiBinding} } from '../shared/api.ts'; import { ${problemStem}AuthenticationProblemSchema, ${problemStem}ForbiddenProblemSchema, @@ -673,68 +674,19 @@ import { import { ${readValue} } from '${readImport}'; import { authenticateOperationPrincipal } from './auth/action-principal.ts'; -const problems = { - authentication: () => - ${problemStem}AuthenticationProblemSchema.make({ - detail: 'A valid audience-scoped Bearer assertion is required.', - status: governedReadHttpStatus.authentication, - title: 'Authentication required', - type: 'https://ontos.dev/problems/operation-authentication-required', - }), - forbidden: () => - ${problemStem}ForbiddenProblemSchema.make({ - detail: 'The principal is not permitted to perform this read.', - status: governedReadHttpStatus.forbidden, - title: 'Read forbidden', - type: 'https://ontos.dev/problems/read-forbidden', - }), - internal: () => - ${problemStem}InternalProblemSchema.make({ - detail: 'The governed read could not be completed.', - status: governedReadHttpStatus.internal, - title: 'Read failed', - type: 'https://ontos.dev/problems/read-failed', - }), - invalid: () => - ${problemStem}InvalidProblemSchema.make({ - detail: 'The governed read request is invalid.', - status: governedReadHttpStatus.invalid, - title: 'Invalid read request', - type: 'https://ontos.dev/problems/read-invalid', - }), - notFound: () => - ${problemStem}NotFoundProblemSchema.make({ - detail: 'The requested resource was not found.', - status: governedReadHttpStatus.notFound, - title: 'Resource not found', - type: 'https://ontos.dev/problems/read-not-found', - }), - policyConflict: () => - ${problemStem}PolicyConflictProblemSchema.make({ - detail: 'The read conflicts with the current business state.', - status: governedReadHttpStatus.policyConflict, - title: 'Read conflict', - type: 'https://ontos.dev/problems/read-policy-conflict', - }), - policyIneligible: () => - ${problemStem}PolicyProblemSchema.make({ - detail: 'The read is not eligible under the current business policy.', - status: governedReadHttpStatus.policyIneligible, - title: 'Read ineligible', - type: 'https://ontos.dev/problems/read-policy-denied', - }), - unavailable: () => - ${problemStem}UnavailableProblemSchema.make({ - detail: 'The governed read is temporarily unavailable.', - retryable: true, - status: governedReadHttpStatus.unavailable, - title: 'Read unavailable', - type: 'https://ontos.dev/problems/read-unavailable', - }), -}; +const problems = makeGovernedReadProblems({ + authentication: ${problemStem}AuthenticationProblemSchema, + forbidden: ${problemStem}ForbiddenProblemSchema, + internal: ${problemStem}InternalProblemSchema, + invalid: ${problemStem}InvalidProblemSchema, + notFound: ${problemStem}NotFoundProblemSchema, + policyConflict: ${problemStem}PolicyConflictProblemSchema, + policyIneligible: ${problemStem}PolicyProblemSchema, + unavailable: ${problemStem}UnavailableProblemSchema, +}); export const ${toCamelCase(name)}ReadApiLive = HttpApiBuilder.group( - governedHttpApi, + ${apiBinding}, '${group}', (handlers) => handlers.handle( @@ -796,7 +748,15 @@ const patchGovernedHttpComposition = Effect.fn('GovernedContributionScaffold.pat const nextSharedApi = yield* tryScaffold('failed to patch governed HTTP API root', () => insertSortedSlotIdempotently( insertSortedSlotIdempotently( - sharedApi, + sharedApi + .replace( + '// ;', + '// \n;', + ) + .replace( + /(?:\/\*\* Canonical composition-root binding consumed by generated governed HTTP adapters\. \*\/\n)?export const governedHttpApi = [A-Za-z][A-Za-z0-9]*;\n?/u, + '', + ), GOVERNED_HTTP_API_IMPORT_SLOT_START, GOVERNED_HTTP_API_IMPORT_SLOT_END, `import { ${apiValue} } from './apis/${contract}.ts';`, @@ -1348,9 +1308,24 @@ const planGovernedTransport = Effect.fn('GovernedContributionScaffold.transport' `${name}-${{ [MODULE_API_KIND]: 'read', [REPORT_KIND]: REPORT_KIND, [SEARCH_PROVIDER_KIND]: 'search' }[kind]}-server.ts`, ), ); + const sharedApiPath = yield* tryScaffold('failed to resolve governed API binding', () => + resolveContainedPath(vertical.directory, 'shared', 'api.ts'), + ); + const fileSystem = yield* FileSystem.FileSystem; + const sharedApi = yield* fileSystem + .readFileString(sharedApiPath) + .pipe( + Effect.mapError((cause) => scaffoldFailure('failed to read governed API binding', cause)), + ); + const apiBinding = governedApiBinding(sharedApi); + if (apiBinding === undefined) { + return raiseScaffoldFailure( + 'governed HTTP composition slots are not bound to the exported runtime root', + ); + } const serverMutation = yield* createOrAcceptGeneratedMutationEffect( serverPath, - renderGovernedServer(kind, name), + renderGovernedServer(apiBinding, kind, name), ); mutations.push( ...EffectArray.getSomes([serverMutation]), diff --git a/app/scripts/scaffolding/tests/scaffold-generators.test.mts b/app/scripts/scaffolding/tests/scaffold-generators.test.mts index c10df6bb3..b9df1e017 100644 --- a/app/scripts/scaffolding/tests/scaffold-generators.test.mts +++ b/app/scripts/scaffolding/tests/scaffold-generators.test.mts @@ -1122,6 +1122,79 @@ test('generated read clients fetch mounted owner URLs and support separately dep }); }); +const compactGovernedSource = (source: string): string => + source.replaceAll(/\s+/gu, '').replaceAll(/,(?=[)}\]])/gu, ''); +const inventorySharedApiFile = 'verticals/inventory-stock/shared/api.ts'; + +test('all live Party read and search transports match actual scaffold output', async () => { + await withFixture(async (fixture) => { + await addInventoryItemResourceType(fixture); + await run(fixture, scaffoldCommand.moduleApi, [ + scaffoldFlag.vertical, + inventorySlug, + '--name', + fixtureName.resourceDetail, + ]); + await run(fixture, scaffoldCommand.searchProvider, [ + scaffoldFlag.vertical, + inventorySlug, + '--name', + fixtureName.inventoryItems, + scaffoldFlag.resource, + 'item', + ]); + const owner = path.join(appRoot, 'verticals/party-registry'); + const ownerFiles = await readdir(path.join(owner, 'api')); + const serverNames = ownerFiles.filter((name) => /-(?:read|search)-server\.ts$/u.test(name)); + assert.equal(serverNames.length, 18); + await Promise.all( + serverNames.map(async (serverName) => { + const search = serverName.endsWith('-search-server.ts'); + const suffix = search ? 'search' : 'read'; + const name = serverName.slice(0, -`-${suffix}-server.ts`.length); + const camel = name.replaceAll(/-(?[a-z])/gu, (_, letter: string) => + letter.toUpperCase(), + ); + const pascal = `${camel.charAt(0).toUpperCase()}${camel.slice(1)}`; + const fixtureNameValue = search ? 'inventory-items' : 'resource-detail'; + const fixtureCamel = search ? 'inventoryItems' : 'resourceDetail'; + const fixturePascal = search ? 'InventoryItems' : 'ResourceDetail'; + const clientName = `${name}${search ? '-search' : ''}-client.ts`; + const normalize = (source: string): string => + compactGovernedSource( + source + .replaceAll(`/${name}`, `/${fixtureNameValue}`) + .replaceAll(pascal, fixturePascal) + .replaceAll(camel, fixtureCamel) + .replaceAll(`${fixturePascal}SearchClientOptions`, `${fixturePascal}ClientOptions`) + .replaceAll('partyRegistryApi', 'fixtureApi') + .replaceAll('/party-registry-api', '/inventory-stock-api'), + ); + const expectedServer = await readFixtureFile( + fixture.root, + `verticals/inventory-stock/api/${fixtureNameValue}-${suffix}-server.ts`, + ); + const expectedClient = await readFixtureFile( + fixture.root, + `verticals/inventory-stock/src/api/${fixtureNameValue}${search ? '-search' : ''}-client.ts`, + ); + assert.equal( + normalize(await readFile(path.join(owner, 'api', serverName), 'utf-8')), + compactGovernedSource(expectedServer), + serverName, + ); + assert.equal( + normalize(await readFile(path.join(owner, 'src/api', clientName), 'utf-8')), + compactGovernedSource(expectedClient), + clientName, + ); + }), + ); + const sharedApi = await readFixtureFile(fixture.root, inventorySharedApiFile); + assert.doesNotMatch(sharedApi, /governedHttpApi/u); + }); +}); + test('the migrated Party governed API slot accepts future generated additions', async () => { const source = await readFile(path.join(appRoot, partyGovernedContractPath), 'utf-8'); const next = insertSortedSlot( @@ -1167,13 +1240,13 @@ const assertRelocatedSlotRefused = async ( const assertGovernedReadClients = (clients: readonly string[]): void => { for (const client of clients) { assert.match(client, /from '@app\/shared-contracts\/client-runtime'/u); - assert.match(client, /return makeEffectBffClient\(/u); + assert.match(client, /makeGovernedEffectBffClient\(/u); assert.match(client, /defaultApiPrefix: '\/inventory-stock-api'/u); assert.match(client, /operationGateway\.invoke\(\(credential\) =>/u); assert.match(client, /WithAuthorization/u); assert.match( client, - /authorization: Redacted\.value\(credential\),\s+'x-correlation-id': requestCorrelation/u, + /credential,\s+defaultApiPrefix: '\/inventory-stock-api',\s+requestCorrelation,/u, ); assert.doesNotMatch( client, @@ -1322,10 +1395,7 @@ test('governed contribution generators patch owner contracts and lazy adapters a const reportServer = await readFixtureFile(fixture.root, inventoryReportServerFile); const moduleApiServer = await readFixtureFile(fixture.root, inventoryModuleApiServerFile); const operationBoundary = await readFixtureFile(fixture.root, inventoryActionPrincipalFile); - const composedApi = await readFixtureFile( - fixture.root, - 'verticals/inventory-stock/shared/api.ts', - ); + const composedApi = await readFixtureFile(fixture.root, inventorySharedApiFile); const composedHandlers = await readFixtureFile(fixture.root, inventoryHandlerRootFile); assert.match(searchClient, /api: InventoryItemsSearchApi,/u); assert.match(reportClient, /api: StockLevelsReportApi,/u); @@ -1428,7 +1498,7 @@ import { inventoryItemsReadApiLive } from './verticals/inventory-stock/api/inven import { inventorySuppliersReadApiLive } from './verticals/inventory-stock/api/inventory-suppliers-search-server.ts'; import { stockLevelsReadApiLive } from './verticals/inventory-stock/api/stock-levels-report-server.ts'; import generatedRuntime from './verticals/inventory-stock/api/index.ts'; -import { governedHttpApi } from './verticals/inventory-stock/shared/api.ts'; +import { fixtureApi } from './verticals/inventory-stock/shared/api.ts'; const calls = []; const readRuntime = { @@ -1441,7 +1511,7 @@ const readRuntime = { }), }; const readLayer = Layer.succeed(ReadRuntime, readRuntime); -const fixtureHandlersLive = HttpApiBuilder.group(governedHttpApi, 'fixture', (handlers) => +const fixtureHandlersLive = HttpApiBuilder.group(fixtureApi, 'fixture', (handlers) => handlers.handle('readiness', () => Effect.succeed({ ok: true })), ); const handlers = Layer.mergeAll( @@ -1453,8 +1523,8 @@ const handlers = Layer.mergeAll( stockLevelsReadApiLive, ).pipe(Layer.provide(readLayer)); const runtime = defineEffectBff({ - api: governedHttpApi, - layer: HttpApiBuilder.layer(governedHttpApi).pipe(Layer.provide(handlers)), + api: fixtureApi, + layer: HttpApiBuilder.layer(fixtureApi).pipe(Layer.provide(handlers)), }); const server = runtime.createHandler(); const generatedServer = generatedRuntime.createHandler(); @@ -1714,7 +1784,7 @@ try { // eslint-disable-next-line no-await-in-loop await writeFile(serverPath, ownedServer, 'utf-8'); } - const sharedApiPath = path.join(fixture.root, 'verticals/inventory-stock/shared/api.ts'); + const sharedApiPath = path.join(fixture.root, inventorySharedApiFile); const validSharedApi = await readFile(sharedApiPath, 'utf-8'); await writeFile( sharedApiPath, @@ -5722,6 +5792,9 @@ test('all generated files typecheck against the real workspace contracts', async '@app/shared-contracts/problem-details': [ path.join(appRoot, 'packages/shared-contracts/src/problem-details.ts'), ], + '@app/shared-contracts/server/effect-bff-runtime': [ + path.join(appRoot, 'packages/shared-contracts/src/effect-bff-runtime.ts'), + ], }, resolveJsonModule: true, skipLibCheck: true, diff --git a/app/scripts/tests/module-entrypoint-boundaries.test.mts b/app/scripts/tests/module-entrypoint-boundaries.test.mts index e27d186c0..252f10353 100644 --- a/app/scripts/tests/module-entrypoint-boundaries.test.mts +++ b/app/scripts/tests/module-entrypoint-boundaries.test.mts @@ -5,7 +5,7 @@ import os from 'node:os'; import path from 'node:path'; import nodeTest from 'node:test'; import { NodeServices } from '@effect/platform-node'; -import { ManagedRuntime } from 'effect'; +import { ManagedRuntime, Schema } from 'effect'; import { checkModuleEntrypointBoundaries as checkModuleEntrypointBoundariesEffect } from '../check-module-entrypoint-boundaries.mts'; import { assertPublishedCrossMicroVerticalContractUsage, @@ -27,6 +27,10 @@ nodeTest.after(async () => { await boundaryCheckRuntime.dispose(); }); +const ARCHIVE_ORGANIZATION_DECLARATION = 'export const archiveOrganizationEngagementAction'; +const PAYLOAD_SCHEMA_PROPERTY = 'payloadSchema:'; +const READ_ACCESS_SOURCE = "access: 'read'"; +const PUBLIC_AUTHORIZATION_SOURCE = "kind: 'public'"; const AUTHENTICATE_PRINCIPAL_BINDING = 'authenticatePrincipal: authenticateOperationPrincipal'; const STOCK_LIST_STEM = 'stock-list'; const GATEWAY_CONTRACT_FILE = 'packages/shared-contracts/src/gateway-context.ts'; @@ -36,11 +40,11 @@ const ACTION_HEADER_FOR_TEST = '// @generated by OntOS Codesmith Action v1'; const GATEWAY_BYPASS_MARKER = 'operationGateway.bypass'; const GATEWAY_INVOKE_MARKER = 'operationGateway.invoke'; const GATEWAY_IMPORT = "import { operationGateway } from './action-gateway.ts';"; -const CLIENT_CONFIG_DECLARATION = ' const clientConfig = {'; -const AUTHORIZATION_VALUE = 'authorization: Redacted.value(credential)'; -const AUTHORIZATION_VALUE_TAIL = "authorization: Redacted.value(credential) + ' bypass'"; -const CORRELATION_VALUE = "'x-correlation-id': requestCorrelation"; -const CORRELATION_VALUE_TAIL = "'x-correlation-id': requestCorrelation || 'wrong'"; +const CLIENT_CONFIG_DECLARATION = ' makeGovernedEffectBffClient('; +const AUTHORIZATION_VALUE = ' credential,'; +const AUTHORIZATION_VALUE_TAIL = " credential: Redacted.make('Bearer bypass'),"; +const CORRELATION_VALUE = ' requestCorrelation,'; +const CORRELATION_VALUE_TAIL = " requestCorrelation: requestCorrelation || 'wrong',"; const DEFAULT_API_PREFIX = "defaultApiPrefix: '/inventory-stock-api'"; const DEFAULT_API_PREFIX_TAIL = "defaultApiPrefix: '/inventory-stock-api' + '/wrong'"; const SHARED_GATEWAY_FACTORY = 'makeSharedOperationGateway(ACTION_GATEWAY_AUDIENCE, acquire)'; @@ -100,7 +104,7 @@ const governedClientFixture = (options: { options.invocationKind === MODULE_API_KIND ? `${apiStem}Request` : `${operationStem}ProviderRequest`; - return `${options.generatedHeader}import { makeEffectBffClient } from '@app/shared-contracts/client-runtime'; + return `${options.generatedHeader}import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; import { ${options.apiValue} } from '${options.contractImport}'; import type { ${requestType} } from '${options.contractImport}'; @@ -114,19 +118,16 @@ const ${options.clientHelper} = ( credential: Redacted.Redacted, requestCorrelation: string, options: ${operationStem}ClientOptions, -) => { - const clientConfig = { - api: ${options.apiValue}, - defaultApiPrefix: '/inventory-stock-api', - transportHeaders: { - authorization: Redacted.value(credential), - 'x-correlation-id': requestCorrelation, +) => + makeGovernedEffectBffClient( + { + api: ${options.apiValue}, + credential, + defaultApiPrefix: '/inventory-stock-api', + requestCorrelation, }, - }; - return makeEffectBffClient( - options.baseUrl === undefined ? clientConfig : { ...clientConfig, baseUrl: options.baseUrl }, + options, ); -}; export const ${options.authorizedOperation} = ( payload: ${requestType}, ...[credential, requestCorrelation, options = {}]: ${apiStem}AuthorizedInvocation @@ -178,16 +179,17 @@ const governedServerFixture = (options: { } const readSuffix = provider ? 'provider' : 'read'; return `${options.generatedHeader}import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; -import { governedReadHttpStatus, makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { authenticateOperationPrincipal } from './auth/action-principal.ts'; -import { governedHttpApi } from '../shared/api.ts'; +import { api } from '../shared/api.ts'; import { ${options.readValue} } from '../src/${directory}/${name}.${readSuffix}.ts'; import { ${problemKinds.map(([, suffix]) => `${schemaStem}${suffix}ProblemSchema`).join(', ')} } from '${options.contractImport}'; -const problems = { -${problemKinds.map(([key, suffix]) => ` ${key}: () => ${schemaStem}${suffix}ProblemSchema.make({ status: governedReadHttpStatus.${key}${key === 'unavailable' ? ', retryable: true' : ''} }),`).join('\n')} -}; +const problems = makeGovernedReadProblems({ +${problemKinds.map(([key, suffix]) => ` ${key}: ${schemaStem}${suffix}ProblemSchema,`).join('\n')} +}); export const ${options.exportedName} = HttpApiBuilder.group( - governedHttpApi, + api, '${options.group}', (handlers) => handlers.handle('execute', makeGovernedReadHttpHandler({ authenticatePrincipal: authenticateOperationPrincipal, @@ -228,7 +230,14 @@ test('governed servers bind the trusted handler, authentication, registration, a readValue: 'stockListRead', }); const accepts = (candidate: string): boolean => - hasGeneratedGovernedServerContract(candidate, exportedName); + hasGeneratedGovernedServerContract( + candidate, + exportedName, + `export const api = HttpApi.make('InventoryApi') +// +// +.pipe(identity);`, + ); assert.equal(accepts(source), true); assert.equal(accepts(source.replace(MODULE_API_HEADER, '')), false); assert.equal(accepts(source.replace(' problems,', ' problems: problems,')), true); @@ -243,9 +252,15 @@ test('governed servers bind the trusted handler, authentication, registration, a [' problems,', ' problems, extra: true,'], ["'@app/core-runtime/http/governed-read'", "'./fake-handler.ts'"], ["'./auth/action-principal.ts'", "'./fake-auth.ts'"], - ['governedReadHttpStatus.authentication', '200'], - ['retryable: true', 'retryable: false'], - ['StockListAuthenticationProblemSchema.make', 'OtherProblemSchema.make'], + [ + 'authentication: StockListAuthenticationProblemSchema', + 'authentication: { make: () => ({ status: 200 }) }', + ], + [ + 'unavailable: StockListUnavailableProblemSchema', + 'unavailable: { make: () => ({ status: 503, retryable: false }) }', + ], + ['authentication: StockListAuthenticationProblemSchema', 'authentication: OtherProblemSchema'], [' problems,', ' problems, ...overrides,'], ] as const) { assert.equal(accepts(source.replace(expected, replacement)), false, expected); @@ -277,7 +292,7 @@ const assertRejectedSources = async ( await assertRejectedSources(root, file, remaining, expected, index + 1); }; -const makeFixture = async (): Promise => { +const makeFixture = async (includeParty = false): Promise => { const root = await mkdtemp(path.join(os.tmpdir(), 'ontos-module-entrypoints-')); await write( root, @@ -287,6 +302,7 @@ const makeFixture = async (): Promise => { apps: [ { id: 'shell-super-app', path: 'apps/shell-super-app' }, { id: 'inventory-stock', path: INVENTORY_VERTICAL_PATH }, + ...(includeParty ? [{ id: PARTY_DEPLOYMENT_ID, path: 'verticals/party-registry' }] : []), ], }, }), @@ -353,6 +369,200 @@ test('accepts governed generated Actions, pages, Workers, catalogs, and route ma } }); +const ENGAGEMENT_ACTION_DIRECTORY = 'verticals/party-registry/src/actions'; +const ENGAGEMENT_REGISTRATION_FILE = `${ENGAGEMENT_ACTION_DIRECTORY}/engagement-lifecycle-registration.ts`; +const ENGAGEMENT_ACTIONS = [ + 'archive-organization-engagement', + 'archive-person-engagement', + 'unarchive-organization-engagement', + 'unarchive-person-engagement', +] as const; +const ENGAGEMENT_ACTION_FILE = `${ENGAGEMENT_ACTION_DIRECTORY}/${ENGAGEMENT_ACTIONS[0]}.action.ts`; +const ENGAGEMENT_REJECTION = /governed action\/write entrypoint|action_execution authorization/u; + +const writeEngagementLifecycleFixture = async (root: string): Promise => { + await Promise.all( + [ + ENGAGEMENT_REGISTRATION_FILE, + ...ENGAGEMENT_ACTIONS.map((action) => `${ENGAGEMENT_ACTION_DIRECTORY}/${action}.action.ts`), + ].map(async (file) => { + const source = await readFile(path.resolve(import.meta.dirname, '../..', file), 'utf-8'); + await write(root, file, source); + }), + ); +}; + +const EngagementInventorySchema = Schema.fromJsonString( + Schema.Struct({ + entries: Schema.Array( + Schema.Struct({ + authorization: Schema.Struct({ + kind: Schema.String, + provisioning: Schema.optional(Schema.String), + }), + deployment: Schema.String, + entrypointKey: Schema.String.pipe(Schema.brand('EntrypointKey')), + owner: Schema.String, + surface: Schema.String, + }), + ), + }), +); + +test('validates all four live shared Action registrations and inventories each concrete identity', async () => { + const root = await makeFixture(true); + try { + await writeEngagementLifecycleFixture(root); + await checkModuleEntrypointBoundaries(root); + const inventory = Schema.decodeUnknownSync(EngagementInventorySchema)( + await readFile( + path.join(root, '.codex/reports/authorization/protected-entrypoints.json'), + 'utf-8', + ), + ); + assert.deepEqual( + inventory.entries.filter((entry) => entry.owner === PARTY_MODULE_ID), + ENGAGEMENT_ACTIONS.map((action) => ({ + authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, + deployment: PARTY_DEPLOYMENT_ID, + entrypointKey: `${PARTY_MODULE_ID}.${action}`, + owner: PARTY_MODULE_ID, + surface: 'action', + })), + ); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +test('rejects shared Action identity, schema, import, shadowing, decoy and override tampering', async () => { + const root = await makeFixture(true); + try { + await writeEngagementLifecycleFixture(root); + const source = await readFile(path.join(root, ENGAGEMENT_ACTION_FILE), 'utf-8'); + const mutations = [ + [ + "'./engagement-lifecycle-registration.ts'", + "'../other/engagement-lifecycle-registration.ts'", + ], + [ + 'import { engagementLifecycleRegistration }', + 'import { engagementLifecycleRegistration as counterfeit }', + ], + [ + "'party.registry.archive-organization-engagement'", + "'party.registry.unarchive-organization-engagement'", + ], + ['// @ontos-action-owner party.registry', '// @ontos-action-owner other.owner'], + [ + '// @ontos-action-slug archive-organization-engagement', + '// @ontos-action-slug archive-person-engagement', + ], + [ + 'payloadSchema: OrganizationEngagementLifecyclePayloadSchema', + 'payloadSchema: OrganizationEngagementProfileSchema', + ], + [ + 'resultSchema: OrganizationEngagementProfileSchema', + 'resultSchema: OrganizationEngagementLifecyclePayloadSchema', + ], + [ + '...engagementLifecycleRegistration', + '...engagementLifecycleRegistration', + ], + ["'../../shared/domain/engagement-profile.ts'", "'../../shared/domain/counterfeit.ts'"], + [ARCHIVE_ORGANIZATION_DECLARATION, 'export const unarchiveOrganizationEngagementAction'], + [PAYLOAD_SCHEMA_PROPERTY, 'resourcePermission: undefined, payloadSchema:'], + [PAYLOAD_SCHEMA_PROPERTY, 'entrypoint: otherEntrypoint, payloadSchema:'], + [PAYLOAD_SCHEMA_PROPERTY, '...overrides, payloadSchema:'], + ['...engagementLifecycleRegistration', '...overrides, ...engagementLifecycleRegistration'], + [ + ARCHIVE_ORGANIZATION_DECLARATION, + 'const engagementLifecycleRegistration = () => ({}); export const archiveOrganizationEngagementAction', + ], + [ + ARCHIVE_ORGANIZATION_DECLARATION, + `const decoy = defineTenantModuleEntrypoint({ access: 'write', authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, entrypointKey: 'party.registry.archive-organization-engagement', moduleKey: 'party.registry', role: 'action' }); export const archiveOrganizationEngagementAction`, + ], + ['import { defineAction,', 'import { defineAction as unsafeAction,'], + [ + 'resultSchema: OrganizationEngagementProfileSchema', + 'resultSchema: OrganizationEngagementProfileSchema, authorization: { kind: "public" }', + ], + ] as const; + await assertRejectedSources( + root, + ENGAGEMENT_ACTION_FILE, + [ + ...mutations.map(([before, after]) => source.replace(before, after)), + `${source.replaceAll('engagementLifecycleRegistration', 'counterfeitRegistration')} +const decoy = defineTenantModuleEntrypoint({ access: 'write', authorization: { kind: 'action_execution', provisioning: 'tenant_membership_default' }, entrypointKey: 'party.registry.archive-organization-engagement', moduleKey: 'party.registry', role: 'action' });`, + ], + ENGAGEMENT_REJECTION, + ); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +test('rejects altered shared Action helper governance and constructor provenance', async () => { + const root = await makeFixture(true); + try { + await writeEngagementLifecycleFixture(root); + const source = await readFile(path.join(root, ENGAGEMENT_REGISTRATION_FILE), 'utf-8'); + const mutations = [ + ["'@app/core-runtime'", "'@app/counterfeit-runtime'"], + ['defineTenantModuleEntrypoint', 'defineSystemModuleEntrypoint'], + ["kind: 'action_execution'", PUBLIC_AUTHORIZATION_SOURCE], + ["provisioning: 'tenant_membership_default'", "provisioning: 'explicit'"], + ["access: 'write'", READ_ACCESS_SOURCE], + ["role: 'action'", "role: 'api'"], + ["moduleKey: 'party.registry'", "moduleKey: 'other.owner'"], + ['entrypointKey: actionKey', "entrypointKey: 'party.registry.wrong'"], + ["legalEntityScope: 'required'", "legalEntityScope: 'optional'"], + ["idempotency: 'required'", "idempotency: 'optional'"], + ["permission: 'write'", "permission: 'read'"], + ['moduleId: payload.profileRef.moduleId', "moduleId: 'other.owner'"], + ['resourceId: payload.profileRef.resourceId', "resourceId: 'other-resource'"], + ['resourceType: payload.profileRef.resourceType', "resourceType: 'other-type'"], + ["'./engagement-lifecycle-handler.ts'", "'./counterfeit-handler.ts'"], + ['domainErrorSchema: EngagementLifecycleErrorSchema', 'domainErrorSchema: Schema.Never'], + ["schemaVersion: '1'", "...overrides, schemaVersion: '1'"], + ['policies: []', 'policies: [,]'], + [ + 'export const engagementLifecycleRegistration', + 'const defineActionResourcePermission = () => undefined; export const engagementLifecycleRegistration', + ], + ] as const; + await assertRejectedSources( + root, + ENGAGEMENT_REGISTRATION_FILE, + mutations.map(([before, after]) => source.replace(before, after)), + ENGAGEMENT_REJECTION, + ); + await write(root, ENGAGEMENT_REGISTRATION_FILE, ''); + await assert.rejects(checkModuleEntrypointBoundaries(root), ENGAGEMENT_REJECTION); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +test('rejects shared Action registrations relocated outside their exact owner and action file', async () => { + const root = await makeFixture(true); + try { + await writeEngagementLifecycleFixture(root); + const source = await readFile(path.join(root, ENGAGEMENT_ACTION_FILE), 'utf-8'); + const wrongFile = `${ENGAGEMENT_ACTION_DIRECTORY}/archive-other-engagement.action.ts`; + await write(root, wrongFile, source); + await assert.rejects(checkModuleEntrypointBoundaries(root), ENGAGEMENT_REJECTION); + await rm(path.join(root, wrongFile)); + await write(root, ACTION_FILE, source); + await assert.rejects(checkModuleEntrypointBoundaries(root), ENGAGEMENT_REJECTION); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + const writeGovernedModuleApi = async (root: string): Promise => { const vertical = 'verticals/inventory-stock'; const header = MODULE_API_HEADER; @@ -368,7 +578,7 @@ export const api = HttpApi.make('InventoryApi') .addHttpApi(StockListApi) // ; -export const governedHttpApi = api;`, +`, ); await write( root, @@ -553,7 +763,7 @@ test('accepts only a complete generated governed module API seam', async () => { root, STOCK_LIST_READ_FILE, moduleReadFixture(STOCK_LIST_STEM, 'StockList', 'stockList').replace( - "access: 'read'", + READ_ACCESS_SOURCE, "access: 'historical_read'", ), ); @@ -884,13 +1094,13 @@ const stockListClient = (`, ); const nestedCanonicalHelperClient = validClient .replace( - ' options: StockListClientOptions,\n) => {', + ' options: StockListClientOptions,\n) =>', ` options: StockListClientOptions, ) => { const deadCanonicalClient = () => {`, ) .replace( - '\n};\nexport const executeStockListWithAuthorization', + '\n );\nexport const executeStockListWithAuthorization', ` }; return Effect.tryPromise(() => fetch('/bypass')); @@ -917,7 +1127,7 @@ export const executeStockList = (payload, requestCorrelation, options) => ); const shadowedFactoryClient = validClient.replace( CLIENT_CONFIG_DECLARATION, - ` const makeEffectBffClient = () => Effect.succeed({ bypass: true }); + ` const makeGovernedEffectBffClient = () => Effect.succeed({ bypass: true }); const clientConfig = {`, ); const invalidClients = [ @@ -929,22 +1139,22 @@ export const executeStockList = (payload, requestCorrelation, options) => validClient.replace(AUTHORIZATION_VALUE, "'x-authorization': Redacted.value(credential)"), validClient.replace(CORRELATION_VALUE, "'x-trace-id': requestCorrelation"), validClient.replace( - " 'x-correlation-id': requestCorrelation,", + CORRELATION_VALUE, " 'x-correlation-id': requestCorrelation,\n ...({ authorization: 'Bearer bypass', 'x-correlation-id': 'wrong' }),", ), validClient.replace(AUTHORIZATION_VALUE, AUTHORIZATION_VALUE_TAIL), validClient.replace(CORRELATION_VALUE, CORRELATION_VALUE_TAIL), validClient.replace(DEFAULT_API_PREFIX, DEFAULT_API_PREFIX_TAIL), validClient.replace( - ' };\n return makeEffectBffClient(', - " };\n clientConfig.transportHeaders.authorization = 'Bearer bypass';\n clientConfig.transportHeaders['x-correlation-id'] = 'wrong';\n return makeEffectBffClient(", + ' options,\n );', + " };\n clientConfig.transportHeaders.authorization = 'Bearer bypass';\n clientConfig.transportHeaders['x-correlation-id'] = 'wrong';\n return makeGovernedEffectBffClient(", ), validClient.replace('../../shared/apis/stock-list.ts', '../../shared/api.ts'), validClient.replace('./action-gateway.ts', './cached-credential-gateway.ts'), validClient.replace('api: StockListApi', 'api: OtherApi'), validClient.replace( CLIENT_CONFIG_DECLARATION, - " return makeEffectBffClient({ api: StockListApi, defaultApiPrefix: '/inventory-stock-api', transportHeaders: {} });\n const clientConfig = {", + " return makeGovernedEffectBffClient({ api: StockListApi, defaultApiPrefix: '/inventory-stock-api', transportHeaders: {} });\n const clientConfig = {", ), deadCanonicalHelperClient, nestedCanonicalHelperClient, @@ -959,7 +1169,7 @@ export const executeStockListWithAuthorization`, ), validClient.replace( CLIENT_CONFIG_DECLARATION, - ` const { makeEffectBffClient } = factoryBox; + ` const { makeGovernedEffectBffClient } = factoryBox; const clientConfig = {`, ), validClient.replace( @@ -1002,7 +1212,7 @@ fetch('/bypass');`, validClient.replace(MODULE_API_HEADER, ''), `${validClient.replace(GATEWAY_INVOKE_MARKER, GATEWAY_BYPASS_MARKER)} const spoof = 'operationGateway.invoke transportHeaders: authorization: x-correlation-id:';`, - `${validClient.replace('transportHeaders:', 'transportMetadata:')} + `${validClient.replace(CORRELATION_VALUE, ' transportMetadata: requestCorrelation,')} // operationGateway.invoke transportHeaders: authorization: 'x-correlation-id':`, ]; await assertRejectedSources( @@ -1013,6 +1223,25 @@ const spoof = 'operationGateway.invoke transportHeaders: authorization: x-correl ); const contractPath = 'verticals/inventory-stock/shared/apis/stock-list.ts'; const validContract = await readFile(path.join(root, contractPath), 'utf-8'); + await assertRejectedSources( + root, + contractPath, + [ + ...problemKinds.map(([, suffix, status]) => + validContract.replace( + `'StockList${suffix}Problem', ${status}`, + `'StockList${suffix}Problem', 200`, + ), + ), + validContract.replace( + "makeRetryableProblemDetailsSchema('StockListUnavailableProblem'", + "makeProblemDetailsSchema('StockListUnavailableProblem'", + ), + ], + /module APIs require an approved Codesmith generator/u, + ); + await write(root, contractPath, validContract); + await write( root, contractPath, @@ -1290,7 +1519,7 @@ const decoyGroup = HttpApiGroup.make('inventoryItemsSearch').add(HttpApiEndpoint ), validProviderServer.replace('registration: inventoryItemsRead', 'registration: otherRead'), validProviderServer.replace(' problems,', ' problems, ...overrides,'), - validProviderServer.replace(' governedHttpApi,', ' OtherApi,'), + validProviderServer.replace(' api,', ' OtherApi,'), validProviderServer.replace(" 'inventoryItemsSearch',", " 'wrongGroup',"), validProviderServer.replace("handlers.handle('execute',", "handlers.handle('bypass',"), `${validProviderServer.replace( @@ -1301,8 +1530,14 @@ const decoyGroup = HttpApiGroup.make('inventoryItemsSearch').add(HttpApiEndpoint "'@app/core-runtime/http/governed-read'", "'./fake-handler.ts'", ), - validProviderServer.replace('governedReadHttpStatus.authentication', '200'), - validProviderServer.replace('retryable: true', 'retryable: false'), + validProviderServer.replace( + 'authentication: InventoryItemsProviderAuthenticationProblemSchema', + 'authentication: { make: () => ({ status: 200 }) }', + ), + validProviderServer.replace( + 'unavailable: InventoryItemsProviderUnavailableProblemSchema', + 'unavailable: { make: () => ({ status: 503, retryable: false }) }', + ), `${validProviderServer}\nfetch('/bypass');`, validProviderServer.replace( providerHeader, @@ -1425,7 +1660,7 @@ const decoyGroup = HttpApiGroup.make('inventoryItemsSearch').add(HttpApiEndpoint await write( root, providerSourcePath, - validProviderSource.replace("access: 'read'", "access: 'historical_read'"), + validProviderSource.replace(READ_ACCESS_SOURCE, "access: 'historical_read'"), ); await assert.rejects( checkModuleEntrypointBoundaries(root), @@ -1451,7 +1686,7 @@ const decoyGroup = HttpApiGroup.make('inventoryItemsSearch').add(HttpApiEndpoint root, providerClientPath, [ - providerClient.replace('makeEffectBffClient', 'makeEffectHttpApiClient'), + providerClient.replace('makeGovernedEffectBffClient', 'makeEffectHttpApiClient'), providerClient.replace(GATEWAY_INVOKE_MARKER, GATEWAY_BYPASS_MARKER), providerClient.replace( AUTHORIZATION_VALUE, @@ -1459,7 +1694,7 @@ const decoyGroup = HttpApiGroup.make('inventoryItemsSearch').add(HttpApiEndpoint ), providerClient.replace(CORRELATION_VALUE, "'x-trace-id': requestCorrelation"), providerClient.replace( - " 'x-correlation-id': requestCorrelation,", + CORRELATION_VALUE, " 'x-correlation-id': requestCorrelation,\n ...({ authorization: 'Bearer bypass', 'x-correlation-id': 'wrong' }),", ), providerClient.replace(AUTHORIZATION_VALUE, AUTHORIZATION_VALUE_TAIL), @@ -1547,8 +1782,8 @@ export const stockLevelsRead = defineRead({ accessKind: 'report', entrypoint: st [ reportClient.replace('defaultApiPrefix:', 'bypassedApiPrefix:'), reportClient.replace( - ' };\n return makeEffectBffClient(', - " };\n clientConfig.transportHeaders.authorization = 'Bearer bypass';\n return makeEffectBffClient(", + ' options,\n );', + " };\n clientConfig.transportHeaders.authorization = 'Bearer bypass';\n return makeGovernedEffectBffClient(", ), reportClient.replace(AUTHORIZATION_VALUE, AUTHORIZATION_VALUE_TAIL), reportClient.replace(CORRELATION_VALUE, CORRELATION_VALUE_TAIL), diff --git a/app/verticals/party-registry/api/ares-lookup-read-server.ts b/app/verticals/party-registry/api/ares-lookup-read-server.ts index 6da578287..35184b39e 100644 --- a/app/verticals/party-registry/api/ares-lookup-read-server.ts +++ b/app/verticals/party-registry/api/ares-lookup-read-server.ts @@ -1,10 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - governedReadHttpStatus, - makeGovernedReadHttpHandler, -} from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; -import { governedHttpApi } from '../shared/api.ts'; +import { partyRegistryApi } from '../shared/api.ts'; import { AresLookupAuthenticationProblemSchema, AresLookupForbiddenProblemSchema, @@ -18,68 +16,19 @@ import { import { aresLookupRead } from '../src/api/ares-lookup.read.ts'; import { authenticateOperationPrincipal } from './auth/action-principal.ts'; -const problems = { - authentication: () => - AresLookupAuthenticationProblemSchema.make({ - detail: 'A valid audience-scoped Bearer assertion is required.', - status: governedReadHttpStatus.authentication, - title: 'Authentication required', - type: 'https://ontos.dev/problems/operation-authentication-required', - }), - forbidden: () => - AresLookupForbiddenProblemSchema.make({ - detail: 'The principal is not permitted to perform this read.', - status: governedReadHttpStatus.forbidden, - title: 'Read forbidden', - type: 'https://ontos.dev/problems/read-forbidden', - }), - internal: () => - AresLookupInternalProblemSchema.make({ - detail: 'The governed read could not be completed.', - status: governedReadHttpStatus.internal, - title: 'Read failed', - type: 'https://ontos.dev/problems/read-failed', - }), - invalid: () => - AresLookupInvalidProblemSchema.make({ - detail: 'The governed read request is invalid.', - status: governedReadHttpStatus.invalid, - title: 'Invalid read request', - type: 'https://ontos.dev/problems/read-invalid', - }), - notFound: () => - AresLookupNotFoundProblemSchema.make({ - detail: 'The requested resource was not found.', - status: governedReadHttpStatus.notFound, - title: 'Resource not found', - type: 'https://ontos.dev/problems/read-not-found', - }), - policyConflict: () => - AresLookupPolicyConflictProblemSchema.make({ - detail: 'The read conflicts with the current business state.', - status: governedReadHttpStatus.policyConflict, - title: 'Read conflict', - type: 'https://ontos.dev/problems/read-policy-conflict', - }), - policyIneligible: () => - AresLookupPolicyProblemSchema.make({ - detail: 'The read is not eligible under the current business policy.', - status: governedReadHttpStatus.policyIneligible, - title: 'Read ineligible', - type: 'https://ontos.dev/problems/read-policy-denied', - }), - unavailable: () => - AresLookupUnavailableProblemSchema.make({ - detail: 'The governed read is temporarily unavailable.', - retryable: true, - status: governedReadHttpStatus.unavailable, - title: 'Read unavailable', - type: 'https://ontos.dev/problems/read-unavailable', - }), -}; +const problems = makeGovernedReadProblems({ + authentication: AresLookupAuthenticationProblemSchema, + forbidden: AresLookupForbiddenProblemSchema, + internal: AresLookupInternalProblemSchema, + invalid: AresLookupInvalidProblemSchema, + notFound: AresLookupNotFoundProblemSchema, + policyConflict: AresLookupPolicyConflictProblemSchema, + policyIneligible: AresLookupPolicyProblemSchema, + unavailable: AresLookupUnavailableProblemSchema, +}); export const aresLookupReadApiLive = HttpApiBuilder.group( - governedHttpApi, + partyRegistryApi, 'aresLookup', (handlers) => handlers.handle( diff --git a/app/verticals/party-registry/api/counterparties-search-server.ts b/app/verticals/party-registry/api/counterparties-search-server.ts index edb21983b..e97215764 100644 --- a/app/verticals/party-registry/api/counterparties-search-server.ts +++ b/app/verticals/party-registry/api/counterparties-search-server.ts @@ -1,11 +1,9 @@ // @generated by OntOS Codesmith Governed Contribution v1 // @ontos-contribution-kind search-provider -import { - governedReadHttpStatus, - makeGovernedReadHttpHandler, -} from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; -import { governedHttpApi } from '../shared/api.ts'; +import { partyRegistryApi } from '../shared/api.ts'; import { CounterpartiesProviderAuthenticationProblemSchema, CounterpartiesProviderForbiddenProblemSchema, @@ -19,68 +17,19 @@ import { import { counterpartiesRead } from '../src/search/counterparties.provider.ts'; import { authenticateOperationPrincipal } from './auth/action-principal.ts'; -const problems = { - authentication: () => - CounterpartiesProviderAuthenticationProblemSchema.make({ - detail: 'A valid audience-scoped Bearer assertion is required.', - status: governedReadHttpStatus.authentication, - title: 'Authentication required', - type: 'https://ontos.dev/problems/operation-authentication-required', - }), - forbidden: () => - CounterpartiesProviderForbiddenProblemSchema.make({ - detail: 'The principal is not permitted to perform this read.', - status: governedReadHttpStatus.forbidden, - title: 'Read forbidden', - type: 'https://ontos.dev/problems/read-forbidden', - }), - internal: () => - CounterpartiesProviderInternalProblemSchema.make({ - detail: 'The governed read could not be completed.', - status: governedReadHttpStatus.internal, - title: 'Read failed', - type: 'https://ontos.dev/problems/read-failed', - }), - invalid: () => - CounterpartiesProviderInvalidProblemSchema.make({ - detail: 'The governed read request is invalid.', - status: governedReadHttpStatus.invalid, - title: 'Invalid read request', - type: 'https://ontos.dev/problems/read-invalid', - }), - notFound: () => - CounterpartiesProviderNotFoundProblemSchema.make({ - detail: 'The requested resource was not found.', - status: governedReadHttpStatus.notFound, - title: 'Resource not found', - type: 'https://ontos.dev/problems/read-not-found', - }), - policyConflict: () => - CounterpartiesProviderPolicyConflictProblemSchema.make({ - detail: 'The read conflicts with the current business state.', - status: governedReadHttpStatus.policyConflict, - title: 'Read conflict', - type: 'https://ontos.dev/problems/read-policy-conflict', - }), - policyIneligible: () => - CounterpartiesProviderPolicyProblemSchema.make({ - detail: 'The read is not eligible under the current business policy.', - status: governedReadHttpStatus.policyIneligible, - title: 'Read ineligible', - type: 'https://ontos.dev/problems/read-policy-denied', - }), - unavailable: () => - CounterpartiesProviderUnavailableProblemSchema.make({ - detail: 'The governed read is temporarily unavailable.', - retryable: true, - status: governedReadHttpStatus.unavailable, - title: 'Read unavailable', - type: 'https://ontos.dev/problems/read-unavailable', - }), -}; +const problems = makeGovernedReadProblems({ + authentication: CounterpartiesProviderAuthenticationProblemSchema, + forbidden: CounterpartiesProviderForbiddenProblemSchema, + internal: CounterpartiesProviderInternalProblemSchema, + invalid: CounterpartiesProviderInvalidProblemSchema, + notFound: CounterpartiesProviderNotFoundProblemSchema, + policyConflict: CounterpartiesProviderPolicyConflictProblemSchema, + policyIneligible: CounterpartiesProviderPolicyProblemSchema, + unavailable: CounterpartiesProviderUnavailableProblemSchema, +}); export const counterpartiesReadApiLive = HttpApiBuilder.group( - governedHttpApi, + partyRegistryApi, 'counterpartiesSearch', (handlers) => handlers.handle( diff --git a/app/verticals/party-registry/api/counterparty-read-read-server.ts b/app/verticals/party-registry/api/counterparty-read-read-server.ts index 7c7390999..821d82b69 100644 --- a/app/verticals/party-registry/api/counterparty-read-read-server.ts +++ b/app/verticals/party-registry/api/counterparty-read-read-server.ts @@ -1,10 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - governedReadHttpStatus, - makeGovernedReadHttpHandler, -} from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; -import { governedHttpApi } from '../shared/api.ts'; +import { partyRegistryApi } from '../shared/api.ts'; import { CounterpartyReadAuthenticationProblemSchema, CounterpartyReadForbiddenProblemSchema, @@ -18,68 +16,19 @@ import { import { counterpartyReadRead } from '../src/api/counterparty-read.read.ts'; import { authenticateOperationPrincipal } from './auth/action-principal.ts'; -const problems = { - authentication: () => - CounterpartyReadAuthenticationProblemSchema.make({ - detail: 'A valid audience-scoped Bearer assertion is required.', - status: governedReadHttpStatus.authentication, - title: 'Authentication required', - type: 'https://ontos.dev/problems/operation-authentication-required', - }), - forbidden: () => - CounterpartyReadForbiddenProblemSchema.make({ - detail: 'The principal is not permitted to perform this read.', - status: governedReadHttpStatus.forbidden, - title: 'Read forbidden', - type: 'https://ontos.dev/problems/read-forbidden', - }), - internal: () => - CounterpartyReadInternalProblemSchema.make({ - detail: 'The governed read could not be completed.', - status: governedReadHttpStatus.internal, - title: 'Read failed', - type: 'https://ontos.dev/problems/read-failed', - }), - invalid: () => - CounterpartyReadInvalidProblemSchema.make({ - detail: 'The governed read request is invalid.', - status: governedReadHttpStatus.invalid, - title: 'Invalid read request', - type: 'https://ontos.dev/problems/read-invalid', - }), - notFound: () => - CounterpartyReadNotFoundProblemSchema.make({ - detail: 'The requested resource was not found.', - status: governedReadHttpStatus.notFound, - title: 'Resource not found', - type: 'https://ontos.dev/problems/read-not-found', - }), - policyConflict: () => - CounterpartyReadPolicyConflictProblemSchema.make({ - detail: 'The read conflicts with the current business state.', - status: governedReadHttpStatus.policyConflict, - title: 'Read conflict', - type: 'https://ontos.dev/problems/read-policy-conflict', - }), - policyIneligible: () => - CounterpartyReadPolicyProblemSchema.make({ - detail: 'The read is not eligible under the current business policy.', - status: governedReadHttpStatus.policyIneligible, - title: 'Read ineligible', - type: 'https://ontos.dev/problems/read-policy-denied', - }), - unavailable: () => - CounterpartyReadUnavailableProblemSchema.make({ - detail: 'The governed read is temporarily unavailable.', - retryable: true, - status: governedReadHttpStatus.unavailable, - title: 'Read unavailable', - type: 'https://ontos.dev/problems/read-unavailable', - }), -}; +const problems = makeGovernedReadProblems({ + authentication: CounterpartyReadAuthenticationProblemSchema, + forbidden: CounterpartyReadForbiddenProblemSchema, + internal: CounterpartyReadInternalProblemSchema, + invalid: CounterpartyReadInvalidProblemSchema, + notFound: CounterpartyReadNotFoundProblemSchema, + policyConflict: CounterpartyReadPolicyConflictProblemSchema, + policyIneligible: CounterpartyReadPolicyProblemSchema, + unavailable: CounterpartyReadUnavailableProblemSchema, +}); export const counterpartyReadReadApiLive = HttpApiBuilder.group( - governedHttpApi, + partyRegistryApi, 'counterpartyRead', (handlers) => handlers.handle( diff --git a/app/verticals/party-registry/api/counterparty-role-history-read-server.ts b/app/verticals/party-registry/api/counterparty-role-history-read-server.ts index 940e7db63..35b2d7607 100644 --- a/app/verticals/party-registry/api/counterparty-role-history-read-server.ts +++ b/app/verticals/party-registry/api/counterparty-role-history-read-server.ts @@ -1,10 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - governedReadHttpStatus, - makeGovernedReadHttpHandler, -} from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; -import { governedHttpApi } from '../shared/api.ts'; +import { partyRegistryApi } from '../shared/api.ts'; import { CounterpartyRoleHistoryAuthenticationProblemSchema, CounterpartyRoleHistoryForbiddenProblemSchema, @@ -18,68 +16,19 @@ import { import { counterpartyRoleHistoryRead } from '../src/api/counterparty-role-history.read.ts'; import { authenticateOperationPrincipal } from './auth/action-principal.ts'; -const problems = { - authentication: () => - CounterpartyRoleHistoryAuthenticationProblemSchema.make({ - detail: 'A valid audience-scoped Bearer assertion is required.', - status: governedReadHttpStatus.authentication, - title: 'Authentication required', - type: 'https://ontos.dev/problems/operation-authentication-required', - }), - forbidden: () => - CounterpartyRoleHistoryForbiddenProblemSchema.make({ - detail: 'The principal is not permitted to perform this read.', - status: governedReadHttpStatus.forbidden, - title: 'Read forbidden', - type: 'https://ontos.dev/problems/read-forbidden', - }), - internal: () => - CounterpartyRoleHistoryInternalProblemSchema.make({ - detail: 'The governed read could not be completed.', - status: governedReadHttpStatus.internal, - title: 'Read failed', - type: 'https://ontos.dev/problems/read-failed', - }), - invalid: () => - CounterpartyRoleHistoryInvalidProblemSchema.make({ - detail: 'The governed read request is invalid.', - status: governedReadHttpStatus.invalid, - title: 'Invalid read request', - type: 'https://ontos.dev/problems/read-invalid', - }), - notFound: () => - CounterpartyRoleHistoryNotFoundProblemSchema.make({ - detail: 'The requested resource was not found.', - status: governedReadHttpStatus.notFound, - title: 'Resource not found', - type: 'https://ontos.dev/problems/read-not-found', - }), - policyConflict: () => - CounterpartyRoleHistoryPolicyConflictProblemSchema.make({ - detail: 'The read conflicts with the current business state.', - status: governedReadHttpStatus.policyConflict, - title: 'Read conflict', - type: 'https://ontos.dev/problems/read-policy-conflict', - }), - policyIneligible: () => - CounterpartyRoleHistoryPolicyProblemSchema.make({ - detail: 'The read is not eligible under the current business policy.', - status: governedReadHttpStatus.policyIneligible, - title: 'Read ineligible', - type: 'https://ontos.dev/problems/read-policy-denied', - }), - unavailable: () => - CounterpartyRoleHistoryUnavailableProblemSchema.make({ - detail: 'The governed read is temporarily unavailable.', - retryable: true, - status: governedReadHttpStatus.unavailable, - title: 'Read unavailable', - type: 'https://ontos.dev/problems/read-unavailable', - }), -}; +const problems = makeGovernedReadProblems({ + authentication: CounterpartyRoleHistoryAuthenticationProblemSchema, + forbidden: CounterpartyRoleHistoryForbiddenProblemSchema, + internal: CounterpartyRoleHistoryInternalProblemSchema, + invalid: CounterpartyRoleHistoryInvalidProblemSchema, + notFound: CounterpartyRoleHistoryNotFoundProblemSchema, + policyConflict: CounterpartyRoleHistoryPolicyConflictProblemSchema, + policyIneligible: CounterpartyRoleHistoryPolicyProblemSchema, + unavailable: CounterpartyRoleHistoryUnavailableProblemSchema, +}); export const counterpartyRoleHistoryReadApiLive = HttpApiBuilder.group( - governedHttpApi, + partyRegistryApi, 'counterpartyRoleHistory', (handlers) => handlers.handle( diff --git a/app/verticals/party-registry/api/duplicate-candidate-detail-read-server.ts b/app/verticals/party-registry/api/duplicate-candidate-detail-read-server.ts index f51243a8b..6fc31c5a4 100644 --- a/app/verticals/party-registry/api/duplicate-candidate-detail-read-server.ts +++ b/app/verticals/party-registry/api/duplicate-candidate-detail-read-server.ts @@ -1,10 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - governedReadHttpStatus, - makeGovernedReadHttpHandler, -} from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; -import { governedHttpApi } from '../shared/api.ts'; +import { partyRegistryApi } from '../shared/api.ts'; import { DuplicateCandidateDetailAuthenticationProblemSchema, DuplicateCandidateDetailForbiddenProblemSchema, @@ -18,68 +16,19 @@ import { import { duplicateCandidateDetailRead } from '../src/api/duplicate-candidate-detail.read.ts'; import { authenticateOperationPrincipal } from './auth/action-principal.ts'; -const problems = { - authentication: () => - DuplicateCandidateDetailAuthenticationProblemSchema.make({ - detail: 'A valid audience-scoped Bearer assertion is required.', - status: governedReadHttpStatus.authentication, - title: 'Authentication required', - type: 'https://ontos.dev/problems/operation-authentication-required', - }), - forbidden: () => - DuplicateCandidateDetailForbiddenProblemSchema.make({ - detail: 'The principal is not permitted to perform this read.', - status: governedReadHttpStatus.forbidden, - title: 'Read forbidden', - type: 'https://ontos.dev/problems/read-forbidden', - }), - internal: () => - DuplicateCandidateDetailInternalProblemSchema.make({ - detail: 'The governed read could not be completed.', - status: governedReadHttpStatus.internal, - title: 'Read failed', - type: 'https://ontos.dev/problems/read-failed', - }), - invalid: () => - DuplicateCandidateDetailInvalidProblemSchema.make({ - detail: 'The governed read request is invalid.', - status: governedReadHttpStatus.invalid, - title: 'Invalid read request', - type: 'https://ontos.dev/problems/read-invalid', - }), - notFound: () => - DuplicateCandidateDetailNotFoundProblemSchema.make({ - detail: 'The requested resource was not found.', - status: governedReadHttpStatus.notFound, - title: 'Resource not found', - type: 'https://ontos.dev/problems/read-not-found', - }), - policyConflict: () => - DuplicateCandidateDetailPolicyConflictProblemSchema.make({ - detail: 'The read conflicts with the current business state.', - status: governedReadHttpStatus.policyConflict, - title: 'Read conflict', - type: 'https://ontos.dev/problems/read-policy-conflict', - }), - policyIneligible: () => - DuplicateCandidateDetailPolicyProblemSchema.make({ - detail: 'The read is not eligible under the current business policy.', - status: governedReadHttpStatus.policyIneligible, - title: 'Read ineligible', - type: 'https://ontos.dev/problems/read-policy-denied', - }), - unavailable: () => - DuplicateCandidateDetailUnavailableProblemSchema.make({ - detail: 'The governed read is temporarily unavailable.', - retryable: true, - status: governedReadHttpStatus.unavailable, - title: 'Read unavailable', - type: 'https://ontos.dev/problems/read-unavailable', - }), -}; +const problems = makeGovernedReadProblems({ + authentication: DuplicateCandidateDetailAuthenticationProblemSchema, + forbidden: DuplicateCandidateDetailForbiddenProblemSchema, + internal: DuplicateCandidateDetailInternalProblemSchema, + invalid: DuplicateCandidateDetailInvalidProblemSchema, + notFound: DuplicateCandidateDetailNotFoundProblemSchema, + policyConflict: DuplicateCandidateDetailPolicyConflictProblemSchema, + policyIneligible: DuplicateCandidateDetailPolicyProblemSchema, + unavailable: DuplicateCandidateDetailUnavailableProblemSchema, +}); export const duplicateCandidateDetailReadApiLive = HttpApiBuilder.group( - governedHttpApi, + partyRegistryApi, 'duplicateCandidateDetail', (handlers) => handlers.handle( diff --git a/app/verticals/party-registry/api/organization-engagement-profile-read-server.ts b/app/verticals/party-registry/api/organization-engagement-profile-read-server.ts index ebba8f20b..55576c4de 100644 --- a/app/verticals/party-registry/api/organization-engagement-profile-read-server.ts +++ b/app/verticals/party-registry/api/organization-engagement-profile-read-server.ts @@ -1,10 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - governedReadHttpStatus, - makeGovernedReadHttpHandler, -} from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; -import { governedHttpApi } from '../shared/api.ts'; +import { partyRegistryApi } from '../shared/api.ts'; import { OrganizationEngagementProfileAuthenticationProblemSchema, OrganizationEngagementProfileForbiddenProblemSchema, @@ -18,68 +16,19 @@ import { import { organizationEngagementProfileRead } from '../src/api/organization-engagement-profile.read.ts'; import { authenticateOperationPrincipal } from './auth/action-principal.ts'; -const problems = { - authentication: () => - OrganizationEngagementProfileAuthenticationProblemSchema.make({ - detail: 'A valid audience-scoped Bearer assertion is required.', - status: governedReadHttpStatus.authentication, - title: 'Authentication required', - type: 'https://ontos.dev/problems/operation-authentication-required', - }), - forbidden: () => - OrganizationEngagementProfileForbiddenProblemSchema.make({ - detail: 'The principal is not permitted to perform this read.', - status: governedReadHttpStatus.forbidden, - title: 'Read forbidden', - type: 'https://ontos.dev/problems/read-forbidden', - }), - internal: () => - OrganizationEngagementProfileInternalProblemSchema.make({ - detail: 'The governed read could not be completed.', - status: governedReadHttpStatus.internal, - title: 'Read failed', - type: 'https://ontos.dev/problems/read-failed', - }), - invalid: () => - OrganizationEngagementProfileInvalidProblemSchema.make({ - detail: 'The governed read request is invalid.', - status: governedReadHttpStatus.invalid, - title: 'Invalid read request', - type: 'https://ontos.dev/problems/read-invalid', - }), - notFound: () => - OrganizationEngagementProfileNotFoundProblemSchema.make({ - detail: 'The requested resource was not found.', - status: governedReadHttpStatus.notFound, - title: 'Resource not found', - type: 'https://ontos.dev/problems/read-not-found', - }), - policyConflict: () => - OrganizationEngagementProfilePolicyConflictProblemSchema.make({ - detail: 'The read conflicts with the current business state.', - status: governedReadHttpStatus.policyConflict, - title: 'Read conflict', - type: 'https://ontos.dev/problems/read-policy-conflict', - }), - policyIneligible: () => - OrganizationEngagementProfilePolicyProblemSchema.make({ - detail: 'The read is not eligible under the current business policy.', - status: governedReadHttpStatus.policyIneligible, - title: 'Read ineligible', - type: 'https://ontos.dev/problems/read-policy-denied', - }), - unavailable: () => - OrganizationEngagementProfileUnavailableProblemSchema.make({ - detail: 'The governed read is temporarily unavailable.', - retryable: true, - status: governedReadHttpStatus.unavailable, - title: 'Read unavailable', - type: 'https://ontos.dev/problems/read-unavailable', - }), -}; +const problems = makeGovernedReadProblems({ + authentication: OrganizationEngagementProfileAuthenticationProblemSchema, + forbidden: OrganizationEngagementProfileForbiddenProblemSchema, + internal: OrganizationEngagementProfileInternalProblemSchema, + invalid: OrganizationEngagementProfileInvalidProblemSchema, + notFound: OrganizationEngagementProfileNotFoundProblemSchema, + policyConflict: OrganizationEngagementProfilePolicyConflictProblemSchema, + policyIneligible: OrganizationEngagementProfilePolicyProblemSchema, + unavailable: OrganizationEngagementProfileUnavailableProblemSchema, +}); export const organizationEngagementProfileReadApiLive = HttpApiBuilder.group( - governedHttpApi, + partyRegistryApi, 'organizationEngagementProfile', (handlers) => handlers.handle( diff --git a/app/verticals/party-registry/api/parties-search-server.ts b/app/verticals/party-registry/api/parties-search-server.ts index 9684ea7b3..ec64cecbf 100644 --- a/app/verticals/party-registry/api/parties-search-server.ts +++ b/app/verticals/party-registry/api/parties-search-server.ts @@ -1,11 +1,9 @@ // @generated by OntOS Codesmith Governed Contribution v1 // @ontos-contribution-kind search-provider -import { - governedReadHttpStatus, - makeGovernedReadHttpHandler, -} from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; -import { governedHttpApi } from '../shared/api.ts'; +import { partyRegistryApi } from '../shared/api.ts'; import { PartiesProviderAuthenticationProblemSchema, PartiesProviderForbiddenProblemSchema, @@ -19,68 +17,19 @@ import { import { partiesRead } from '../src/search/parties.provider.ts'; import { authenticateOperationPrincipal } from './auth/action-principal.ts'; -const problems = { - authentication: () => - PartiesProviderAuthenticationProblemSchema.make({ - detail: 'A valid audience-scoped Bearer assertion is required.', - status: governedReadHttpStatus.authentication, - title: 'Authentication required', - type: 'https://ontos.dev/problems/operation-authentication-required', - }), - forbidden: () => - PartiesProviderForbiddenProblemSchema.make({ - detail: 'The principal is not permitted to perform this read.', - status: governedReadHttpStatus.forbidden, - title: 'Read forbidden', - type: 'https://ontos.dev/problems/read-forbidden', - }), - internal: () => - PartiesProviderInternalProblemSchema.make({ - detail: 'The governed read could not be completed.', - status: governedReadHttpStatus.internal, - title: 'Read failed', - type: 'https://ontos.dev/problems/read-failed', - }), - invalid: () => - PartiesProviderInvalidProblemSchema.make({ - detail: 'The governed read request is invalid.', - status: governedReadHttpStatus.invalid, - title: 'Invalid read request', - type: 'https://ontos.dev/problems/read-invalid', - }), - notFound: () => - PartiesProviderNotFoundProblemSchema.make({ - detail: 'The requested resource was not found.', - status: governedReadHttpStatus.notFound, - title: 'Resource not found', - type: 'https://ontos.dev/problems/read-not-found', - }), - policyConflict: () => - PartiesProviderPolicyConflictProblemSchema.make({ - detail: 'The read conflicts with the current business state.', - status: governedReadHttpStatus.policyConflict, - title: 'Read conflict', - type: 'https://ontos.dev/problems/read-policy-conflict', - }), - policyIneligible: () => - PartiesProviderPolicyProblemSchema.make({ - detail: 'The read is not eligible under the current business policy.', - status: governedReadHttpStatus.policyIneligible, - title: 'Read ineligible', - type: 'https://ontos.dev/problems/read-policy-denied', - }), - unavailable: () => - PartiesProviderUnavailableProblemSchema.make({ - detail: 'The governed read is temporarily unavailable.', - retryable: true, - status: governedReadHttpStatus.unavailable, - title: 'Read unavailable', - type: 'https://ontos.dev/problems/read-unavailable', - }), -}; +const problems = makeGovernedReadProblems({ + authentication: PartiesProviderAuthenticationProblemSchema, + forbidden: PartiesProviderForbiddenProblemSchema, + internal: PartiesProviderInternalProblemSchema, + invalid: PartiesProviderInvalidProblemSchema, + notFound: PartiesProviderNotFoundProblemSchema, + policyConflict: PartiesProviderPolicyConflictProblemSchema, + policyIneligible: PartiesProviderPolicyProblemSchema, + unavailable: PartiesProviderUnavailableProblemSchema, +}); export const partiesReadApiLive = HttpApiBuilder.group( - governedHttpApi, + partyRegistryApi, 'partiesSearch', (handlers) => handlers.handle( diff --git a/app/verticals/party-registry/api/party-contact-point-detail-read-server.ts b/app/verticals/party-registry/api/party-contact-point-detail-read-server.ts index 0829addfa..1ef9b9b5b 100644 --- a/app/verticals/party-registry/api/party-contact-point-detail-read-server.ts +++ b/app/verticals/party-registry/api/party-contact-point-detail-read-server.ts @@ -1,10 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - governedReadHttpStatus, - makeGovernedReadHttpHandler, -} from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; -import { governedHttpApi } from '../shared/api.ts'; +import { partyRegistryApi } from '../shared/api.ts'; import { PartyContactPointDetailAuthenticationProblemSchema, PartyContactPointDetailForbiddenProblemSchema, @@ -18,68 +16,19 @@ import { import { partyContactPointDetailRead } from '../src/api/party-contact-point-detail.read.ts'; import { authenticateOperationPrincipal } from './auth/action-principal.ts'; -const problems = { - authentication: () => - PartyContactPointDetailAuthenticationProblemSchema.make({ - detail: 'A valid audience-scoped Bearer assertion is required.', - status: governedReadHttpStatus.authentication, - title: 'Authentication required', - type: 'https://ontos.dev/problems/operation-authentication-required', - }), - forbidden: () => - PartyContactPointDetailForbiddenProblemSchema.make({ - detail: 'The principal is not permitted to perform this read.', - status: governedReadHttpStatus.forbidden, - title: 'Read forbidden', - type: 'https://ontos.dev/problems/read-forbidden', - }), - internal: () => - PartyContactPointDetailInternalProblemSchema.make({ - detail: 'The governed read could not be completed.', - status: governedReadHttpStatus.internal, - title: 'Read failed', - type: 'https://ontos.dev/problems/read-failed', - }), - invalid: () => - PartyContactPointDetailInvalidProblemSchema.make({ - detail: 'The governed read request is invalid.', - status: governedReadHttpStatus.invalid, - title: 'Invalid read request', - type: 'https://ontos.dev/problems/read-invalid', - }), - notFound: () => - PartyContactPointDetailNotFoundProblemSchema.make({ - detail: 'The requested resource was not found.', - status: governedReadHttpStatus.notFound, - title: 'Resource not found', - type: 'https://ontos.dev/problems/read-not-found', - }), - policyConflict: () => - PartyContactPointDetailPolicyConflictProblemSchema.make({ - detail: 'The read conflicts with the current business state.', - status: governedReadHttpStatus.policyConflict, - title: 'Read conflict', - type: 'https://ontos.dev/problems/read-policy-conflict', - }), - policyIneligible: () => - PartyContactPointDetailPolicyProblemSchema.make({ - detail: 'The read is not eligible under the current business policy.', - status: governedReadHttpStatus.policyIneligible, - title: 'Read ineligible', - type: 'https://ontos.dev/problems/read-policy-denied', - }), - unavailable: () => - PartyContactPointDetailUnavailableProblemSchema.make({ - detail: 'The governed read is temporarily unavailable.', - retryable: true, - status: governedReadHttpStatus.unavailable, - title: 'Read unavailable', - type: 'https://ontos.dev/problems/read-unavailable', - }), -}; +const problems = makeGovernedReadProblems({ + authentication: PartyContactPointDetailAuthenticationProblemSchema, + forbidden: PartyContactPointDetailForbiddenProblemSchema, + internal: PartyContactPointDetailInternalProblemSchema, + invalid: PartyContactPointDetailInvalidProblemSchema, + notFound: PartyContactPointDetailNotFoundProblemSchema, + policyConflict: PartyContactPointDetailPolicyConflictProblemSchema, + policyIneligible: PartyContactPointDetailPolicyProblemSchema, + unavailable: PartyContactPointDetailUnavailableProblemSchema, +}); export const partyContactPointDetailReadApiLive = HttpApiBuilder.group( - governedHttpApi, + partyRegistryApi, 'partyContactPointDetail', (handlers) => handlers.handle( diff --git a/app/verticals/party-registry/api/party-contact-points-read-server.ts b/app/verticals/party-registry/api/party-contact-points-read-server.ts index b92c9dc2d..f4377280d 100644 --- a/app/verticals/party-registry/api/party-contact-points-read-server.ts +++ b/app/verticals/party-registry/api/party-contact-points-read-server.ts @@ -1,10 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - governedReadHttpStatus, - makeGovernedReadHttpHandler, -} from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; -import { governedHttpApi } from '../shared/api.ts'; +import { partyRegistryApi } from '../shared/api.ts'; import { PartyContactPointsAuthenticationProblemSchema, PartyContactPointsForbiddenProblemSchema, @@ -18,68 +16,19 @@ import { import { partyContactPointsRead } from '../src/api/party-contact-points.read.ts'; import { authenticateOperationPrincipal } from './auth/action-principal.ts'; -const problems = { - authentication: () => - PartyContactPointsAuthenticationProblemSchema.make({ - detail: 'A valid audience-scoped Bearer assertion is required.', - status: governedReadHttpStatus.authentication, - title: 'Authentication required', - type: 'https://ontos.dev/problems/operation-authentication-required', - }), - forbidden: () => - PartyContactPointsForbiddenProblemSchema.make({ - detail: 'The principal is not permitted to perform this read.', - status: governedReadHttpStatus.forbidden, - title: 'Read forbidden', - type: 'https://ontos.dev/problems/read-forbidden', - }), - internal: () => - PartyContactPointsInternalProblemSchema.make({ - detail: 'The governed read could not be completed.', - status: governedReadHttpStatus.internal, - title: 'Read failed', - type: 'https://ontos.dev/problems/read-failed', - }), - invalid: () => - PartyContactPointsInvalidProblemSchema.make({ - detail: 'The governed read request is invalid.', - status: governedReadHttpStatus.invalid, - title: 'Invalid read request', - type: 'https://ontos.dev/problems/read-invalid', - }), - notFound: () => - PartyContactPointsNotFoundProblemSchema.make({ - detail: 'The requested resource was not found.', - status: governedReadHttpStatus.notFound, - title: 'Resource not found', - type: 'https://ontos.dev/problems/read-not-found', - }), - policyConflict: () => - PartyContactPointsPolicyConflictProblemSchema.make({ - detail: 'The read conflicts with the current business state.', - status: governedReadHttpStatus.policyConflict, - title: 'Read conflict', - type: 'https://ontos.dev/problems/read-policy-conflict', - }), - policyIneligible: () => - PartyContactPointsPolicyProblemSchema.make({ - detail: 'The read is not eligible under the current business policy.', - status: governedReadHttpStatus.policyIneligible, - title: 'Read ineligible', - type: 'https://ontos.dev/problems/read-policy-denied', - }), - unavailable: () => - PartyContactPointsUnavailableProblemSchema.make({ - detail: 'The governed read is temporarily unavailable.', - retryable: true, - status: governedReadHttpStatus.unavailable, - title: 'Read unavailable', - type: 'https://ontos.dev/problems/read-unavailable', - }), -}; +const problems = makeGovernedReadProblems({ + authentication: PartyContactPointsAuthenticationProblemSchema, + forbidden: PartyContactPointsForbiddenProblemSchema, + internal: PartyContactPointsInternalProblemSchema, + invalid: PartyContactPointsInvalidProblemSchema, + notFound: PartyContactPointsNotFoundProblemSchema, + policyConflict: PartyContactPointsPolicyConflictProblemSchema, + policyIneligible: PartyContactPointsPolicyProblemSchema, + unavailable: PartyContactPointsUnavailableProblemSchema, +}); export const partyContactPointsReadApiLive = HttpApiBuilder.group( - governedHttpApi, + partyRegistryApi, 'partyContactPoints', (handlers) => handlers.handle( diff --git a/app/verticals/party-registry/api/party-correction-read-server.ts b/app/verticals/party-registry/api/party-correction-read-server.ts index 429706c3b..e19a1cb21 100644 --- a/app/verticals/party-registry/api/party-correction-read-server.ts +++ b/app/verticals/party-registry/api/party-correction-read-server.ts @@ -1,10 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - governedReadHttpStatus, - makeGovernedReadHttpHandler, -} from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; -import { governedHttpApi } from '../shared/api.ts'; +import { partyRegistryApi } from '../shared/api.ts'; import { PartyCorrectionAuthenticationProblemSchema, PartyCorrectionForbiddenProblemSchema, @@ -18,68 +16,19 @@ import { import { partyCorrectionRead } from '../src/api/party-correction.read.ts'; import { authenticateOperationPrincipal } from './auth/action-principal.ts'; -const problems = { - authentication: () => - PartyCorrectionAuthenticationProblemSchema.make({ - detail: 'A valid audience-scoped Bearer assertion is required.', - status: governedReadHttpStatus.authentication, - title: 'Authentication required', - type: 'https://ontos.dev/problems/operation-authentication-required', - }), - forbidden: () => - PartyCorrectionForbiddenProblemSchema.make({ - detail: 'The principal is not permitted to perform this read.', - status: governedReadHttpStatus.forbidden, - title: 'Read forbidden', - type: 'https://ontos.dev/problems/read-forbidden', - }), - internal: () => - PartyCorrectionInternalProblemSchema.make({ - detail: 'The governed read could not be completed.', - status: governedReadHttpStatus.internal, - title: 'Read failed', - type: 'https://ontos.dev/problems/read-failed', - }), - invalid: () => - PartyCorrectionInvalidProblemSchema.make({ - detail: 'The governed read request is invalid.', - status: governedReadHttpStatus.invalid, - title: 'Invalid read request', - type: 'https://ontos.dev/problems/read-invalid', - }), - notFound: () => - PartyCorrectionNotFoundProblemSchema.make({ - detail: 'The requested resource was not found.', - status: governedReadHttpStatus.notFound, - title: 'Resource not found', - type: 'https://ontos.dev/problems/read-not-found', - }), - policyConflict: () => - PartyCorrectionPolicyConflictProblemSchema.make({ - detail: 'The read conflicts with the current business state.', - status: governedReadHttpStatus.policyConflict, - title: 'Read conflict', - type: 'https://ontos.dev/problems/read-policy-conflict', - }), - policyIneligible: () => - PartyCorrectionPolicyProblemSchema.make({ - detail: 'The read is not eligible under the current business policy.', - status: governedReadHttpStatus.policyIneligible, - title: 'Read ineligible', - type: 'https://ontos.dev/problems/read-policy-denied', - }), - unavailable: () => - PartyCorrectionUnavailableProblemSchema.make({ - detail: 'The governed read is temporarily unavailable.', - retryable: true, - status: governedReadHttpStatus.unavailable, - title: 'Read unavailable', - type: 'https://ontos.dev/problems/read-unavailable', - }), -}; +const problems = makeGovernedReadProblems({ + authentication: PartyCorrectionAuthenticationProblemSchema, + forbidden: PartyCorrectionForbiddenProblemSchema, + internal: PartyCorrectionInternalProblemSchema, + invalid: PartyCorrectionInvalidProblemSchema, + notFound: PartyCorrectionNotFoundProblemSchema, + policyConflict: PartyCorrectionPolicyConflictProblemSchema, + policyIneligible: PartyCorrectionPolicyProblemSchema, + unavailable: PartyCorrectionUnavailableProblemSchema, +}); export const partyCorrectionReadApiLive = HttpApiBuilder.group( - governedHttpApi, + partyRegistryApi, 'partyCorrection', (handlers) => handlers.handle( diff --git a/app/verticals/party-registry/api/party-detail-read-server.ts b/app/verticals/party-registry/api/party-detail-read-server.ts index 5e1b4ba3a..e51377396 100644 --- a/app/verticals/party-registry/api/party-detail-read-server.ts +++ b/app/verticals/party-registry/api/party-detail-read-server.ts @@ -1,10 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - governedReadHttpStatus, - makeGovernedReadHttpHandler, -} from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; -import { governedHttpApi } from '../shared/api.ts'; +import { partyRegistryApi } from '../shared/api.ts'; import { PartyDetailAuthenticationProblemSchema, PartyDetailForbiddenProblemSchema, @@ -18,68 +16,19 @@ import { import { partyDetailRead } from '../src/api/party-detail.read.ts'; import { authenticateOperationPrincipal } from './auth/action-principal.ts'; -const problems = { - authentication: () => - PartyDetailAuthenticationProblemSchema.make({ - detail: 'A valid audience-scoped Bearer assertion is required.', - status: governedReadHttpStatus.authentication, - title: 'Authentication required', - type: 'https://ontos.dev/problems/operation-authentication-required', - }), - forbidden: () => - PartyDetailForbiddenProblemSchema.make({ - detail: 'The principal is not permitted to perform this read.', - status: governedReadHttpStatus.forbidden, - title: 'Read forbidden', - type: 'https://ontos.dev/problems/read-forbidden', - }), - internal: () => - PartyDetailInternalProblemSchema.make({ - detail: 'The governed read could not be completed.', - status: governedReadHttpStatus.internal, - title: 'Read failed', - type: 'https://ontos.dev/problems/read-failed', - }), - invalid: () => - PartyDetailInvalidProblemSchema.make({ - detail: 'The governed read request is invalid.', - status: governedReadHttpStatus.invalid, - title: 'Invalid read request', - type: 'https://ontos.dev/problems/read-invalid', - }), - notFound: () => - PartyDetailNotFoundProblemSchema.make({ - detail: 'The requested resource was not found.', - status: governedReadHttpStatus.notFound, - title: 'Resource not found', - type: 'https://ontos.dev/problems/read-not-found', - }), - policyConflict: () => - PartyDetailPolicyConflictProblemSchema.make({ - detail: 'The read conflicts with the current business state.', - status: governedReadHttpStatus.policyConflict, - title: 'Read conflict', - type: 'https://ontos.dev/problems/read-policy-conflict', - }), - policyIneligible: () => - PartyDetailPolicyProblemSchema.make({ - detail: 'The read is not eligible under the current business policy.', - status: governedReadHttpStatus.policyIneligible, - title: 'Read ineligible', - type: 'https://ontos.dev/problems/read-policy-denied', - }), - unavailable: () => - PartyDetailUnavailableProblemSchema.make({ - detail: 'The governed read is temporarily unavailable.', - retryable: true, - status: governedReadHttpStatus.unavailable, - title: 'Read unavailable', - type: 'https://ontos.dev/problems/read-unavailable', - }), -}; +const problems = makeGovernedReadProblems({ + authentication: PartyDetailAuthenticationProblemSchema, + forbidden: PartyDetailForbiddenProblemSchema, + internal: PartyDetailInternalProblemSchema, + invalid: PartyDetailInvalidProblemSchema, + notFound: PartyDetailNotFoundProblemSchema, + policyConflict: PartyDetailPolicyConflictProblemSchema, + policyIneligible: PartyDetailPolicyProblemSchema, + unavailable: PartyDetailUnavailableProblemSchema, +}); export const partyDetailReadApiLive = HttpApiBuilder.group( - governedHttpApi, + partyRegistryApi, 'partyDetail', (handlers) => handlers.handle( diff --git a/app/verticals/party-registry/api/party-match-decision-read-server.ts b/app/verticals/party-registry/api/party-match-decision-read-server.ts index 775442785..2d9a70f62 100644 --- a/app/verticals/party-registry/api/party-match-decision-read-server.ts +++ b/app/verticals/party-registry/api/party-match-decision-read-server.ts @@ -1,10 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - governedReadHttpStatus, - makeGovernedReadHttpHandler, -} from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; -import { governedHttpApi } from '../shared/api.ts'; +import { partyRegistryApi } from '../shared/api.ts'; import { PartyMatchDecisionAuthenticationProblemSchema, PartyMatchDecisionForbiddenProblemSchema, @@ -18,68 +16,19 @@ import { import { partyMatchDecisionRead } from '../src/api/party-match-decision.read.ts'; import { authenticateOperationPrincipal } from './auth/action-principal.ts'; -const problems = { - authentication: () => - PartyMatchDecisionAuthenticationProblemSchema.make({ - detail: 'A valid audience-scoped Bearer assertion is required.', - status: governedReadHttpStatus.authentication, - title: 'Authentication required', - type: 'https://ontos.dev/problems/operation-authentication-required', - }), - forbidden: () => - PartyMatchDecisionForbiddenProblemSchema.make({ - detail: 'The principal is not permitted to perform this read.', - status: governedReadHttpStatus.forbidden, - title: 'Read forbidden', - type: 'https://ontos.dev/problems/read-forbidden', - }), - internal: () => - PartyMatchDecisionInternalProblemSchema.make({ - detail: 'The governed read could not be completed.', - status: governedReadHttpStatus.internal, - title: 'Read failed', - type: 'https://ontos.dev/problems/read-failed', - }), - invalid: () => - PartyMatchDecisionInvalidProblemSchema.make({ - detail: 'The governed read request is invalid.', - status: governedReadHttpStatus.invalid, - title: 'Invalid read request', - type: 'https://ontos.dev/problems/read-invalid', - }), - notFound: () => - PartyMatchDecisionNotFoundProblemSchema.make({ - detail: 'The requested resource was not found.', - status: governedReadHttpStatus.notFound, - title: 'Resource not found', - type: 'https://ontos.dev/problems/read-not-found', - }), - policyConflict: () => - PartyMatchDecisionPolicyConflictProblemSchema.make({ - detail: 'The read conflicts with the current business state.', - status: governedReadHttpStatus.policyConflict, - title: 'Read conflict', - type: 'https://ontos.dev/problems/read-policy-conflict', - }), - policyIneligible: () => - PartyMatchDecisionPolicyProblemSchema.make({ - detail: 'The read is not eligible under the current business policy.', - status: governedReadHttpStatus.policyIneligible, - title: 'Read ineligible', - type: 'https://ontos.dev/problems/read-policy-denied', - }), - unavailable: () => - PartyMatchDecisionUnavailableProblemSchema.make({ - detail: 'The governed read is temporarily unavailable.', - retryable: true, - status: governedReadHttpStatus.unavailable, - title: 'Read unavailable', - type: 'https://ontos.dev/problems/read-unavailable', - }), -}; +const problems = makeGovernedReadProblems({ + authentication: PartyMatchDecisionAuthenticationProblemSchema, + forbidden: PartyMatchDecisionForbiddenProblemSchema, + internal: PartyMatchDecisionInternalProblemSchema, + invalid: PartyMatchDecisionInvalidProblemSchema, + notFound: PartyMatchDecisionNotFoundProblemSchema, + policyConflict: PartyMatchDecisionPolicyConflictProblemSchema, + policyIneligible: PartyMatchDecisionPolicyProblemSchema, + unavailable: PartyMatchDecisionUnavailableProblemSchema, +}); export const partyMatchDecisionReadApiLive = HttpApiBuilder.group( - governedHttpApi, + partyRegistryApi, 'partyMatchDecision', (handlers) => handlers.handle( diff --git a/app/verticals/party-registry/api/party-match-read-server.ts b/app/verticals/party-registry/api/party-match-read-server.ts index a0ae43a3c..e974d0ded 100644 --- a/app/verticals/party-registry/api/party-match-read-server.ts +++ b/app/verticals/party-registry/api/party-match-read-server.ts @@ -1,10 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - governedReadHttpStatus, - makeGovernedReadHttpHandler, -} from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; -import { governedHttpApi } from '../shared/api.ts'; +import { partyRegistryApi } from '../shared/api.ts'; import { PartyMatchAuthenticationProblemSchema, PartyMatchForbiddenProblemSchema, @@ -18,68 +16,19 @@ import { import { partyMatchRead } from '../src/api/party-match.read.ts'; import { authenticateOperationPrincipal } from './auth/action-principal.ts'; -const problems = { - authentication: () => - PartyMatchAuthenticationProblemSchema.make({ - detail: 'A valid audience-scoped Bearer assertion is required.', - status: governedReadHttpStatus.authentication, - title: 'Authentication required', - type: 'https://ontos.dev/problems/operation-authentication-required', - }), - forbidden: () => - PartyMatchForbiddenProblemSchema.make({ - detail: 'The principal is not permitted to perform this read.', - status: governedReadHttpStatus.forbidden, - title: 'Read forbidden', - type: 'https://ontos.dev/problems/read-forbidden', - }), - internal: () => - PartyMatchInternalProblemSchema.make({ - detail: 'The governed read could not be completed.', - status: governedReadHttpStatus.internal, - title: 'Read failed', - type: 'https://ontos.dev/problems/read-failed', - }), - invalid: () => - PartyMatchInvalidProblemSchema.make({ - detail: 'The governed read request is invalid.', - status: governedReadHttpStatus.invalid, - title: 'Invalid read request', - type: 'https://ontos.dev/problems/read-invalid', - }), - notFound: () => - PartyMatchNotFoundProblemSchema.make({ - detail: 'The requested resource was not found.', - status: governedReadHttpStatus.notFound, - title: 'Resource not found', - type: 'https://ontos.dev/problems/read-not-found', - }), - policyConflict: () => - PartyMatchPolicyConflictProblemSchema.make({ - detail: 'The read conflicts with the current business state.', - status: governedReadHttpStatus.policyConflict, - title: 'Read conflict', - type: 'https://ontos.dev/problems/read-policy-conflict', - }), - policyIneligible: () => - PartyMatchPolicyProblemSchema.make({ - detail: 'The read is not eligible under the current business policy.', - status: governedReadHttpStatus.policyIneligible, - title: 'Read ineligible', - type: 'https://ontos.dev/problems/read-policy-denied', - }), - unavailable: () => - PartyMatchUnavailableProblemSchema.make({ - detail: 'The governed read is temporarily unavailable.', - retryable: true, - status: governedReadHttpStatus.unavailable, - title: 'Read unavailable', - type: 'https://ontos.dev/problems/read-unavailable', - }), -}; +const problems = makeGovernedReadProblems({ + authentication: PartyMatchAuthenticationProblemSchema, + forbidden: PartyMatchForbiddenProblemSchema, + internal: PartyMatchInternalProblemSchema, + invalid: PartyMatchInvalidProblemSchema, + notFound: PartyMatchNotFoundProblemSchema, + policyConflict: PartyMatchPolicyConflictProblemSchema, + policyIneligible: PartyMatchPolicyProblemSchema, + unavailable: PartyMatchUnavailableProblemSchema, +}); export const partyMatchReadApiLive = HttpApiBuilder.group( - governedHttpApi, + partyRegistryApi, 'partyMatch', (handlers) => handlers.handle( diff --git a/app/verticals/party-registry/api/party-merge-readiness-read-server.ts b/app/verticals/party-registry/api/party-merge-readiness-read-server.ts index 67b372ccc..ee0161538 100644 --- a/app/verticals/party-registry/api/party-merge-readiness-read-server.ts +++ b/app/verticals/party-registry/api/party-merge-readiness-read-server.ts @@ -1,10 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - governedReadHttpStatus, - makeGovernedReadHttpHandler, -} from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; -import { governedHttpApi } from '../shared/api.ts'; +import { partyRegistryApi } from '../shared/api.ts'; import { PartyMergeReadinessAuthenticationProblemSchema, PartyMergeReadinessForbiddenProblemSchema, @@ -18,68 +16,19 @@ import { import { partyMergeReadinessRead } from '../src/api/party-merge-readiness.read.ts'; import { authenticateOperationPrincipal } from './auth/action-principal.ts'; -const problems = { - authentication: () => - PartyMergeReadinessAuthenticationProblemSchema.make({ - detail: 'A valid audience-scoped Bearer assertion is required.', - status: governedReadHttpStatus.authentication, - title: 'Authentication required', - type: 'https://ontos.dev/problems/operation-authentication-required', - }), - forbidden: () => - PartyMergeReadinessForbiddenProblemSchema.make({ - detail: 'The principal is not permitted to perform this read.', - status: governedReadHttpStatus.forbidden, - title: 'Read forbidden', - type: 'https://ontos.dev/problems/read-forbidden', - }), - internal: () => - PartyMergeReadinessInternalProblemSchema.make({ - detail: 'The governed read could not be completed.', - status: governedReadHttpStatus.internal, - title: 'Read failed', - type: 'https://ontos.dev/problems/read-failed', - }), - invalid: () => - PartyMergeReadinessInvalidProblemSchema.make({ - detail: 'The governed read request is invalid.', - status: governedReadHttpStatus.invalid, - title: 'Invalid read request', - type: 'https://ontos.dev/problems/read-invalid', - }), - notFound: () => - PartyMergeReadinessNotFoundProblemSchema.make({ - detail: 'The requested resource was not found.', - status: governedReadHttpStatus.notFound, - title: 'Resource not found', - type: 'https://ontos.dev/problems/read-not-found', - }), - policyConflict: () => - PartyMergeReadinessPolicyConflictProblemSchema.make({ - detail: 'The read conflicts with the current business state.', - status: governedReadHttpStatus.policyConflict, - title: 'Read conflict', - type: 'https://ontos.dev/problems/read-policy-conflict', - }), - policyIneligible: () => - PartyMergeReadinessPolicyProblemSchema.make({ - detail: 'The read is not eligible under the current business policy.', - status: governedReadHttpStatus.policyIneligible, - title: 'Read ineligible', - type: 'https://ontos.dev/problems/read-policy-denied', - }), - unavailable: () => - PartyMergeReadinessUnavailableProblemSchema.make({ - detail: 'The governed read is temporarily unavailable.', - retryable: true, - status: governedReadHttpStatus.unavailable, - title: 'Read unavailable', - type: 'https://ontos.dev/problems/read-unavailable', - }), -}; +const problems = makeGovernedReadProblems({ + authentication: PartyMergeReadinessAuthenticationProblemSchema, + forbidden: PartyMergeReadinessForbiddenProblemSchema, + internal: PartyMergeReadinessInternalProblemSchema, + invalid: PartyMergeReadinessInvalidProblemSchema, + notFound: PartyMergeReadinessNotFoundProblemSchema, + policyConflict: PartyMergeReadinessPolicyConflictProblemSchema, + policyIneligible: PartyMergeReadinessPolicyProblemSchema, + unavailable: PartyMergeReadinessUnavailableProblemSchema, +}); export const partyMergeReadinessReadApiLive = HttpApiBuilder.group( - governedHttpApi, + partyRegistryApi, 'partyMergeReadiness', (handlers) => handlers.handle( diff --git a/app/verticals/party-registry/api/party-official-identifier-detail-read-server.ts b/app/verticals/party-registry/api/party-official-identifier-detail-read-server.ts index 22f16b65b..4c7e87703 100644 --- a/app/verticals/party-registry/api/party-official-identifier-detail-read-server.ts +++ b/app/verticals/party-registry/api/party-official-identifier-detail-read-server.ts @@ -1,10 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - governedReadHttpStatus, - makeGovernedReadHttpHandler, -} from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; -import { governedHttpApi } from '../shared/api.ts'; +import { partyRegistryApi } from '../shared/api.ts'; import { PartyOfficialIdentifierDetailAuthenticationProblemSchema, PartyOfficialIdentifierDetailForbiddenProblemSchema, @@ -18,68 +16,19 @@ import { import { partyOfficialIdentifierDetailRead } from '../src/api/party-official-identifier-detail.read.ts'; import { authenticateOperationPrincipal } from './auth/action-principal.ts'; -const problems = { - authentication: () => - PartyOfficialIdentifierDetailAuthenticationProblemSchema.make({ - detail: 'A valid audience-scoped Bearer assertion is required.', - status: governedReadHttpStatus.authentication, - title: 'Authentication required', - type: 'https://ontos.dev/problems/operation-authentication-required', - }), - forbidden: () => - PartyOfficialIdentifierDetailForbiddenProblemSchema.make({ - detail: 'The principal is not permitted to perform this read.', - status: governedReadHttpStatus.forbidden, - title: 'Read forbidden', - type: 'https://ontos.dev/problems/read-forbidden', - }), - internal: () => - PartyOfficialIdentifierDetailInternalProblemSchema.make({ - detail: 'The governed read could not be completed.', - status: governedReadHttpStatus.internal, - title: 'Read failed', - type: 'https://ontos.dev/problems/read-failed', - }), - invalid: () => - PartyOfficialIdentifierDetailInvalidProblemSchema.make({ - detail: 'The governed read request is invalid.', - status: governedReadHttpStatus.invalid, - title: 'Invalid read request', - type: 'https://ontos.dev/problems/read-invalid', - }), - notFound: () => - PartyOfficialIdentifierDetailNotFoundProblemSchema.make({ - detail: 'The requested resource was not found.', - status: governedReadHttpStatus.notFound, - title: 'Resource not found', - type: 'https://ontos.dev/problems/read-not-found', - }), - policyConflict: () => - PartyOfficialIdentifierDetailPolicyConflictProblemSchema.make({ - detail: 'The read conflicts with the current business state.', - status: governedReadHttpStatus.policyConflict, - title: 'Read conflict', - type: 'https://ontos.dev/problems/read-policy-conflict', - }), - policyIneligible: () => - PartyOfficialIdentifierDetailPolicyProblemSchema.make({ - detail: 'The read is not eligible under the current business policy.', - status: governedReadHttpStatus.policyIneligible, - title: 'Read ineligible', - type: 'https://ontos.dev/problems/read-policy-denied', - }), - unavailable: () => - PartyOfficialIdentifierDetailUnavailableProblemSchema.make({ - detail: 'The governed read is temporarily unavailable.', - retryable: true, - status: governedReadHttpStatus.unavailable, - title: 'Read unavailable', - type: 'https://ontos.dev/problems/read-unavailable', - }), -}; +const problems = makeGovernedReadProblems({ + authentication: PartyOfficialIdentifierDetailAuthenticationProblemSchema, + forbidden: PartyOfficialIdentifierDetailForbiddenProblemSchema, + internal: PartyOfficialIdentifierDetailInternalProblemSchema, + invalid: PartyOfficialIdentifierDetailInvalidProblemSchema, + notFound: PartyOfficialIdentifierDetailNotFoundProblemSchema, + policyConflict: PartyOfficialIdentifierDetailPolicyConflictProblemSchema, + policyIneligible: PartyOfficialIdentifierDetailPolicyProblemSchema, + unavailable: PartyOfficialIdentifierDetailUnavailableProblemSchema, +}); export const partyOfficialIdentifierDetailReadApiLive = HttpApiBuilder.group( - governedHttpApi, + partyRegistryApi, 'partyOfficialIdentifierDetail', (handlers) => handlers.handle( diff --git a/app/verticals/party-registry/api/party-official-identifier-history-read-server.ts b/app/verticals/party-registry/api/party-official-identifier-history-read-server.ts index 4bd1ba7f4..b126512e9 100644 --- a/app/verticals/party-registry/api/party-official-identifier-history-read-server.ts +++ b/app/verticals/party-registry/api/party-official-identifier-history-read-server.ts @@ -1,10 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - governedReadHttpStatus, - makeGovernedReadHttpHandler, -} from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; -import { governedHttpApi } from '../shared/api.ts'; +import { partyRegistryApi } from '../shared/api.ts'; import { PartyOfficialIdentifierHistoryAuthenticationProblemSchema, PartyOfficialIdentifierHistoryForbiddenProblemSchema, @@ -18,68 +16,19 @@ import { import { partyOfficialIdentifierHistoryRead } from '../src/api/party-official-identifier-history.read.ts'; import { authenticateOperationPrincipal } from './auth/action-principal.ts'; -const problems = { - authentication: () => - PartyOfficialIdentifierHistoryAuthenticationProblemSchema.make({ - detail: 'A valid audience-scoped Bearer assertion is required.', - status: governedReadHttpStatus.authentication, - title: 'Authentication required', - type: 'https://ontos.dev/problems/operation-authentication-required', - }), - forbidden: () => - PartyOfficialIdentifierHistoryForbiddenProblemSchema.make({ - detail: 'The principal is not permitted to perform this read.', - status: governedReadHttpStatus.forbidden, - title: 'Read forbidden', - type: 'https://ontos.dev/problems/read-forbidden', - }), - internal: () => - PartyOfficialIdentifierHistoryInternalProblemSchema.make({ - detail: 'The governed read could not be completed.', - status: governedReadHttpStatus.internal, - title: 'Read failed', - type: 'https://ontos.dev/problems/read-failed', - }), - invalid: () => - PartyOfficialIdentifierHistoryInvalidProblemSchema.make({ - detail: 'The governed read request is invalid.', - status: governedReadHttpStatus.invalid, - title: 'Invalid read request', - type: 'https://ontos.dev/problems/read-invalid', - }), - notFound: () => - PartyOfficialIdentifierHistoryNotFoundProblemSchema.make({ - detail: 'The requested resource was not found.', - status: governedReadHttpStatus.notFound, - title: 'Resource not found', - type: 'https://ontos.dev/problems/read-not-found', - }), - policyConflict: () => - PartyOfficialIdentifierHistoryPolicyConflictProblemSchema.make({ - detail: 'The read conflicts with the current business state.', - status: governedReadHttpStatus.policyConflict, - title: 'Read conflict', - type: 'https://ontos.dev/problems/read-policy-conflict', - }), - policyIneligible: () => - PartyOfficialIdentifierHistoryPolicyProblemSchema.make({ - detail: 'The read is not eligible under the current business policy.', - status: governedReadHttpStatus.policyIneligible, - title: 'Read ineligible', - type: 'https://ontos.dev/problems/read-policy-denied', - }), - unavailable: () => - PartyOfficialIdentifierHistoryUnavailableProblemSchema.make({ - detail: 'The governed read is temporarily unavailable.', - retryable: true, - status: governedReadHttpStatus.unavailable, - title: 'Read unavailable', - type: 'https://ontos.dev/problems/read-unavailable', - }), -}; +const problems = makeGovernedReadProblems({ + authentication: PartyOfficialIdentifierHistoryAuthenticationProblemSchema, + forbidden: PartyOfficialIdentifierHistoryForbiddenProblemSchema, + internal: PartyOfficialIdentifierHistoryInternalProblemSchema, + invalid: PartyOfficialIdentifierHistoryInvalidProblemSchema, + notFound: PartyOfficialIdentifierHistoryNotFoundProblemSchema, + policyConflict: PartyOfficialIdentifierHistoryPolicyConflictProblemSchema, + policyIneligible: PartyOfficialIdentifierHistoryPolicyProblemSchema, + unavailable: PartyOfficialIdentifierHistoryUnavailableProblemSchema, +}); export const partyOfficialIdentifierHistoryReadApiLive = HttpApiBuilder.group( - governedHttpApi, + partyRegistryApi, 'partyOfficialIdentifierHistory', (handlers) => handlers.handle( diff --git a/app/verticals/party-registry/api/party-relationship-detail-read-server.ts b/app/verticals/party-registry/api/party-relationship-detail-read-server.ts index 0904af652..7681d8c42 100644 --- a/app/verticals/party-registry/api/party-relationship-detail-read-server.ts +++ b/app/verticals/party-registry/api/party-relationship-detail-read-server.ts @@ -1,10 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - governedReadHttpStatus, - makeGovernedReadHttpHandler, -} from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; -import { governedHttpApi } from '../shared/api.ts'; +import { partyRegistryApi } from '../shared/api.ts'; import { PartyRelationshipDetailAuthenticationProblemSchema, PartyRelationshipDetailForbiddenProblemSchema, @@ -18,68 +16,19 @@ import { import { partyRelationshipDetailRead } from '../src/api/party-relationship-detail.read.ts'; import { authenticateOperationPrincipal } from './auth/action-principal.ts'; -const problems = { - authentication: () => - PartyRelationshipDetailAuthenticationProblemSchema.make({ - detail: 'A valid audience-scoped Bearer assertion is required.', - status: governedReadHttpStatus.authentication, - title: 'Authentication required', - type: 'https://ontos.dev/problems/operation-authentication-required', - }), - forbidden: () => - PartyRelationshipDetailForbiddenProblemSchema.make({ - detail: 'The principal is not permitted to perform this read.', - status: governedReadHttpStatus.forbidden, - title: 'Read forbidden', - type: 'https://ontos.dev/problems/read-forbidden', - }), - internal: () => - PartyRelationshipDetailInternalProblemSchema.make({ - detail: 'The governed read could not be completed.', - status: governedReadHttpStatus.internal, - title: 'Read failed', - type: 'https://ontos.dev/problems/read-failed', - }), - invalid: () => - PartyRelationshipDetailInvalidProblemSchema.make({ - detail: 'The governed read request is invalid.', - status: governedReadHttpStatus.invalid, - title: 'Invalid read request', - type: 'https://ontos.dev/problems/read-invalid', - }), - notFound: () => - PartyRelationshipDetailNotFoundProblemSchema.make({ - detail: 'The requested resource was not found.', - status: governedReadHttpStatus.notFound, - title: 'Resource not found', - type: 'https://ontos.dev/problems/read-not-found', - }), - policyConflict: () => - PartyRelationshipDetailPolicyConflictProblemSchema.make({ - detail: 'The read conflicts with the current business state.', - status: governedReadHttpStatus.policyConflict, - title: 'Read conflict', - type: 'https://ontos.dev/problems/read-policy-conflict', - }), - policyIneligible: () => - PartyRelationshipDetailPolicyProblemSchema.make({ - detail: 'The read is not eligible under the current business policy.', - status: governedReadHttpStatus.policyIneligible, - title: 'Read ineligible', - type: 'https://ontos.dev/problems/read-policy-denied', - }), - unavailable: () => - PartyRelationshipDetailUnavailableProblemSchema.make({ - detail: 'The governed read is temporarily unavailable.', - retryable: true, - status: governedReadHttpStatus.unavailable, - title: 'Read unavailable', - type: 'https://ontos.dev/problems/read-unavailable', - }), -}; +const problems = makeGovernedReadProblems({ + authentication: PartyRelationshipDetailAuthenticationProblemSchema, + forbidden: PartyRelationshipDetailForbiddenProblemSchema, + internal: PartyRelationshipDetailInternalProblemSchema, + invalid: PartyRelationshipDetailInvalidProblemSchema, + notFound: PartyRelationshipDetailNotFoundProblemSchema, + policyConflict: PartyRelationshipDetailPolicyConflictProblemSchema, + policyIneligible: PartyRelationshipDetailPolicyProblemSchema, + unavailable: PartyRelationshipDetailUnavailableProblemSchema, +}); export const partyRelationshipDetailReadApiLive = HttpApiBuilder.group( - governedHttpApi, + partyRegistryApi, 'partyRelationshipDetail', (handlers) => handlers.handle( diff --git a/app/verticals/party-registry/api/person-engagement-profile-read-server.ts b/app/verticals/party-registry/api/person-engagement-profile-read-server.ts index 7d46fccdf..a70a511c1 100644 --- a/app/verticals/party-registry/api/person-engagement-profile-read-server.ts +++ b/app/verticals/party-registry/api/person-engagement-profile-read-server.ts @@ -1,10 +1,8 @@ // @generated by OntOS Codesmith module-api v1 -import { - governedReadHttpStatus, - makeGovernedReadHttpHandler, -} from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadHttpHandler } from '@app/core-runtime/http/governed-read'; +import { makeGovernedReadProblems } from '@app/shared-contracts/server/effect-bff-runtime'; import { HttpApiBuilder } from '@modern-js/plugin-bff/effect-edge'; -import { governedHttpApi } from '../shared/api.ts'; +import { partyRegistryApi } from '../shared/api.ts'; import { PersonEngagementProfileAuthenticationProblemSchema, PersonEngagementProfileForbiddenProblemSchema, @@ -18,68 +16,19 @@ import { import { personEngagementProfileRead } from '../src/api/person-engagement-profile.read.ts'; import { authenticateOperationPrincipal } from './auth/action-principal.ts'; -const problems = { - authentication: () => - PersonEngagementProfileAuthenticationProblemSchema.make({ - detail: 'A valid audience-scoped Bearer assertion is required.', - status: governedReadHttpStatus.authentication, - title: 'Authentication required', - type: 'https://ontos.dev/problems/operation-authentication-required', - }), - forbidden: () => - PersonEngagementProfileForbiddenProblemSchema.make({ - detail: 'The principal is not permitted to perform this read.', - status: governedReadHttpStatus.forbidden, - title: 'Read forbidden', - type: 'https://ontos.dev/problems/read-forbidden', - }), - internal: () => - PersonEngagementProfileInternalProblemSchema.make({ - detail: 'The governed read could not be completed.', - status: governedReadHttpStatus.internal, - title: 'Read failed', - type: 'https://ontos.dev/problems/read-failed', - }), - invalid: () => - PersonEngagementProfileInvalidProblemSchema.make({ - detail: 'The governed read request is invalid.', - status: governedReadHttpStatus.invalid, - title: 'Invalid read request', - type: 'https://ontos.dev/problems/read-invalid', - }), - notFound: () => - PersonEngagementProfileNotFoundProblemSchema.make({ - detail: 'The requested resource was not found.', - status: governedReadHttpStatus.notFound, - title: 'Resource not found', - type: 'https://ontos.dev/problems/read-not-found', - }), - policyConflict: () => - PersonEngagementProfilePolicyConflictProblemSchema.make({ - detail: 'The read conflicts with the current business state.', - status: governedReadHttpStatus.policyConflict, - title: 'Read conflict', - type: 'https://ontos.dev/problems/read-policy-conflict', - }), - policyIneligible: () => - PersonEngagementProfilePolicyProblemSchema.make({ - detail: 'The read is not eligible under the current business policy.', - status: governedReadHttpStatus.policyIneligible, - title: 'Read ineligible', - type: 'https://ontos.dev/problems/read-policy-denied', - }), - unavailable: () => - PersonEngagementProfileUnavailableProblemSchema.make({ - detail: 'The governed read is temporarily unavailable.', - retryable: true, - status: governedReadHttpStatus.unavailable, - title: 'Read unavailable', - type: 'https://ontos.dev/problems/read-unavailable', - }), -}; +const problems = makeGovernedReadProblems({ + authentication: PersonEngagementProfileAuthenticationProblemSchema, + forbidden: PersonEngagementProfileForbiddenProblemSchema, + internal: PersonEngagementProfileInternalProblemSchema, + invalid: PersonEngagementProfileInvalidProblemSchema, + notFound: PersonEngagementProfileNotFoundProblemSchema, + policyConflict: PersonEngagementProfilePolicyConflictProblemSchema, + policyIneligible: PersonEngagementProfilePolicyProblemSchema, + unavailable: PersonEngagementProfileUnavailableProblemSchema, +}); export const personEngagementProfileReadApiLive = HttpApiBuilder.group( - governedHttpApi, + partyRegistryApi, 'personEngagementProfile', (handlers) => handlers.handle( diff --git a/app/verticals/party-registry/shared/api.ts b/app/verticals/party-registry/shared/api.ts index 86e0ec880..c11889064 100644 --- a/app/verticals/party-registry/shared/api.ts +++ b/app/verticals/party-registry/shared/api.ts @@ -122,8 +122,6 @@ export const partyRegistryApi = HttpApi.make('PartyRegistryApi') .addHttpApi(PersonEngagementProfileApi) // .pipe(identity); -/** Canonical composition-root binding consumed by generated governed HTTP adapters. */ -export const governedHttpApi = partyRegistryApi; export const partyRegistryOperationContexts = { aresLookup: createMicroVerticalOperationContext({ diff --git a/app/verticals/party-registry/src/api/ares-lookup-client.ts b/app/verticals/party-registry/src/api/ares-lookup-client.ts index 79d8b9984..347124ad3 100644 --- a/app/verticals/party-registry/src/api/ares-lookup-client.ts +++ b/app/verticals/party-registry/src/api/ares-lookup-client.ts @@ -1,5 +1,5 @@ // @generated by OntOS Codesmith module-api v1 -import { makeEffectBffClient } from '@app/shared-contracts/client-runtime'; +import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; import { AresLookupApi } from '../../shared/apis/ares-lookup.ts'; import type { AresLookupRequest } from '../../shared/apis/ares-lookup.ts'; @@ -24,19 +24,16 @@ const aresLookupClient = ( credential: Redacted.Redacted, requestCorrelation: string, options: AresLookupClientOptions, -) => { - const clientConfig = { - api: AresLookupApi, - defaultApiPrefix: '/party-registry-api', - transportHeaders: { - authorization: Redacted.value(credential), - 'x-correlation-id': requestCorrelation, +) => + makeGovernedEffectBffClient( + { + api: AresLookupApi, + credential, + defaultApiPrefix: '/party-registry-api', + requestCorrelation, }, - }; - return makeEffectBffClient( - options.baseUrl === undefined ? clientConfig : { ...clientConfig, baseUrl: options.baseUrl }, + options, ); -}; export const executeAresLookupWithAuthorization = ( payload: AresLookupRequest, diff --git a/app/verticals/party-registry/src/api/counterparties-search-client.ts b/app/verticals/party-registry/src/api/counterparties-search-client.ts index 7a150647a..f37292fdf 100644 --- a/app/verticals/party-registry/src/api/counterparties-search-client.ts +++ b/app/verticals/party-registry/src/api/counterparties-search-client.ts @@ -1,6 +1,6 @@ // @generated by OntOS Codesmith Governed Contribution v1 // @ontos-contribution-kind search-provider -import { makeEffectBffClient } from '@app/shared-contracts/client-runtime'; +import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; import { CounterpartiesSearchApi } from '../../shared/apis/counterparties-search.ts'; import type { CounterpartiesProviderRequest } from '../../shared/apis/counterparties-search.ts'; @@ -25,19 +25,16 @@ const counterpartiesClient = ( credential: Redacted.Redacted, requestCorrelation: string, options: CounterpartiesSearchClientOptions, -) => { - const clientConfig = { - api: CounterpartiesSearchApi, - defaultApiPrefix: '/party-registry-api', - transportHeaders: { - authorization: Redacted.value(credential), - 'x-correlation-id': requestCorrelation, +) => + makeGovernedEffectBffClient( + { + api: CounterpartiesSearchApi, + credential, + defaultApiPrefix: '/party-registry-api', + requestCorrelation, }, - }; - return makeEffectBffClient( - options.baseUrl === undefined ? clientConfig : { ...clientConfig, baseUrl: options.baseUrl }, + options, ); -}; export const loadCounterpartiesClientWithAuthorization = ( payload: CounterpartiesProviderRequest, diff --git a/app/verticals/party-registry/src/api/counterparty-read-client.ts b/app/verticals/party-registry/src/api/counterparty-read-client.ts index 81c2890df..f1b37d7d3 100644 --- a/app/verticals/party-registry/src/api/counterparty-read-client.ts +++ b/app/verticals/party-registry/src/api/counterparty-read-client.ts @@ -1,5 +1,5 @@ // @generated by OntOS Codesmith module-api v1 -import { makeEffectBffClient } from '@app/shared-contracts/client-runtime'; +import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; import { CounterpartyReadApi } from '../../shared/apis/counterparty-read.ts'; import type { CounterpartyReadRequest } from '../../shared/apis/counterparty-read.ts'; @@ -24,19 +24,16 @@ const counterpartyReadClient = ( credential: Redacted.Redacted, requestCorrelation: string, options: CounterpartyReadClientOptions, -) => { - const clientConfig = { - api: CounterpartyReadApi, - defaultApiPrefix: '/party-registry-api', - transportHeaders: { - authorization: Redacted.value(credential), - 'x-correlation-id': requestCorrelation, +) => + makeGovernedEffectBffClient( + { + api: CounterpartyReadApi, + credential, + defaultApiPrefix: '/party-registry-api', + requestCorrelation, }, - }; - return makeEffectBffClient( - options.baseUrl === undefined ? clientConfig : { ...clientConfig, baseUrl: options.baseUrl }, + options, ); -}; export const executeCounterpartyReadWithAuthorization = ( payload: CounterpartyReadRequest, diff --git a/app/verticals/party-registry/src/api/counterparty-read.read.ts b/app/verticals/party-registry/src/api/counterparty-read.read.ts index 54280b93e..a359d7dd2 100644 --- a/app/verticals/party-registry/src/api/counterparty-read.read.ts +++ b/app/verticals/party-registry/src/api/counterparty-read.read.ts @@ -1,8 +1,8 @@ +// @generated by OntOS Codesmith module-api v1 import { counterpartyPermissionTarget, resolveCounterpartyRead, } from './counterparty-read-support.ts'; -// @generated by OntOS Codesmith module-api v1 import type { ReadHandlerContext } from '@app/core-runtime'; import { defineRead, defineTenantModuleEntrypoint } from '@app/core-runtime'; import { Effect } from 'effect'; @@ -23,7 +23,9 @@ const counterpartyReadEntrypoint = defineTenantModuleEntrypoint({ role: 'api', }); -export const counterpartyReadPermissionTarget = counterpartyPermissionTarget; +export const counterpartyReadPermissionTarget = ( + input: Parameters[0], +) => counterpartyPermissionTarget(input); export const counterpartyReadRead = defineRead( { diff --git a/app/verticals/party-registry/src/api/counterparty-role-history-client.ts b/app/verticals/party-registry/src/api/counterparty-role-history-client.ts index 60c5a82f0..e4b844f28 100644 --- a/app/verticals/party-registry/src/api/counterparty-role-history-client.ts +++ b/app/verticals/party-registry/src/api/counterparty-role-history-client.ts @@ -1,5 +1,5 @@ // @generated by OntOS Codesmith module-api v1 -import { makeEffectBffClient } from '@app/shared-contracts/client-runtime'; +import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; import { CounterpartyRoleHistoryApi } from '../../shared/apis/counterparty-role-history.ts'; import type { CounterpartyRoleHistoryRequest } from '../../shared/apis/counterparty-role-history.ts'; @@ -24,19 +24,16 @@ const counterpartyRoleHistoryClient = ( credential: Redacted.Redacted, requestCorrelation: string, options: CounterpartyRoleHistoryClientOptions, -) => { - const clientConfig = { - api: CounterpartyRoleHistoryApi, - defaultApiPrefix: '/party-registry-api', - transportHeaders: { - authorization: Redacted.value(credential), - 'x-correlation-id': requestCorrelation, +) => + makeGovernedEffectBffClient( + { + api: CounterpartyRoleHistoryApi, + credential, + defaultApiPrefix: '/party-registry-api', + requestCorrelation, }, - }; - return makeEffectBffClient( - options.baseUrl === undefined ? clientConfig : { ...clientConfig, baseUrl: options.baseUrl }, + options, ); -}; export const executeCounterpartyRoleHistoryWithAuthorization = ( payload: CounterpartyRoleHistoryRequest, diff --git a/app/verticals/party-registry/src/api/counterparty-role-history.read.ts b/app/verticals/party-registry/src/api/counterparty-role-history.read.ts index 33ab3bdc3..5f820a08a 100644 --- a/app/verticals/party-registry/src/api/counterparty-role-history.read.ts +++ b/app/verticals/party-registry/src/api/counterparty-role-history.read.ts @@ -1,8 +1,8 @@ +// @generated by OntOS Codesmith module-api v1 import { counterpartyPermissionTarget, resolveCounterpartyRead, } from './counterparty-read-support.ts'; -// @generated by OntOS Codesmith module-api v1 import type { ReadHandlerContext } from '@app/core-runtime'; import { defineRead, defineTenantModuleEntrypoint } from '@app/core-runtime'; import { Effect } from 'effect'; @@ -23,7 +23,9 @@ const counterpartyRoleHistoryEntrypoint = defineTenantModuleEntrypoint({ role: 'api', }); -export const counterpartyRoleHistoryPermissionTarget = counterpartyPermissionTarget; +export const counterpartyRoleHistoryPermissionTarget = ( + input: Parameters[0], +) => counterpartyPermissionTarget(input); export const counterpartyRoleHistoryRead = defineRead( { diff --git a/app/verticals/party-registry/src/api/duplicate-candidate-detail-client.ts b/app/verticals/party-registry/src/api/duplicate-candidate-detail-client.ts index b37ece4b5..67f70fbad 100644 --- a/app/verticals/party-registry/src/api/duplicate-candidate-detail-client.ts +++ b/app/verticals/party-registry/src/api/duplicate-candidate-detail-client.ts @@ -1,5 +1,5 @@ // @generated by OntOS Codesmith module-api v1 -import { makeEffectBffClient } from '@app/shared-contracts/client-runtime'; +import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; import { DuplicateCandidateDetailApi } from '../../shared/apis/duplicate-candidate-detail.ts'; import type { DuplicateCandidateDetailRequest } from '../../shared/apis/duplicate-candidate-detail.ts'; @@ -24,19 +24,16 @@ const duplicateCandidateDetailClient = ( credential: Redacted.Redacted, requestCorrelation: string, options: DuplicateCandidateDetailClientOptions, -) => { - const clientConfig = { - api: DuplicateCandidateDetailApi, - defaultApiPrefix: '/party-registry-api', - transportHeaders: { - authorization: Redacted.value(credential), - 'x-correlation-id': requestCorrelation, +) => + makeGovernedEffectBffClient( + { + api: DuplicateCandidateDetailApi, + credential, + defaultApiPrefix: '/party-registry-api', + requestCorrelation, }, - }; - return makeEffectBffClient( - options.baseUrl === undefined ? clientConfig : { ...clientConfig, baseUrl: options.baseUrl }, + options, ); -}; export const executeDuplicateCandidateDetailWithAuthorization = ( payload: DuplicateCandidateDetailRequest, diff --git a/app/verticals/party-registry/src/api/organization-engagement-profile-client.ts b/app/verticals/party-registry/src/api/organization-engagement-profile-client.ts index 0ad395866..1b3b26967 100644 --- a/app/verticals/party-registry/src/api/organization-engagement-profile-client.ts +++ b/app/verticals/party-registry/src/api/organization-engagement-profile-client.ts @@ -1,5 +1,5 @@ // @generated by OntOS Codesmith module-api v1 -import { makeEffectBffClient } from '@app/shared-contracts/client-runtime'; +import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; import { OrganizationEngagementProfileApi } from '../../shared/apis/organization-engagement-profile.ts'; import type { OrganizationEngagementProfileRequest } from '../../shared/apis/organization-engagement-profile.ts'; @@ -24,19 +24,16 @@ const organizationEngagementProfileClient = ( credential: Redacted.Redacted, requestCorrelation: string, options: OrganizationEngagementProfileClientOptions, -) => { - const clientConfig = { - api: OrganizationEngagementProfileApi, - defaultApiPrefix: '/party-registry-api', - transportHeaders: { - authorization: Redacted.value(credential), - 'x-correlation-id': requestCorrelation, +) => + makeGovernedEffectBffClient( + { + api: OrganizationEngagementProfileApi, + credential, + defaultApiPrefix: '/party-registry-api', + requestCorrelation, }, - }; - return makeEffectBffClient( - options.baseUrl === undefined ? clientConfig : { ...clientConfig, baseUrl: options.baseUrl }, + options, ); -}; export const executeOrganizationEngagementProfileWithAuthorization = ( payload: OrganizationEngagementProfileRequest, diff --git a/app/verticals/party-registry/src/api/parties-search-client.ts b/app/verticals/party-registry/src/api/parties-search-client.ts index c77d46c79..3ce4868c7 100644 --- a/app/verticals/party-registry/src/api/parties-search-client.ts +++ b/app/verticals/party-registry/src/api/parties-search-client.ts @@ -1,6 +1,6 @@ // @generated by OntOS Codesmith Governed Contribution v1 // @ontos-contribution-kind search-provider -import { makeEffectBffClient } from '@app/shared-contracts/client-runtime'; +import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; import { PartiesSearchApi } from '../../shared/apis/parties-search.ts'; import type { PartiesProviderRequest } from '../../shared/apis/parties-search.ts'; @@ -25,19 +25,16 @@ const partiesClient = ( credential: Redacted.Redacted, requestCorrelation: string, options: PartiesSearchClientOptions, -) => { - const clientConfig = { - api: PartiesSearchApi, - defaultApiPrefix: '/party-registry-api', - transportHeaders: { - authorization: Redacted.value(credential), - 'x-correlation-id': requestCorrelation, +) => + makeGovernedEffectBffClient( + { + api: PartiesSearchApi, + credential, + defaultApiPrefix: '/party-registry-api', + requestCorrelation, }, - }; - return makeEffectBffClient( - options.baseUrl === undefined ? clientConfig : { ...clientConfig, baseUrl: options.baseUrl }, + options, ); -}; export const loadPartiesClientWithAuthorization = ( payload: PartiesProviderRequest, diff --git a/app/verticals/party-registry/src/api/party-contact-point-detail-client.ts b/app/verticals/party-registry/src/api/party-contact-point-detail-client.ts index 45c2f8b77..36417d099 100644 --- a/app/verticals/party-registry/src/api/party-contact-point-detail-client.ts +++ b/app/verticals/party-registry/src/api/party-contact-point-detail-client.ts @@ -1,5 +1,5 @@ // @generated by OntOS Codesmith module-api v1 -import { makeEffectBffClient } from '@app/shared-contracts/client-runtime'; +import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; import { PartyContactPointDetailApi } from '../../shared/apis/party-contact-point-detail.ts'; import type { PartyContactPointDetailRequest } from '../../shared/apis/party-contact-point-detail.ts'; @@ -24,19 +24,16 @@ const partyContactPointDetailClient = ( credential: Redacted.Redacted, requestCorrelation: string, options: PartyContactPointDetailClientOptions, -) => { - const clientConfig = { - api: PartyContactPointDetailApi, - defaultApiPrefix: '/party-registry-api', - transportHeaders: { - authorization: Redacted.value(credential), - 'x-correlation-id': requestCorrelation, +) => + makeGovernedEffectBffClient( + { + api: PartyContactPointDetailApi, + credential, + defaultApiPrefix: '/party-registry-api', + requestCorrelation, }, - }; - return makeEffectBffClient( - options.baseUrl === undefined ? clientConfig : { ...clientConfig, baseUrl: options.baseUrl }, + options, ); -}; export const executePartyContactPointDetailWithAuthorization = ( payload: PartyContactPointDetailRequest, diff --git a/app/verticals/party-registry/src/api/party-contact-points-client.ts b/app/verticals/party-registry/src/api/party-contact-points-client.ts index 5f5c35218..10c9458f7 100644 --- a/app/verticals/party-registry/src/api/party-contact-points-client.ts +++ b/app/verticals/party-registry/src/api/party-contact-points-client.ts @@ -1,5 +1,5 @@ // @generated by OntOS Codesmith module-api v1 -import { makeEffectBffClient } from '@app/shared-contracts/client-runtime'; +import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; import { PartyContactPointsApi } from '../../shared/apis/party-contact-points.ts'; import type { PartyContactPointsRequest } from '../../shared/apis/party-contact-points.ts'; @@ -24,19 +24,16 @@ const partyContactPointsClient = ( credential: Redacted.Redacted, requestCorrelation: string, options: PartyContactPointsClientOptions, -) => { - const clientConfig = { - api: PartyContactPointsApi, - defaultApiPrefix: '/party-registry-api', - transportHeaders: { - authorization: Redacted.value(credential), - 'x-correlation-id': requestCorrelation, +) => + makeGovernedEffectBffClient( + { + api: PartyContactPointsApi, + credential, + defaultApiPrefix: '/party-registry-api', + requestCorrelation, }, - }; - return makeEffectBffClient( - options.baseUrl === undefined ? clientConfig : { ...clientConfig, baseUrl: options.baseUrl }, + options, ); -}; export const executePartyContactPointsWithAuthorization = ( payload: PartyContactPointsRequest, diff --git a/app/verticals/party-registry/src/api/party-correction-client.ts b/app/verticals/party-registry/src/api/party-correction-client.ts index 3d6b57b47..5cb1c3b2c 100644 --- a/app/verticals/party-registry/src/api/party-correction-client.ts +++ b/app/verticals/party-registry/src/api/party-correction-client.ts @@ -1,5 +1,5 @@ // @generated by OntOS Codesmith module-api v1 -import { makeEffectBffClient } from '@app/shared-contracts/client-runtime'; +import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; import { PartyCorrectionApi } from '../../shared/apis/party-correction.ts'; import type { PartyCorrectionRequest } from '../../shared/apis/party-correction.ts'; @@ -24,19 +24,16 @@ const partyCorrectionClient = ( credential: Redacted.Redacted, requestCorrelation: string, options: PartyCorrectionClientOptions, -) => { - const clientConfig = { - api: PartyCorrectionApi, - defaultApiPrefix: '/party-registry-api', - transportHeaders: { - authorization: Redacted.value(credential), - 'x-correlation-id': requestCorrelation, +) => + makeGovernedEffectBffClient( + { + api: PartyCorrectionApi, + credential, + defaultApiPrefix: '/party-registry-api', + requestCorrelation, }, - }; - return makeEffectBffClient( - options.baseUrl === undefined ? clientConfig : { ...clientConfig, baseUrl: options.baseUrl }, + options, ); -}; export const executePartyCorrectionWithAuthorization = ( payload: PartyCorrectionRequest, diff --git a/app/verticals/party-registry/src/api/party-detail-client.ts b/app/verticals/party-registry/src/api/party-detail-client.ts index 54787b6d9..535cd8866 100644 --- a/app/verticals/party-registry/src/api/party-detail-client.ts +++ b/app/verticals/party-registry/src/api/party-detail-client.ts @@ -1,5 +1,5 @@ // @generated by OntOS Codesmith module-api v1 -import { makeEffectBffClient } from '@app/shared-contracts/client-runtime'; +import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; import { PartyDetailApi } from '../../shared/apis/party-detail.ts'; import type { PartyDetailRequest } from '../../shared/apis/party-detail.ts'; @@ -24,19 +24,16 @@ const partyDetailClient = ( credential: Redacted.Redacted, requestCorrelation: string, options: PartyDetailClientOptions, -) => { - const clientConfig = { - api: PartyDetailApi, - defaultApiPrefix: '/party-registry-api', - transportHeaders: { - authorization: Redacted.value(credential), - 'x-correlation-id': requestCorrelation, +) => + makeGovernedEffectBffClient( + { + api: PartyDetailApi, + credential, + defaultApiPrefix: '/party-registry-api', + requestCorrelation, }, - }; - return makeEffectBffClient( - options.baseUrl === undefined ? clientConfig : { ...clientConfig, baseUrl: options.baseUrl }, + options, ); -}; export const executePartyDetailWithAuthorization = ( payload: PartyDetailRequest, diff --git a/app/verticals/party-registry/src/api/party-match-client.ts b/app/verticals/party-registry/src/api/party-match-client.ts index f260bb98c..26fb2264c 100644 --- a/app/verticals/party-registry/src/api/party-match-client.ts +++ b/app/verticals/party-registry/src/api/party-match-client.ts @@ -1,5 +1,5 @@ // @generated by OntOS Codesmith module-api v1 -import { makeEffectBffClient } from '@app/shared-contracts/client-runtime'; +import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; import { PartyMatchApi } from '../../shared/apis/party-match.ts'; import type { PartyMatchRequest } from '../../shared/apis/party-match.ts'; @@ -24,19 +24,16 @@ const partyMatchClient = ( credential: Redacted.Redacted, requestCorrelation: string, options: PartyMatchClientOptions, -) => { - const clientConfig = { - api: PartyMatchApi, - defaultApiPrefix: '/party-registry-api', - transportHeaders: { - authorization: Redacted.value(credential), - 'x-correlation-id': requestCorrelation, +) => + makeGovernedEffectBffClient( + { + api: PartyMatchApi, + credential, + defaultApiPrefix: '/party-registry-api', + requestCorrelation, }, - }; - return makeEffectBffClient( - options.baseUrl === undefined ? clientConfig : { ...clientConfig, baseUrl: options.baseUrl }, + options, ); -}; export const executePartyMatchWithAuthorization = ( payload: PartyMatchRequest, diff --git a/app/verticals/party-registry/src/api/party-match-decision-client.ts b/app/verticals/party-registry/src/api/party-match-decision-client.ts index 6bcb9c002..ec632d968 100644 --- a/app/verticals/party-registry/src/api/party-match-decision-client.ts +++ b/app/verticals/party-registry/src/api/party-match-decision-client.ts @@ -1,5 +1,5 @@ // @generated by OntOS Codesmith module-api v1 -import { makeEffectBffClient } from '@app/shared-contracts/client-runtime'; +import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; import { PartyMatchDecisionApi } from '../../shared/apis/party-match-decision.ts'; import type { PartyMatchDecisionRequest } from '../../shared/apis/party-match-decision.ts'; @@ -24,19 +24,16 @@ const partyMatchDecisionClient = ( credential: Redacted.Redacted, requestCorrelation: string, options: PartyMatchDecisionClientOptions, -) => { - const clientConfig = { - api: PartyMatchDecisionApi, - defaultApiPrefix: '/party-registry-api', - transportHeaders: { - authorization: Redacted.value(credential), - 'x-correlation-id': requestCorrelation, +) => + makeGovernedEffectBffClient( + { + api: PartyMatchDecisionApi, + credential, + defaultApiPrefix: '/party-registry-api', + requestCorrelation, }, - }; - return makeEffectBffClient( - options.baseUrl === undefined ? clientConfig : { ...clientConfig, baseUrl: options.baseUrl }, + options, ); -}; export const executePartyMatchDecisionWithAuthorization = ( payload: PartyMatchDecisionRequest, diff --git a/app/verticals/party-registry/src/api/party-merge-readiness-client.ts b/app/verticals/party-registry/src/api/party-merge-readiness-client.ts index 5b85fc929..cfe6a0171 100644 --- a/app/verticals/party-registry/src/api/party-merge-readiness-client.ts +++ b/app/verticals/party-registry/src/api/party-merge-readiness-client.ts @@ -1,5 +1,5 @@ // @generated by OntOS Codesmith module-api v1 -import { makeEffectBffClient } from '@app/shared-contracts/client-runtime'; +import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; import { PartyMergeReadinessApi } from '../../shared/apis/party-merge-readiness.ts'; import type { PartyMergeReadinessRequest } from '../../shared/apis/party-merge-readiness.ts'; @@ -24,19 +24,16 @@ const partyMergeReadinessClient = ( credential: Redacted.Redacted, requestCorrelation: string, options: PartyMergeReadinessClientOptions, -) => { - const clientConfig = { - api: PartyMergeReadinessApi, - defaultApiPrefix: '/party-registry-api', - transportHeaders: { - authorization: Redacted.value(credential), - 'x-correlation-id': requestCorrelation, +) => + makeGovernedEffectBffClient( + { + api: PartyMergeReadinessApi, + credential, + defaultApiPrefix: '/party-registry-api', + requestCorrelation, }, - }; - return makeEffectBffClient( - options.baseUrl === undefined ? clientConfig : { ...clientConfig, baseUrl: options.baseUrl }, + options, ); -}; export const executePartyMergeReadinessWithAuthorization = ( payload: PartyMergeReadinessRequest, diff --git a/app/verticals/party-registry/src/api/party-official-identifier-detail-client.ts b/app/verticals/party-registry/src/api/party-official-identifier-detail-client.ts index a593597cd..9bf236cd1 100644 --- a/app/verticals/party-registry/src/api/party-official-identifier-detail-client.ts +++ b/app/verticals/party-registry/src/api/party-official-identifier-detail-client.ts @@ -1,5 +1,5 @@ // @generated by OntOS Codesmith module-api v1 -import { makeEffectBffClient } from '@app/shared-contracts/client-runtime'; +import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; import { PartyOfficialIdentifierDetailApi } from '../../shared/apis/party-official-identifier-detail.ts'; import type { PartyOfficialIdentifierDetailRequest } from '../../shared/apis/party-official-identifier-detail.ts'; @@ -24,19 +24,16 @@ const partyOfficialIdentifierDetailClient = ( credential: Redacted.Redacted, requestCorrelation: string, options: PartyOfficialIdentifierDetailClientOptions, -) => { - const clientConfig = { - api: PartyOfficialIdentifierDetailApi, - defaultApiPrefix: '/party-registry-api', - transportHeaders: { - authorization: Redacted.value(credential), - 'x-correlation-id': requestCorrelation, +) => + makeGovernedEffectBffClient( + { + api: PartyOfficialIdentifierDetailApi, + credential, + defaultApiPrefix: '/party-registry-api', + requestCorrelation, }, - }; - return makeEffectBffClient( - options.baseUrl === undefined ? clientConfig : { ...clientConfig, baseUrl: options.baseUrl }, + options, ); -}; export const executePartyOfficialIdentifierDetailWithAuthorization = ( payload: PartyOfficialIdentifierDetailRequest, diff --git a/app/verticals/party-registry/src/api/party-official-identifier-history-client.ts b/app/verticals/party-registry/src/api/party-official-identifier-history-client.ts index 02b4e0a25..58803ffda 100644 --- a/app/verticals/party-registry/src/api/party-official-identifier-history-client.ts +++ b/app/verticals/party-registry/src/api/party-official-identifier-history-client.ts @@ -1,5 +1,5 @@ // @generated by OntOS Codesmith module-api v1 -import { makeEffectBffClient } from '@app/shared-contracts/client-runtime'; +import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; import { PartyOfficialIdentifierHistoryApi } from '../../shared/apis/party-official-identifier-history.ts'; import type { PartyOfficialIdentifierHistoryRequest } from '../../shared/apis/party-official-identifier-history.ts'; @@ -24,19 +24,16 @@ const partyOfficialIdentifierHistoryClient = ( credential: Redacted.Redacted, requestCorrelation: string, options: PartyOfficialIdentifierHistoryClientOptions, -) => { - const clientConfig = { - api: PartyOfficialIdentifierHistoryApi, - defaultApiPrefix: '/party-registry-api', - transportHeaders: { - authorization: Redacted.value(credential), - 'x-correlation-id': requestCorrelation, +) => + makeGovernedEffectBffClient( + { + api: PartyOfficialIdentifierHistoryApi, + credential, + defaultApiPrefix: '/party-registry-api', + requestCorrelation, }, - }; - return makeEffectBffClient( - options.baseUrl === undefined ? clientConfig : { ...clientConfig, baseUrl: options.baseUrl }, + options, ); -}; export const executePartyOfficialIdentifierHistoryWithAuthorization = ( payload: PartyOfficialIdentifierHistoryRequest, diff --git a/app/verticals/party-registry/src/api/party-relationship-detail-client.ts b/app/verticals/party-registry/src/api/party-relationship-detail-client.ts index ce92b8d69..1552534c1 100644 --- a/app/verticals/party-registry/src/api/party-relationship-detail-client.ts +++ b/app/verticals/party-registry/src/api/party-relationship-detail-client.ts @@ -1,5 +1,5 @@ // @generated by OntOS Codesmith module-api v1 -import { makeEffectBffClient } from '@app/shared-contracts/client-runtime'; +import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; import { PartyRelationshipDetailApi } from '../../shared/apis/party-relationship-detail.ts'; import type { PartyRelationshipDetailRequest } from '../../shared/apis/party-relationship-detail.ts'; @@ -24,19 +24,16 @@ const partyRelationshipDetailClient = ( credential: Redacted.Redacted, requestCorrelation: string, options: PartyRelationshipDetailClientOptions, -) => { - const clientConfig = { - api: PartyRelationshipDetailApi, - defaultApiPrefix: '/party-registry-api', - transportHeaders: { - authorization: Redacted.value(credential), - 'x-correlation-id': requestCorrelation, +) => + makeGovernedEffectBffClient( + { + api: PartyRelationshipDetailApi, + credential, + defaultApiPrefix: '/party-registry-api', + requestCorrelation, }, - }; - return makeEffectBffClient( - options.baseUrl === undefined ? clientConfig : { ...clientConfig, baseUrl: options.baseUrl }, + options, ); -}; export const executePartyRelationshipDetailWithAuthorization = ( payload: PartyRelationshipDetailRequest, diff --git a/app/verticals/party-registry/src/api/person-engagement-profile-client.ts b/app/verticals/party-registry/src/api/person-engagement-profile-client.ts index ee366fc77..ef05dcc9d 100644 --- a/app/verticals/party-registry/src/api/person-engagement-profile-client.ts +++ b/app/verticals/party-registry/src/api/person-engagement-profile-client.ts @@ -1,5 +1,5 @@ // @generated by OntOS Codesmith module-api v1 -import { makeEffectBffClient } from '@app/shared-contracts/client-runtime'; +import { makeGovernedEffectBffClient } from '@app/shared-contracts/client-runtime'; import { Effect, Redacted } from 'effect'; import { PersonEngagementProfileApi } from '../../shared/apis/person-engagement-profile.ts'; import type { PersonEngagementProfileRequest } from '../../shared/apis/person-engagement-profile.ts'; @@ -24,19 +24,16 @@ const personEngagementProfileClient = ( credential: Redacted.Redacted, requestCorrelation: string, options: PersonEngagementProfileClientOptions, -) => { - const clientConfig = { - api: PersonEngagementProfileApi, - defaultApiPrefix: '/party-registry-api', - transportHeaders: { - authorization: Redacted.value(credential), - 'x-correlation-id': requestCorrelation, +) => + makeGovernedEffectBffClient( + { + api: PersonEngagementProfileApi, + credential, + defaultApiPrefix: '/party-registry-api', + requestCorrelation, }, - }; - return makeEffectBffClient( - options.baseUrl === undefined ? clientConfig : { ...clientConfig, baseUrl: options.baseUrl }, + options, ); -}; export const executePersonEngagementProfileWithAuthorization = ( payload: PersonEngagementProfileRequest, diff --git a/app/verticals/party-registry/src/search/search-normalization.ts b/app/verticals/party-registry/src/search-normalization.ts similarity index 72% rename from app/verticals/party-registry/src/search/search-normalization.ts rename to app/verticals/party-registry/src/search-normalization.ts index 6eda612f4..51fc3e035 100644 --- a/app/verticals/party-registry/src/search/search-normalization.ts +++ b/app/verticals/party-registry/src/search-normalization.ts @@ -1,6 +1,6 @@ import { Effect, Match } from 'effect'; -import { PartySearchProjectionUnavailable } from '../../shared/domain/search-projection-error.ts'; -import type { SearchNormalizationResult } from '../../shared/domain/search-semantics.ts'; +import { PartySearchProjectionUnavailable } from '../shared/domain/search-projection-error.ts'; +import type { SearchNormalizationResult } from '../shared/domain/search-semantics.ts'; export const resolveSearchNormalization = (normalized: SearchNormalizationResult) => Match.value(normalized).pipe( diff --git a/app/verticals/party-registry/src/search/counterparties.provider.ts b/app/verticals/party-registry/src/search/counterparties.provider.ts index 01be7e137..2ec67b09d 100644 --- a/app/verticals/party-registry/src/search/counterparties.provider.ts +++ b/app/verticals/party-registry/src/search/counterparties.provider.ts @@ -20,7 +20,7 @@ import { PartySearchProjectionGateway } from '../../shared/domain/search-project import type { PartySearchProjectionGatewayService as PartySearchProjectionGatewayPort } from '../../shared/domain/search-projection-gateway.ts'; import type { PartySearchProjectionUnavailable } from '../../shared/domain/search-projection-error.ts'; import { normalizeCounterpartySearchHits } from '../../shared/domain/search-semantics.ts'; -import { resolveSearchNormalization } from './search-normalization.ts'; +import { resolveSearchNormalization } from '../search-normalization.ts'; const counterpartiesEntrypoint = defineTenantModuleEntrypoint({ authorization: { kind: 'context_permission', permission: 'module.access' }, diff --git a/app/verticals/party-registry/src/search/parties.provider.ts b/app/verticals/party-registry/src/search/parties.provider.ts index 3d03ed8ed..aa8654251 100644 --- a/app/verticals/party-registry/src/search/parties.provider.ts +++ b/app/verticals/party-registry/src/search/parties.provider.ts @@ -33,7 +33,7 @@ import { normalizePartySearchHits } from '../../shared/domain/search-semantics.t import { CurrentCounterpartyRoleSchema } from '../../shared/domain/search-result.ts'; import { CounterpartyRefSchema } from '../../shared/resources/counterparty.ts'; import { PartyRefSchema } from '../../shared/resources/party.ts'; -import { resolveSearchNormalization } from './search-normalization.ts'; +import { resolveSearchNormalization } from '../search-normalization.ts'; const partiesEntrypoint = defineTenantModuleEntrypoint({ authorization: { kind: 'context_permission', permission: 'module.access' }, From ecef7072d9d3f74bf66d0969fabbf78b7c9c4e05 Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Tue, 8 Sep 2026 13:12:57 +0200 Subject: [PATCH 09/13] refactor: finish baseline purge and restore strict quality tooling Remove unreachable additional-Shell validation, preserve nested fluent-slot semantics and starter parity, repair pinned i18n and declaration defects without version changes, and enforce full audit source coverage with regression controls. Co-Authored-By: Claude Fable 5.1 --- app/package.json | 4 +- ...n-js-code-tools@3.8.2-ultramodern.12.patch | 84 + ...odern-js-create@3.8.2-ultramodern.12.patch | 1612 +++++++++-------- .../drizzle-orm-rc5-declarations.patch | 148 ++ app/patches/effect-schema-sentinel.patch | 26 +- app/pnpm-lock.yaml | 117 +- app/pnpm-workspace.yaml | 1 + app/quality-audit/knip-model.mts | 15 +- app/quality-audit/knip-runtime-model.mts | 23 +- app/quality-audit/scope.json | 1 + .../microvertical-api-baseline-boundary.mts | 285 ++- app/scripts/quality-audit-gate.mts | 15 +- app/scripts/quality-audit.mts | 47 +- app/scripts/quality-cli-lifecycle.mts | 15 + app/scripts/scaffolding/shared.mts | 42 +- app/scripts/tests/api-only-tooling.test.mts | 13 +- app/scripts/tests/code-tools-i18n.test.mts | 132 ++ .../tests/dependency-declarations.test.mts | 189 ++ .../tests/generated-slot-entries.test.mts | 45 + .../tests/quality-audit-count-domain.test.mts | 47 + app/scripts/tests/quality-audit.test.mts | 77 +- .../tests/quality-cli-lifecycle.test.mts | 82 + .../validate-ultramodern-workspace.mts | 549 +----- .../shared/ultramodern-build.ts | 3 - 24 files changed, 2001 insertions(+), 1571 deletions(-) create mode 100644 app/patches/drizzle-orm-rc5-declarations.patch create mode 100644 app/scripts/quality-cli-lifecycle.mts create mode 100644 app/scripts/tests/code-tools-i18n.test.mts create mode 100644 app/scripts/tests/dependency-declarations.test.mts create mode 100644 app/scripts/tests/generated-slot-entries.test.mts create mode 100644 app/scripts/tests/quality-audit-count-domain.test.mts create mode 100644 app/scripts/tests/quality-cli-lifecycle.test.mts diff --git a/app/package.json b/app/package.json index b4a119ebb..6fa67c87e 100644 --- a/app/package.json +++ b/app/package.json @@ -24,7 +24,7 @@ "local:initialize": "node ./scripts/initialize-local-development.mts", "test:unit": "pnpm -r --if-present run test:unit && pnpm -r --if-present run test:component", "test:integration": "pnpm -r --if-present run test:integration", - "test:scripts": "node --test scripts/tests/boundary-source-structure.test.mts scripts/local-environment-values.test.mts scripts/tests/audit-database-trust-boundaries.test.mts scripts/tests/authorization-rollout-contract.test.mts scripts/tests/check-authorization-readiness.test.mts scripts/tests/database-access-boundaries.test.mts scripts/tests/initialize-local-development.test.mts scripts/tests/locki-feature.test.mts scripts/tests/migrate-contacts-authorization.test.mts scripts/tests/module-entrypoint-boundaries.test.mts scripts/tests/plan-deployment-impact.test.mts scripts/tests/protected-entrypoint-inventory.test.mts scripts/tests/provision-current-action-authorization.test.mts scripts/tests/report-fail-closed-authorization-impact.test.mts scripts/tests/api-only-tooling.test.mts scripts/tests/root-environment.test.mts scripts/tests/typecheck-project-references.test.mts scripts/tests/ultramodern-command.test.mts", + "test:scripts": "node --test scripts/tests/boundary-source-structure.test.mts scripts/local-environment-values.test.mts scripts/tests/audit-database-trust-boundaries.test.mts scripts/tests/authorization-rollout-contract.test.mts scripts/tests/check-authorization-readiness.test.mts scripts/tests/database-access-boundaries.test.mts scripts/tests/initialize-local-development.test.mts scripts/tests/locki-feature.test.mts scripts/tests/migrate-contacts-authorization.test.mts scripts/tests/module-entrypoint-boundaries.test.mts scripts/tests/plan-deployment-impact.test.mts scripts/tests/protected-entrypoint-inventory.test.mts scripts/tests/provision-current-action-authorization.test.mts scripts/tests/report-fail-closed-authorization-impact.test.mts scripts/tests/api-only-tooling.test.mts scripts/tests/generated-slot-entries.test.mts scripts/tests/root-environment.test.mts scripts/tests/typecheck-project-references.test.mts scripts/tests/ultramodern-command.test.mts scripts/tests/code-tools-i18n.test.mts scripts/tests/dependency-declarations.test.mts", "test:lint-rules": "node --test tools/oxlint/effect-native/tests/*.test.mts", "typecheck:lint-rules": "tsc -p tools/oxlint/effect-native/tsconfig.json", "lint:effect": "node tools/oxlint/effect-native/report.mts", @@ -87,7 +87,7 @@ "quality:audit": "node ./scripts/quality-audit.mts", "quality:audit:gate": "node ./scripts/quality-audit-gate.mts", "quality:check": "pnpm quality:audit && pnpm quality:audit:gate", - "quality:audit:test": "node --test ./scripts/tests/quality-audit.test.mts ./scripts/tests/quality-audit-model.test.mts ./scripts/tests/quality-audit-runtime-model.test.mts ./scripts/tests/quality-audit-gate.test.mts" + "quality:audit:test": "node --test ./scripts/tests/quality-audit.test.mts ./scripts/tests/quality-audit-model.test.mts ./scripts/tests/quality-audit-runtime-model.test.mts ./scripts/tests/quality-audit-gate.test.mts ./scripts/tests/quality-cli-lifecycle.test.mts ./scripts/tests/quality-audit-count-domain.test.mts" }, "dependencies": { "@authzed/authzed-node": "1.6.1", diff --git a/app/patches/@bleedingdev__modern-js-code-tools@3.8.2-ultramodern.12.patch b/app/patches/@bleedingdev__modern-js-code-tools@3.8.2-ultramodern.12.patch index c3d7264fa..ac41201cb 100644 --- a/app/patches/@bleedingdev__modern-js-code-tools@3.8.2-ultramodern.12.patch +++ b/app/patches/@bleedingdev__modern-js-code-tools@3.8.2-ultramodern.12.patch @@ -1,3 +1,40 @@ +diff --git a/dist/cjs/cli/oxlint.cjs b/dist/cjs/cli/oxlint.cjs +index 5ccf1e9670dc9e6f4555c330f948dd3c5b61c1eb..e6d28368813ad8a21a532c34c00eb990224bb570 100644 +--- a/dist/cjs/cli/oxlint.cjs ++++ b/dist/cjs/cli/oxlint.cjs +@@ -135,8 +135,6 @@ const runOxlintRules = ({ cwd, targets, rules })=>{ + ...resolvedTargets, + '--config', + configPath, +- '--format', +- 'unix', + '--quiet' + ], { + cwd, +diff --git a/dist/cjs/oxlint-plugin/rules/no-manual-locale-copy-branching.cjs b/dist/cjs/oxlint-plugin/rules/no-manual-locale-copy-branching.cjs +index 0b6edb6fec0eee7cbde212b541b806ac56611eab..572aa9ddd76387172bff32eb7617d6f39002c220 100644 +--- a/dist/cjs/oxlint-plugin/rules/no-manual-locale-copy-branching.cjs ++++ b/dist/cjs/oxlint-plugin/rules/no-manual-locale-copy-branching.cjs +@@ -54,7 +54,7 @@ const createNoManualLocaleCopyBranchingRule = ()=>({ + const reportBranch = (node, text)=>{ + context.report({ + node, +- message: `Move locale-specific copy branch to i18n resources: ${JSON.stringify(normalizeVisibleText(text))}` ++ message: `Move locale-specific copy branch to i18n resources: ${JSON.stringify((0, external_ast_cjs_namespaceObject.normalizeVisibleText)(text))}` + }); + }; + return { +@@ -64,8 +64,8 @@ const createNoManualLocaleCopyBranchingRule = ()=>({ + node.consequent, + node.alternate + ]){ +- const text = expressionStringValue(branch); +- if (text && hasLetters(text) && !isAllowedBranchLiteral(text.trim())) reportBranch(branch, text); ++ const text = (0, external_ast_cjs_namespaceObject.expressionStringValue)(branch); ++ if (text && (0, external_ast_cjs_namespaceObject.hasLetters)(text) && !isAllowedBranchLiteral(text.trim())) reportBranch(branch, text); + } + } + }; diff --git a/dist/cjs/oxlint-plugin/rules/strict-effect-api-boundaries.cjs b/dist/cjs/oxlint-plugin/rules/strict-effect-api-boundaries.cjs index 274117ad372e9fdc0a95da28731999524a8ef842..37edeeeb3489619996ab9beb7becd1ac77280dda 100644 --- a/dist/cjs/oxlint-plugin/rules/strict-effect-api-boundaries.cjs @@ -542,6 +579,29 @@ index 274117ad372e9fdc0a95da28731999524a8ef842..37edeeeb3489619996ab9beb7becd1ac } if ((0, external_ast_cjs_namespaceObject.isSharedApiContractFile)(filename)) { (0, external_ast_cjs_namespaceObject.reportMissingProgramPattern)(context, node, source, /\bHttpApi\.make\b/u, 'Generated shared API contracts must declare an HttpApi contract.'); +diff --git a/dist/esm/cli/oxlint.js b/dist/esm/cli/oxlint.js +index 9325d7959be8599e06e3cd052aa5eb43137d2566..6f4aad039f17343164c7040a03575a7d84c307da 100644 +--- a/dist/esm/cli/oxlint.js ++++ b/dist/esm/cli/oxlint.js +@@ -87,8 +87,6 @@ const runOxlintRules = ({ cwd, targets, rules })=>{ + ...resolvedTargets, + '--config', + configPath, +- '--format', +- 'unix', + '--quiet' + ], { + cwd, +diff --git a/dist/esm/oxlint-plugin/rules/no-manual-locale-copy-branching.js b/dist/esm/oxlint-plugin/rules/no-manual-locale-copy-branching.js +index 12e69ce6ba16c94541bb8adfdd8a2df3701d934b..787917cc125aa236dacfad10297d762036955c1a 100644 +--- a/dist/esm/oxlint-plugin/rules/no-manual-locale-copy-branching.js ++++ b/dist/esm/oxlint-plugin/rules/no-manual-locale-copy-branching.js +@@ -1,4 +1,4 @@ +-import { getSourceText } from "../ast.js"; ++import { expressionStringValue, getSourceText, hasLetters, normalizeVisibleText } from "../ast.js"; + const looksLikeLocaleTest = (context, node)=>{ + const text = getSourceText(context, node); + return /\b(?:language|locale|lng|currentLanguage)\b/u.test(text) && /(?:={2,3}|!==?|\.\s*startsWith\s*\()/u.test(text) && /['"][a-z]{2}(?:-[A-Za-z0-9]+)?['"]/u.test(text); diff --git a/dist/esm/oxlint-plugin/rules/strict-effect-api-boundaries.js b/dist/esm/oxlint-plugin/rules/strict-effect-api-boundaries.js index edb519487006c65d15bdef2150b7ddae2f54ab3e..dbc9cad9a0fb3e55d20da65308caa979f545925e 100644 --- a/dist/esm/oxlint-plugin/rules/strict-effect-api-boundaries.js @@ -1082,6 +1142,30 @@ index edb519487006c65d15bdef2150b7ddae2f54ab3e..dbc9cad9a0fb3e55d20da65308caa979 } if (isSharedApiContractFile(filename)) { reportMissingProgramPattern(context, node, source, /\bHttpApi\.make\b/u, 'Generated shared API contracts must declare an HttpApi contract.'); +diff --git a/dist/esm-node/cli/oxlint.js b/dist/esm-node/cli/oxlint.js +index b6951d613f380534a7966398dbb2af9c050dbdbf..0a810f7112d077b84a7685c8833729cf650a8ab8 100644 +--- a/dist/esm-node/cli/oxlint.js ++++ b/dist/esm-node/cli/oxlint.js +@@ -88,8 +88,6 @@ const runOxlintRules = ({ cwd, targets, rules })=>{ + ...resolvedTargets, + '--config', + configPath, +- '--format', +- 'unix', + '--quiet' + ], { + cwd, +diff --git a/dist/esm-node/oxlint-plugin/rules/no-manual-locale-copy-branching.js b/dist/esm-node/oxlint-plugin/rules/no-manual-locale-copy-branching.js +index d357dfcb3708de5c343a32961de8ed0e29e2f34e..6af206ece5e822e1fc542c7498ca217db8d3f844 100644 +--- a/dist/esm-node/oxlint-plugin/rules/no-manual-locale-copy-branching.js ++++ b/dist/esm-node/oxlint-plugin/rules/no-manual-locale-copy-branching.js +@@ -1,5 +1,5 @@ + import "node:module"; +-import { getSourceText } from "../ast.js"; ++import { expressionStringValue, getSourceText, hasLetters, normalizeVisibleText } from "../ast.js"; + const looksLikeLocaleTest = (context, node)=>{ + const text = getSourceText(context, node); + return /\b(?:language|locale|lng|currentLanguage)\b/u.test(text) && /(?:={2,3}|!==?|\.\s*startsWith\s*\()/u.test(text) && /['"][a-z]{2}(?:-[A-Za-z0-9]+)?['"]/u.test(text); diff --git a/dist/esm-node/oxlint-plugin/rules/strict-effect-api-boundaries.js b/dist/esm-node/oxlint-plugin/rules/strict-effect-api-boundaries.js index 044b7e175b0df7c266c3ddbbe915888f059a322a..7ef9d6c8c38a86018b918d9c76521b995277ac52 100644 --- a/dist/esm-node/oxlint-plugin/rules/strict-effect-api-boundaries.js diff --git a/app/patches/@bleedingdev__modern-js-create@3.8.2-ultramodern.12.patch b/app/patches/@bleedingdev__modern-js-create@3.8.2-ultramodern.12.patch index 81f450ab1..3387ce2c5 100644 --- a/app/patches/@bleedingdev__modern-js-create@3.8.2-ultramodern.12.patch +++ b/app/patches/@bleedingdev__modern-js-create@3.8.2-ultramodern.12.patch @@ -703,7 +703,7 @@ index 45a7179587e8ab49d1828ba05d25241acad5db82..3fcaa7833e9a1f1b3917470796a6ea34 const moduleFederationConfig: Parameters< typeof createModuleFederationConfig diff --git a/dist/cjs/ultramodern-workspace/module-federation/reexport-module.cjs b/dist/cjs/ultramodern-workspace/module-federation/reexport-module.cjs -index ef811e6023d5790d8d0bb388455f3b59e9a25707..69a2929c4a71799340a5838d8b52cbed879e8130 100644 +index ef811e6023d5790d8d0bb388455f3b59e9a25707..6267913c4afa69b78b07cd7fb4d82e146ad7e1f9 100644 --- a/dist/cjs/ultramodern-workspace/module-federation/reexport-module.cjs +++ b/dist/cjs/ultramodern-workspace/module-federation/reexport-module.cjs @@ -47,14 +47,24 @@ function createUltramodernBuildModule(scope, app) { @@ -739,6 +739,29 @@ index ef811e6023d5790d8d0bb388455f3b59e9a25707..69a2929c4a71799340a5838d8b52cbed const ultramodernBuildArtifact = { ...ultramodernGeneratedBuildArtifact, deliveryUnit: { +@@ -79,22 +89,17 @@ const ultramodernBuildArtifact = { + }, + } as const; + +-export { ultramodernBuildArtifact }; +- + export const ultramodernDeliveryUnit = + ultramodernBuildArtifact.deliveryUnit; +-export const ultramodernVerticalIdentity = ultramodernDeliveryUnit; + export const ultramodernUiMarker = ultramodernBuildArtifact.surfaces.ui; + export const ultramodernApiMarker = ultramodernBuildArtifact.surfaces.api; + `; + } + function createUltramodernBuildReexportModule() { + return `export { +- ultramodernBuildArtifact, + ultramodernApiMarker, + ultramodernDeliveryUnit, + ultramodernUiMarker, +- ultramodernVerticalIdentity, + } from '../shared/ultramodern-build'; + `; + } diff --git a/dist/cjs/ultramodern-workspace/package-json.cjs b/dist/cjs/ultramodern-workspace/package-json.cjs index 312a3b3c5caa8c007906c22f589972928efa71e6..d5f2acfbb76cea1d40615f4cf702bd3d845baa51 100644 --- a/dist/cjs/ultramodern-workspace/package-json.cjs @@ -872,11 +895,11 @@ index b239e505a9db2a5d5620a223c96ae986c36b11db..6e124abb2f2c7fb3dacc05bd82671d03 (0, external_workspace_scripts_cjs_namespaceObject.writeGeneratedWorkspaceScripts)(options.targetDir, scope, enableTailwind, initialVerticals, releaseCohort); const preliminaryAfterFiles = (0, external_generation_result_cjs_namespaceObject.createFileSnapshot)(options.targetDir); const preliminaryDiff = (0, external_generation_result_cjs_namespaceObject.diffFileSnapshots)(beforeFiles, preliminaryAfterFiles); -diff --git a/dist/esm-node/ultramodern-workspace/api/client.js b/dist/esm-node/ultramodern-workspace/api/client.js -index 8016fe8802c9d52562473063de93081154d5175c..7ab89bf22dd14a0a72f20725edc8c739e848ffb8 100644 ---- a/dist/esm-node/ultramodern-workspace/api/client.js -+++ b/dist/esm-node/ultramodern-workspace/api/client.js -@@ -119,7 +119,7 @@ export const ${readinessName} = ( +diff --git a/dist/esm/ultramodern-workspace/api/client.js b/dist/esm/ultramodern-workspace/api/client.js +index 254d58c84d5ba27b142539059961de4abb4749ab..7ac63ef2b979028a220cbfa2154562a78528d49e 100644 +--- a/dist/esm/ultramodern-workspace/api/client.js ++++ b/dist/esm/ultramodern-workspace/api/client.js +@@ -118,7 +118,7 @@ export const ${readinessName} = ( operationContext: options.operationContext ?? ${groupName}OperationContexts.readiness, }).pipe( @@ -885,11 +908,11 @@ index 8016fe8802c9d52562473063de93081154d5175c..7ab89bf22dd14a0a72f20725edc8c739 ); export const ${getName} = ( -diff --git a/dist/esm-node/ultramodern-workspace/api/service.js b/dist/esm-node/ultramodern-workspace/api/service.js -index d07b951c8c0dd4f3c55779073c1b7922c9dd1a7c..f0ad37ef79ba635af5e9a217f349c4f69f8c4bb6 100644 ---- a/dist/esm-node/ultramodern-workspace/api/service.js -+++ b/dist/esm-node/ultramodern-workspace/api/service.js -@@ -5,23 +5,20 @@ import { packageName } from "../naming.js"; +diff --git a/dist/esm/ultramodern-workspace/api/service.js b/dist/esm/ultramodern-workspace/api/service.js +index 92e2c050f9b499571236d28f6bb0c6fffc085e47..ef0047a0a8be2983ddcc26809e9317b99aa121a0 100644 +--- a/dist/esm/ultramodern-workspace/api/service.js ++++ b/dist/esm/ultramodern-workspace/api/service.js +@@ -4,23 +4,20 @@ import { packageName } from "../naming.js"; import { createCheckoutCartServerHandlers, createCheckoutCartServerState } from "./checkout-cart.js"; import { verticalApiExport, verticalApiGroupName, verticalApiNotFoundErrorExport } from "./names.js"; import { createRpcApiServiceEntry, rpcPath, verticalRpcContractExport, verticalRpcGroupExport } from "./rpc.js"; @@ -918,7 +941,7 @@ index d07b951c8c0dd4f3c55779073c1b7922c9dd1a7c..f0ad37ef79ba635af5e9a217f349c4f6 import { ultramodernApiMarker } from '../shared/ultramodern-build.ts'; import { ${apiExport}, -@@ -29,7 +26,6 @@ import { +@@ -28,7 +25,6 @@ import { } from '${contractImportPath}'; import type { ${notFoundErrorExport}, @@ -926,7 +949,7 @@ index d07b951c8c0dd4f3c55779073c1b7922c9dd1a7c..f0ad37ef79ba635af5e9a217f349c4f6 } from '${contractImportPath}'; const ${groupName}Items = [ -@@ -41,15 +37,29 @@ const ${groupName}Items = [ +@@ -40,15 +36,29 @@ const ${groupName}Items = [ ]; ${createCheckoutCartServerState(service)} @@ -965,7 +988,7 @@ index d07b951c8c0dd4f3c55779073c1b7922c9dd1a7c..f0ad37ef79ba635af5e9a217f349c4f6 const ${groupName}Layer = HttpApiBuilder.group( ${apiExport}, -@@ -64,25 +74,7 @@ const ${groupName}Layer = HttpApiBuilder.group( +@@ -63,25 +73,7 @@ const ${groupName}Layer = HttpApiBuilder.group( : ${groupName}Items, }).pipe( Effect.withSpan('ultramodern.api.${groupName}.list', { @@ -992,7 +1015,7 @@ index d07b951c8c0dd4f3c55779073c1b7922c9dd1a7c..f0ad37ef79ba635af5e9a217f349c4f6 kind: 'server', }), ), -@@ -102,7 +94,7 @@ const ${groupName}Layer = HttpApiBuilder.group( +@@ -101,7 +93,7 @@ const ${groupName}Layer = HttpApiBuilder.group( return result.pipe( Effect.withSpan('ultramodern.api.${groupName}.get', { @@ -1001,7 +1024,7 @@ index d07b951c8c0dd4f3c55779073c1b7922c9dd1a7c..f0ad37ef79ba635af5e9a217f349c4f6 kind: 'server', }), ); -@@ -119,21 +111,21 @@ const ${groupName}Layer = HttpApiBuilder.group( +@@ -118,21 +110,21 @@ const ${groupName}Layer = HttpApiBuilder.group( }, }).pipe( Effect.withSpan('ultramodern.api.${groupName}.create', { @@ -1032,12 +1055,11 @@ index d07b951c8c0dd4f3c55779073c1b7922c9dd1a7c..f0ad37ef79ba635af5e9a217f349c4f6 export default apiRuntime; `; -diff --git a/dist/esm-node/ultramodern-workspace/api/shared.js b/dist/esm-node/ultramodern-workspace/api/shared.js -index 0fa68d1a008de027e7859edfb45c09a540ae8fbd..22c904fc55f5d1773b9e913bc4d2822fc6a30aef 100644 ---- a/dist/esm-node/ultramodern-workspace/api/shared.js -+++ b/dist/esm-node/ultramodern-workspace/api/shared.js -@@ -1,10 +1,11 @@ - import "node:module"; +diff --git a/dist/esm/ultramodern-workspace/api/shared.js b/dist/esm/ultramodern-workspace/api/shared.js +index a01a243a186a98d9b48fbc420d8b74a8e3b0095e..3e99a71932122d02acffeacbea0ff99454a768a4 100644 +--- a/dist/esm/ultramodern-workspace/api/shared.js ++++ b/dist/esm/ultramodern-workspace/api/shared.js +@@ -1,9 +1,10 @@ import { resolveApiPrefix, resolveApiStem } from "../descriptors.js"; import { renderTemplate } from "../fs-io.js"; -import { toPascalCase } from "../naming.js"; @@ -1050,7 +1072,7 @@ index 0fa68d1a008de027e7859edfb45c09a540ae8fbd..22c904fc55f5d1773b9e913bc4d2822f return `import { HttpApi, HttpApiEndpoint, -@@ -12,6 +13,16 @@ function createSharedApiImports() { +@@ -11,6 +12,16 @@ function createSharedApiImports() { HttpApiSchema, Schema, } from '@modern-js/plugin-bff/effect-client'; @@ -1067,7 +1089,7 @@ index 0fa68d1a008de027e7859edfb45c09a540ae8fbd..22c904fc55f5d1773b9e913bc4d2822f `; } function createSharedApiContract(service) { -@@ -35,30 +46,26 @@ function createSharedApiContract(service) { +@@ -34,30 +45,26 @@ function createSharedApiContract(service) { const apiPrefix = resolveApiPrefix(service); const checkoutCartSharedSchemas = createCheckoutCartSharedSchemas(service); const checkoutCartSharedSchemaSection = '' === checkoutCartSharedSchemas ? '' : `${checkoutCartSharedSchemas}\n`; @@ -1106,7 +1128,7 @@ index 0fa68d1a008de027e7859edfb45c09a540ae8fbd..22c904fc55f5d1773b9e913bc4d2822f const checkoutCartOperationContextTemplate = createCheckoutCartOperationContexts(service); const operationContextEntries = '' === checkoutCartOperationContextTemplate ? [ createOperationContext, -@@ -71,17 +78,7 @@ function createSharedApiContract(service) { +@@ -70,17 +77,7 @@ function createSharedApiContract(service) { listOperationContext, readinessOperationContext }).trim(); @@ -1125,7 +1147,7 @@ index 0fa68d1a008de027e7859edfb45c09a540ae8fbd..22c904fc55f5d1773b9e913bc4d2822f export interface ${itemType} { readonly id: string; -@@ -89,17 +86,7 @@ export interface ${itemType} { +@@ -88,17 +85,7 @@ export interface ${itemType} { readonly title: string; } @@ -1144,7 +1166,7 @@ index 0fa68d1a008de027e7859edfb45c09a540ae8fbd..22c904fc55f5d1773b9e913bc4d2822f export interface ${createPayloadType} { readonly title: string; -@@ -118,17 +105,8 @@ export interface ${notFoundErrorExport} { +@@ -117,17 +104,8 @@ export interface ${notFoundErrorExport} { readonly id: string; } @@ -1164,7 +1186,7 @@ index 0fa68d1a008de027e7859edfb45c09a540ae8fbd..22c904fc55f5d1773b9e913bc4d2822f export const ${schemaExport}: Schema.Codec<${itemType}> = Schema.Struct({ id: Schema.String, -@@ -136,17 +114,8 @@ export const ${schemaExport}: Schema.Codec<${itemType}> = Schema.Struct({ +@@ -135,17 +113,8 @@ export const ${schemaExport}: Schema.Codec<${itemType}> = Schema.Struct({ title: Schema.String, }); @@ -1184,7 +1206,7 @@ index 0fa68d1a008de027e7859edfb45c09a540ae8fbd..22c904fc55f5d1773b9e913bc4d2822f export const ${createPayloadSchemaExport}: Schema.Codec<${createPayloadType}> = Schema.Struct({ title: Schema.String, -@@ -158,38 +127,31 @@ ${checkoutCartSharedSchemaSection}export const ${notFoundSchemaExport}: Schema.C +@@ -157,38 +126,31 @@ ${checkoutCartSharedSchemaSection}export const ${notFoundSchemaExport}: Schema.C HttpApiSchema.status(404), ); @@ -1246,7 +1268,7 @@ index 0fa68d1a008de027e7859edfb45c09a540ae8fbd..22c904fc55f5d1773b9e913bc4d2822f HttpApiEndpoint.get('get', '/${stem}/:id', { error: ${notFoundSchemaExport}, params: { -@@ -220,8 +182,8 @@ ${createCheckoutCartApiContractFields(service)} ownerId: '${service.id}', +@@ -219,8 +181,8 @@ ${createCheckoutCartApiContractFields(service)} ownerId: '${service.id}', } as const; `; } @@ -1257,11 +1279,11 @@ index 0fa68d1a008de027e7859edfb45c09a540ae8fbd..22c904fc55f5d1773b9e913bc4d2822f ${createSharedApiContract(service)}`; } export { createSharedApi }; -diff --git a/dist/esm-node/ultramodern-workspace/demo-components.js b/dist/esm-node/ultramodern-workspace/demo-components.js -index 503564abb6a3ea1be8e3c209197f55ef49b6209f..d0e7878f9d82d9fe8996ed1987eee2f9c12a0862 100644 ---- a/dist/esm-node/ultramodern-workspace/demo-components.js -+++ b/dist/esm-node/ultramodern-workspace/demo-components.js -@@ -267,13 +267,13 @@ function createLayout(appId) { +diff --git a/dist/esm/ultramodern-workspace/demo-components.js b/dist/esm/ultramodern-workspace/demo-components.js +index 4baf232ad01064de66c3dcf8b913a99b4754e5ce..3b5ff3fbf21fdd396bd0a230457798e07a6603fd 100644 +--- a/dist/esm/ultramodern-workspace/demo-components.js ++++ b/dist/esm/ultramodern-workspace/demo-components.js +@@ -266,13 +266,13 @@ function createLayout(appId) { return `import { Outlet } from '@modern-js/plugin-tanstack/runtime'; import './index.css'; @@ -1282,23 +1304,22 @@ index 503564abb6a3ea1be8e3c209197f55ef49b6209f..d0e7878f9d82d9fe8996ed1987eee2f9 `; } function createRemoteEntry(app) { -diff --git a/dist/esm-node/ultramodern-workspace/mf-validation/constants.js b/dist/esm-node/ultramodern-workspace/mf-validation/constants.js -index f52edfd11fbd7ade07b5b37ec90186e66f3573b8..cfdd41d84464e05894479b3e4c09a28f3f952e38 100644 ---- a/dist/esm-node/ultramodern-workspace/mf-validation/constants.js -+++ b/dist/esm-node/ultramodern-workspace/mf-validation/constants.js -@@ -1,6 +1,6 @@ - import "node:module"; +diff --git a/dist/esm/ultramodern-workspace/mf-validation/constants.js b/dist/esm/ultramodern-workspace/mf-validation/constants.js +index eb2e286b9e3f8dfd6fdb3b0e4473efcc87d2f0a6..32509a4e998fa4dec3ef4d53fc58a5a2b92c3a20 100644 +--- a/dist/esm/ultramodern-workspace/mf-validation/constants.js ++++ b/dist/esm/ultramodern-workspace/mf-validation/constants.js +@@ -1,5 +1,5 @@ const moduleFederationConfigFile = 'module-federation.config.ts'; -const mfTypesArchive = 'dist/@mf-types.zip'; +const mfTypesArchive = process.env.ULTRAMODERN_MF_TYPES_ARCHIVE || 'dist/@mf-types.zip'; const generatedMetadataPaths = [ '.modernjs/ultramodern.json' ]; -diff --git a/dist/esm-node/ultramodern-workspace/mf-validation/inspect.js b/dist/esm-node/ultramodern-workspace/mf-validation/inspect.js -index c333334745820eaf95b7a9783e481b500c669590..5bfe33f755f0ad4460fb93a47f4f2fd042067afc 100644 ---- a/dist/esm-node/ultramodern-workspace/mf-validation/inspect.js -+++ b/dist/esm-node/ultramodern-workspace/mf-validation/inspect.js -@@ -12,8 +12,12 @@ function extractExposes(configPath, value) { +diff --git a/dist/esm/ultramodern-workspace/mf-validation/inspect.js b/dist/esm/ultramodern-workspace/mf-validation/inspect.js +index d3d50e38814b4f1c95815a1bb61065c163ab8772..7f8bc5f5d7699225fadf3d711ab0571fbc4f2e68 100644 +--- a/dist/esm/ultramodern-workspace/mf-validation/inspect.js ++++ b/dist/esm/ultramodern-workspace/mf-validation/inspect.js +@@ -11,8 +11,12 @@ function extractExposes(configPath, value) { if (array) return array.sort(); throw new Error(`Cannot statically extract Module Federation exposes from ${configPath}; use a literal exposes object or string array.`); } @@ -1312,7 +1333,7 @@ index c333334745820eaf95b7a9783e481b500c669590..5bfe33f755f0ad4460fb93a47f4f2fd0 const dts = parseObjectLiteral(value); if (!dts || dts.hasSpread) throw new Error(`Cannot statically extract Module Federation DTS settings from ${configPath}; use a literal dts object.`); const generateTypes = parseObjectLiteral(dts.properties.get('generateTypes')); -@@ -39,7 +43,7 @@ function inspectModuleFederationConfigSource(source, appDir, configPath) { +@@ -38,7 +42,7 @@ function inspectModuleFederationConfigSource(source, appDir, configPath) { return { appDir, configPath, @@ -1321,11 +1342,11 @@ index c333334745820eaf95b7a9783e481b500c669590..5bfe33f755f0ad4460fb93a47f4f2fd0 exposes, hostOnlyNoExposes }; -diff --git a/dist/esm-node/ultramodern-workspace/module-federation/config.js b/dist/esm-node/ultramodern-workspace/module-federation/config.js -index 5956501f8ec070d086690b2be4096c40c0c249c0..6ac78eac22d08322d0546fc25bcd5c642ed216c1 100644 ---- a/dist/esm-node/ultramodern-workspace/module-federation/config.js -+++ b/dist/esm-node/ultramodern-workspace/module-federation/config.js -@@ -12,19 +12,18 @@ import { createSharedModuleFederationConfig, formatTsObjectLiteral } from "./sha +diff --git a/dist/esm/ultramodern-workspace/module-federation/config.js b/dist/esm/ultramodern-workspace/module-federation/config.js +index d5dead9cf23d170edaa7d30fc60c2f7d64c217df..eb3e2e74650a06d7130b77594239ef8b6b3c16a0 100644 +--- a/dist/esm/ultramodern-workspace/module-federation/config.js ++++ b/dist/esm/ultramodern-workspace/module-federation/config.js +@@ -11,19 +11,18 @@ import { createSharedModuleFederationConfig, formatTsObjectLiteral } from "./sha function createAppModernConfig(scope, app, remotes = [], enableTailwind = true, configuredDevPorts) { const deliveryUnit = createDeliveryUnitRecord(scope, app); const emitsUi = appEmitsBrowserUi(app); @@ -1351,7 +1372,7 @@ index 5956501f8ec070d086690b2be4096c40c0c249c0..6ac78eac22d08322d0546fc25bcd5c64 // Zephyr uploads federated build artifacts to Zephyr Cloud (the fast // rollback path). Uploading REQUIRES a Zephyr Cloud account and, in CI, a // deploy-scoped ZE_CI_TOKEN; without it Zephyr fatally fails to load its -@@ -35,8 +34,7 @@ import { ultramodernLocalisedUrls } from './src/routes/ultramodern-route-metadat +@@ -34,8 +33,7 @@ import { ultramodernLocalisedUrls } from './src/routes/ultramodern-route-metadat // (this gate keys on Zephyr's native deploy token, not any UltraModern // opt-out). When deploying, ZE_FAIL_BUILD=true makes an upload failure a // hard build failure. @@ -1361,7 +1382,7 @@ index 5956501f8ec070d086690b2be4096c40c0c249c0..6ac78eac22d08322d0546fc25bcd5c64 if (!zephyrCiDeploy) { return; } -@@ -47,7 +45,7 @@ import { ultramodernLocalisedUrls } from './src/routes/ultramodern-route-metadat +@@ -46,7 +44,7 @@ import { ultramodernLocalisedUrls } from './src/routes/ultramodern-route-metadat }); ` : ''; @@ -1370,7 +1391,7 @@ index 5956501f8ec070d086690b2be4096c40c0c249c0..6ac78eac22d08322d0546fc25bcd5c64 const uiPluginEntries = emitsUi ? ' moduleFederationPlugin(),\n zephyrRspackPlugin(),\n' : ''; const tailwindImport = enableTailwind ? "import { pluginTailwindcss } from '@rsbuild/plugin-tailwindcss';\n" : ''; const bffConfig = appHasApi(app) ? ` bff: { -@@ -83,17 +81,16 @@ ${distributedSsrExposes(service).map((expose)=>` { +@@ -82,17 +80,16 @@ ${distributedSsrExposes(service).map((expose)=>` { ], ` : ''; const defaultAssetPrefixSource = 'shell' === app.kind ? "const defaultAssetPrefix = '/';" : `const remoteAssetOrigin = @@ -1392,7 +1413,7 @@ index 5956501f8ec070d086690b2be4096c40c0c249c0..6ac78eac22d08322d0546fc25bcd5c64 const defaultAssetPrefix = defaultRemoteAssetPrefix;`; const devAssetPrefixSource = 'shell' === app.kind ? ` // Keep shell dev assets origin-relative so the shell works through // tunnels and local previews without rewriting its own chunks. -@@ -108,7 +105,7 @@ const defaultAssetPrefix = defaultRemoteAssetPrefix;`; +@@ -107,7 +104,7 @@ const defaultAssetPrefix = defaultRemoteAssetPrefix;`; ]).filter((port)=>'number' == typeof port && Number.isFinite(port))) ].toSorted((left, right)=>left - right); const legacyCorsSource = `const moduleFederationDevServerOrigin = @@ -1401,7 +1422,7 @@ index 5956501f8ec070d086690b2be4096c40c0c249c0..6ac78eac22d08322d0546fc25bcd5c64 const configuredCorsSource = `const moduleFederationDevServerAllowedOrigins = [ ${developmentPorts.map((port)=>` 'http://localhost:${port}',`).join('\n')} ];`; -@@ -120,7 +117,125 @@ ${developmentPorts.map((port)=>` 'http://localhost:${port}',`).join('\n')} +@@ -119,7 +116,125 @@ ${developmentPorts.map((port)=>` 'http://localhost:${port}',`).join('\n')} }, },`; const useConfiguredCorsAllowlist = void 0 !== configuredDevPorts; @@ -1527,7 +1548,7 @@ index 5956501f8ec070d086690b2be4096c40c0c249c0..6ac78eac22d08322d0546fc25bcd5c64 return renderFileTemplate('workspace/apps/modern.config.ts', { value0: `${bffImport}${tailwindImport}`, value1: app.id, -@@ -142,7 +257,7 @@ ${developmentPorts.map((port)=>` 'http://localhost:${port}',`).join('\n')} +@@ -141,7 +256,7 @@ ${developmentPorts.map((port)=>` 'http://localhost:${port}',`).join('\n')} value17: createRspackChunkLoadingGlobal(app), value18: tailwindBuilderPluginsConfig, value19: useConfiguredCorsAllowlist ? configuredCorsSource : legacyCorsSource, @@ -1536,7 +1557,7 @@ index 5956501f8ec070d086690b2be4096c40c0c249c0..6ac78eac22d08322d0546fc25bcd5c64 value21: configuredCorsHeader, value22: uiImports, value23: zephyrPluginSource, -@@ -150,7 +265,19 @@ ${developmentPorts.map((port)=>` 'http://localhost:${port}',`).join('\n')} +@@ -149,7 +264,19 @@ ${developmentPorts.map((port)=>` 'http://localhost:${port}',`).join('\n')} value25: uiPluginEntries, value26: deliveryUnit.unitId, value27: deliveryUnit.buildMarker, @@ -1557,7 +1578,7 @@ index 5956501f8ec070d086690b2be4096c40c0c249c0..6ac78eac22d08322d0546fc25bcd5c64 }); } function createModuleFederationBridgeConfig(enableBridgeRouter) { -@@ -205,15 +332,15 @@ export default moduleFederationConfig; +@@ -204,15 +331,15 @@ export default moduleFederationConfig; function createBackendModuleFederationConfig(app) { return `import { createRequire } from 'node:module'; import { createModuleFederationConfig } from '@module-federation/modern-js-v3'; @@ -1579,11 +1600,11 @@ index 5956501f8ec070d086690b2be4096c40c0c249c0..6ac78eac22d08322d0546fc25bcd5c64 const moduleFederationConfig: Parameters< typeof createModuleFederationConfig -diff --git a/dist/esm-node/ultramodern-workspace/module-federation/reexport-module.js b/dist/esm-node/ultramodern-workspace/module-federation/reexport-module.js -index be7c63e9f560d2f31a3ee28d4047d700a51b2abb..74714165cdb59faf8158bee5779a5432076e9e5d 100644 ---- a/dist/esm-node/ultramodern-workspace/module-federation/reexport-module.js -+++ b/dist/esm-node/ultramodern-workspace/module-federation/reexport-module.js -@@ -14,14 +14,24 @@ function createUltramodernBuildModule(scope, app) { +diff --git a/dist/esm/ultramodern-workspace/module-federation/reexport-module.js b/dist/esm/ultramodern-workspace/module-federation/reexport-module.js +index 1e2bef1edab5bff011cc1913b7746c39312ca90b..b1df6c180dd1fc1a1008b41fad0d9fcfb3047ed8 100644 +--- a/dist/esm/ultramodern-workspace/module-federation/reexport-module.js ++++ b/dist/esm/ultramodern-workspace/module-federation/reexport-module.js +@@ -13,14 +13,24 @@ function createUltramodernBuildModule(scope, app) { declare const ULTRAMODERN_SOURCE_REVISION: string; const ultramodernGeneratedBuildArtifact = ${JSON.stringify(createUltramodernBuildArtifact(record), null, 2)} as const; @@ -1616,11 +1637,34 @@ index be7c63e9f560d2f31a3ee28d4047d700a51b2abb..74714165cdb59faf8158bee5779a5432 const ultramodernBuildArtifact = { ...ultramodernGeneratedBuildArtifact, deliveryUnit: { -diff --git a/dist/esm-node/ultramodern-workspace/package-json.js b/dist/esm-node/ultramodern-workspace/package-json.js -index f901de089342afc1a1462696799a58e5821fe68e..f2690ad470ea517556177e6d109f9060e7bde92f 100644 ---- a/dist/esm-node/ultramodern-workspace/package-json.js -+++ b/dist/esm-node/ultramodern-workspace/package-json.js -@@ -172,7 +172,7 @@ function createAppPackage(scope, app, packageSource, enableTailwind, remotes = [ +@@ -45,22 +55,17 @@ const ultramodernBuildArtifact = { + }, + } as const; + +-export { ultramodernBuildArtifact }; +- + export const ultramodernDeliveryUnit = + ultramodernBuildArtifact.deliveryUnit; +-export const ultramodernVerticalIdentity = ultramodernDeliveryUnit; + export const ultramodernUiMarker = ultramodernBuildArtifact.surfaces.ui; + export const ultramodernApiMarker = ultramodernBuildArtifact.surfaces.api; + `; + } + function createUltramodernBuildReexportModule() { + return `export { +- ultramodernBuildArtifact, + ultramodernApiMarker, + ultramodernDeliveryUnit, + ultramodernUiMarker, +- ultramodernVerticalIdentity, + } from '../shared/ultramodern-build'; + `; + } +diff --git a/dist/esm/ultramodern-workspace/package-json.js b/dist/esm/ultramodern-workspace/package-json.js +index d97db9c90294bd70a94d5ccc10296a0079cd014e..053864d01b5e842b49f083a26753c60b91aa648f 100644 +--- a/dist/esm/ultramodern-workspace/package-json.js ++++ b/dist/esm/ultramodern-workspace/package-json.js +@@ -171,7 +171,7 @@ function createAppPackage(scope, app, packageSource, enableTailwind, remotes = [ if (Object.keys(packageExports).length > 0) packageJson.exports = packageExports; return packageJson; } @@ -1629,7 +1673,7 @@ index f901de089342afc1a1462696799a58e5821fe68e..f2690ad470ea517556177e6d109f9060 const packageJson = { private: true, name: packageName(scope, id), -@@ -189,10 +189,21 @@ function createSharedPackage(scope, id, description) { +@@ -188,10 +188,21 @@ function createSharedPackage(scope, id, description) { '@effect/tsgo': ULTRAMODERN_PACKAGE_PINS.appDevDependencies["@effect/tsgo"] } }; @@ -1651,11 +1695,11 @@ index f901de089342afc1a1462696799a58e5821fe68e..f2690ad470ea517556177e6d109f9060 return packageJson; } function createSharedContractsIndex() { -diff --git a/dist/esm-node/ultramodern-workspace/workspace-script-plan.js b/dist/esm-node/ultramodern-workspace/workspace-script-plan.js -index d5c918d9687cc706d40994e9526d3639624b4acd..f64f423af3cfcc62459c9a6919d63760eca271b8 100644 ---- a/dist/esm-node/ultramodern-workspace/workspace-script-plan.js -+++ b/dist/esm-node/ultramodern-workspace/workspace-script-plan.js -@@ -109,7 +109,7 @@ function createWorkspaceRootScriptPlan(remotes = [], options = {}) { +diff --git a/dist/esm/ultramodern-workspace/workspace-script-plan.js b/dist/esm/ultramodern-workspace/workspace-script-plan.js +index 683e43743c53e41b163455e8c8138929a44480c2..2d082066f2c5f5b489ec69d9fd7faa4dad5a8ce6 100644 +--- a/dist/esm/ultramodern-workspace/workspace-script-plan.js ++++ b/dist/esm/ultramodern-workspace/workspace-script-plan.js +@@ -108,7 +108,7 @@ function createWorkspaceRootScriptPlan(remotes = [], options = {}) { migrateStrictEffect: rootToolingWrapperCommand('migrateStrictEffect'), zeropsMaterialize: "node ./scripts/materialize-zerops-runtime.mjs", contractCheck: rootToolingWrapperCommand('validate'), @@ -1664,11 +1708,11 @@ index d5c918d9687cc706d40994e9526d3639624b4acd..f64f423af3cfcc62459c9a6919d63760 check: `pnpm format:check && pnpm lint && pnpm typecheck && pnpm skills:check && pnpm i18n:boundaries && pnpm api:check && pnpm contract:check && pnpm performance:readiness${bridgeCheck}` }; } -diff --git a/dist/esm-node/ultramodern-workspace/workspace-scripts.js b/dist/esm-node/ultramodern-workspace/workspace-scripts.js -index 7854e7fa839cccd828c491664fdd2c3b2ca0365f..f4b1fe155da8c0a624014374769fb3b32f891395 100644 ---- a/dist/esm-node/ultramodern-workspace/workspace-scripts.js -+++ b/dist/esm-node/ultramodern-workspace/workspace-scripts.js -@@ -116,6 +116,9 @@ function createWorkspaceI18nBoundaryValidationScript() { +diff --git a/dist/esm/ultramodern-workspace/workspace-scripts.js b/dist/esm/ultramodern-workspace/workspace-scripts.js +index ce720ef74bd8b5c9a09d29d87db247834d54121e..b31c1beef859c3374dea6b6473d97d2ab5eaef91 100644 +--- a/dist/esm/ultramodern-workspace/workspace-scripts.js ++++ b/dist/esm/ultramodern-workspace/workspace-scripts.js +@@ -115,6 +115,9 @@ function createWorkspaceI18nBoundaryValidationScript() { function createWorkspaceApiBoundaryValidationScript() { return external_fs_io_js_readFileTemplate("workspace-scripts/check-ultramodern-api-boundaries.mts"); } @@ -1678,7 +1722,7 @@ index 7854e7fa839cccd828c491664fdd2c3b2ca0365f..f4b1fe155da8c0a624014374769fb3b3 function createPerformanceReadinessConfigScript() { return external_fs_io_js_readFileTemplate("workspace-scripts/ultramodern-performance-readiness.config.mjs"); } -@@ -130,6 +133,7 @@ function writeGeneratedWorkspaceScripts(targetDir, scope, enableTailwind, remote +@@ -129,6 +132,7 @@ function writeGeneratedWorkspaceScripts(targetDir, scope, enableTailwind, remote const hasBackendSurface = remotes.some(appHasApi); writeWorkspaceOwnedMtsScript(targetDir, 'check-ultramodern-i18n-boundaries', createWorkspaceI18nBoundaryValidationScript()); writeWorkspaceOwnedMtsScript(targetDir, 'check-ultramodern-api-boundaries', createWorkspaceApiBoundaryValidationScript()); @@ -1686,7 +1730,7 @@ index 7854e7fa839cccd828c491664fdd2c3b2ca0365f..f4b1fe155da8c0a624014374769fb3b3 if (!shellOnly) { writeFileReplacing(targetDir, "scripts/materialize-zerops-runtime.mjs", createZeropsRuntimeMaterializationScript()); writeWorkspaceOwnedMtsScript(targetDir, 'proof-workerd-ssr', createWorkerdSsrProofScript()); -@@ -162,6 +166,10 @@ function migratedWorkspaceScriptArtifacts(options) { +@@ -161,6 +165,10 @@ function migratedWorkspaceScriptArtifacts(options) { content: createWorkspaceApiBoundaryValidationScript(), legacyPath: "scripts/check-ultramodern-api-boundaries.mjs" }, @@ -1697,7 +1741,7 @@ index 7854e7fa839cccd828c491664fdd2c3b2ca0365f..f4b1fe155da8c0a624014374769fb3b3 { relativePath: "scripts/ultramodern-performance-readiness.config.mjs", content: createPerformanceReadinessConfigScript() -@@ -193,6 +201,7 @@ function migratedWorkspaceScriptArtifacts(options) { +@@ -192,6 +200,7 @@ function migratedWorkspaceScriptArtifacts(options) { const migratedWorkspaceScriptBasenames = [ 'check-ultramodern-i18n-boundaries', 'check-ultramodern-api-boundaries', @@ -1705,11 +1749,11 @@ index 7854e7fa839cccd828c491664fdd2c3b2ca0365f..f4b1fe155da8c0a624014374769fb3b3 'bootstrap-agent-skills', 'setup-agent-reference-repos', 'proof-workerd-ssr', -diff --git a/dist/esm-node/ultramodern-workspace/write-app.js b/dist/esm-node/ultramodern-workspace/write-app.js -index 60cc1188ac1f6174f7c5c1bdfe62d8d04e44735f..7d3744fac4dcd0675c8dc03ca2d8e7a1658589d8 100644 ---- a/dist/esm-node/ultramodern-workspace/write-app.js -+++ b/dist/esm-node/ultramodern-workspace/write-app.js -@@ -89,8 +89,8 @@ function writeAppRouteAndShellFiles({ targetDir, scope, resolvedApp, emitsUi, re +diff --git a/dist/esm/ultramodern-workspace/write-app.js b/dist/esm/ultramodern-workspace/write-app.js +index 15a02949f43f418dd305a50e9c2edebd7f7db907..b99e450abba5a47f2cc0c768425b4d5c484ce3ed 100644 +--- a/dist/esm/ultramodern-workspace/write-app.js ++++ b/dist/esm/ultramodern-workspace/write-app.js +@@ -88,8 +88,8 @@ function writeAppRouteAndShellFiles({ targetDir, scope, resolvedApp, emitsUi, re function writeAppApiAndRemoteExposeFiles({ targetDir, scope, resolvedApp, emitsUi, writeAppFile }) { if (appHasApi(resolvedApp)) { const rpcProtocol = 'rpc' === resolveApiProtocol(resolvedApp); @@ -1720,11 +1764,11 @@ index 60cc1188ac1f6174f7c5c1bdfe62d8d04e44735f..7d3744fac4dcd0675c8dc03ca2d8e7a1 writeFile(targetDir, `${resolvedApp.directory}/api/backend-federation.ts`, createBackendFederationContractFile(resolvedApp)); writeFile(targetDir, `${resolvedApp.directory}/api/effect-api.ts`, createBackendEffectApiExpose(scope, resolvedApp)); rpcProtocol ? writeFile(targetDir, `${resolvedApp.directory}/src/api/${resolvedApp.api.stem}-rpc-client.ts`, createRpcClientFile(resolvedApp)) : writeFile(targetDir, `${resolvedApp.directory}/src/api/${resolvedApp.api.stem}-client.ts`, createApiClient(resolvedApp, '../../shared/api')); -diff --git a/dist/esm-node/ultramodern-workspace/write-workspace.js b/dist/esm-node/ultramodern-workspace/write-workspace.js -index 5b5c2103812a3a6be778cb9aa4a591614ecf88bd..7f3dcf6d110aa65295167396c44bc4f5d73f19ce 100644 ---- a/dist/esm-node/ultramodern-workspace/write-workspace.js -+++ b/dist/esm-node/ultramodern-workspace/write-workspace.js -@@ -5,7 +5,7 @@ import { createSharedDesignTokensCss } from "./app-files.js"; +diff --git a/dist/esm/ultramodern-workspace/write-workspace.js b/dist/esm/ultramodern-workspace/write-workspace.js +index afd450197f1a313fe7f8edd6b349d10b9e0ad8a2..0e34c9589d469b542916dd57ff3fead0384c4591 100644 +--- a/dist/esm/ultramodern-workspace/write-workspace.js ++++ b/dist/esm/ultramodern-workspace/write-workspace.js +@@ -4,7 +4,7 @@ import { createSharedDesignTokensCss } from "./app-files.js"; import { normalizeUltramodernBridgeConfig } from "./bridge-config.js"; import { createDevelopmentOverlay, createOwnership, createTopology, createUltramodernConfig } from "./contracts.js"; import { ULTRAMODERN_CONFIG_PATH, createShellHost, sharedPackages, shellApp } from "./descriptors.js"; @@ -1733,7 +1777,7 @@ index 5b5c2103812a3a6be778cb9aa4a591614ecf88bd..7f3dcf6d110aa65295167396c44bc4f5 import { createFileSnapshot, createGenerationResult, diffFileSnapshots } from "./generation-result.js"; import { assertUniqueTailwindPrefixes, toPackageScope } from "./naming.js"; import { runCodeSmithOverlays } from "./overlays.js"; -@@ -19,12 +19,13 @@ import { createZeropsYaml } from "./zerops.js"; +@@ -18,12 +18,13 @@ import { createZeropsYaml } from "./zerops.js"; function hasExplicitInstallRequest(options) { return void 0 !== options.packageSource && 'workspace' !== options.packageSource.strategy; } @@ -1749,7 +1793,7 @@ index 5b5c2103812a3a6be778cb9aa4a591614ecf88bd..7f3dcf6d110aa65295167396c44bc4f5 writeFile(targetDir, 'packages/shared-design-tokens/src/index.ts', `export const sharedDesignTokens = { color: { accent: '#2f8f68', -@@ -135,7 +136,7 @@ function generateUltramodernWorkspace(options) { +@@ -134,7 +135,7 @@ function generateUltramodernWorkspace(options) { writeJson(options.targetDir, ULTRAMODERN_CONFIG_PATH, createCompactUltramodernConfig(scope, options.modernVersion, packageSource, createdApps, enableTailwind, bridge)); writeApp(options.targetDir, scope, shellApp, packageSource, enableTailwind, initialVerticals, bridge); for (const remote of initialVerticals)writeApp(options.targetDir, scope, remote, packageSource, enableTailwind, initialVerticals, bridge); @@ -1758,11 +1802,11 @@ index 5b5c2103812a3a6be778cb9aa4a591614ecf88bd..7f3dcf6d110aa65295167396c44bc4f5 writeGeneratedWorkspaceScripts(options.targetDir, scope, enableTailwind, initialVerticals, releaseCohort); const preliminaryAfterFiles = createFileSnapshot(options.targetDir); const preliminaryDiff = diffFileSnapshots(beforeFiles, preliminaryAfterFiles); -diff --git a/dist/esm/ultramodern-workspace/api/client.js b/dist/esm/ultramodern-workspace/api/client.js -index 254d58c84d5ba27b142539059961de4abb4749ab..7ac63ef2b979028a220cbfa2154562a78528d49e 100644 ---- a/dist/esm/ultramodern-workspace/api/client.js -+++ b/dist/esm/ultramodern-workspace/api/client.js -@@ -118,7 +118,7 @@ export const ${readinessName} = ( +diff --git a/dist/esm-node/ultramodern-workspace/api/client.js b/dist/esm-node/ultramodern-workspace/api/client.js +index 8016fe8802c9d52562473063de93081154d5175c..7ab89bf22dd14a0a72f20725edc8c739e848ffb8 100644 +--- a/dist/esm-node/ultramodern-workspace/api/client.js ++++ b/dist/esm-node/ultramodern-workspace/api/client.js +@@ -119,7 +119,7 @@ export const ${readinessName} = ( operationContext: options.operationContext ?? ${groupName}OperationContexts.readiness, }).pipe( @@ -1771,11 +1815,11 @@ index 254d58c84d5ba27b142539059961de4abb4749ab..7ac63ef2b979028a220cbfa2154562a7 ); export const ${getName} = ( -diff --git a/dist/esm/ultramodern-workspace/api/service.js b/dist/esm/ultramodern-workspace/api/service.js -index 92e2c050f9b499571236d28f6bb0c6fffc085e47..ef0047a0a8be2983ddcc26809e9317b99aa121a0 100644 ---- a/dist/esm/ultramodern-workspace/api/service.js -+++ b/dist/esm/ultramodern-workspace/api/service.js -@@ -4,23 +4,20 @@ import { packageName } from "../naming.js"; +diff --git a/dist/esm-node/ultramodern-workspace/api/service.js b/dist/esm-node/ultramodern-workspace/api/service.js +index d07b951c8c0dd4f3c55779073c1b7922c9dd1a7c..f0ad37ef79ba635af5e9a217f349c4f69f8c4bb6 100644 +--- a/dist/esm-node/ultramodern-workspace/api/service.js ++++ b/dist/esm-node/ultramodern-workspace/api/service.js +@@ -5,23 +5,20 @@ import { packageName } from "../naming.js"; import { createCheckoutCartServerHandlers, createCheckoutCartServerState } from "./checkout-cart.js"; import { verticalApiExport, verticalApiGroupName, verticalApiNotFoundErrorExport } from "./names.js"; import { createRpcApiServiceEntry, rpcPath, verticalRpcContractExport, verticalRpcGroupExport } from "./rpc.js"; @@ -1804,7 +1848,7 @@ index 92e2c050f9b499571236d28f6bb0c6fffc085e47..ef0047a0a8be2983ddcc26809e9317b9 import { ultramodernApiMarker } from '../shared/ultramodern-build.ts'; import { ${apiExport}, -@@ -28,7 +25,6 @@ import { +@@ -29,7 +26,6 @@ import { } from '${contractImportPath}'; import type { ${notFoundErrorExport}, @@ -1812,7 +1856,7 @@ index 92e2c050f9b499571236d28f6bb0c6fffc085e47..ef0047a0a8be2983ddcc26809e9317b9 } from '${contractImportPath}'; const ${groupName}Items = [ -@@ -40,15 +36,29 @@ const ${groupName}Items = [ +@@ -41,15 +37,29 @@ const ${groupName}Items = [ ]; ${createCheckoutCartServerState(service)} @@ -1851,7 +1895,7 @@ index 92e2c050f9b499571236d28f6bb0c6fffc085e47..ef0047a0a8be2983ddcc26809e9317b9 const ${groupName}Layer = HttpApiBuilder.group( ${apiExport}, -@@ -63,25 +73,7 @@ const ${groupName}Layer = HttpApiBuilder.group( +@@ -64,25 +74,7 @@ const ${groupName}Layer = HttpApiBuilder.group( : ${groupName}Items, }).pipe( Effect.withSpan('ultramodern.api.${groupName}.list', { @@ -1878,7 +1922,7 @@ index 92e2c050f9b499571236d28f6bb0c6fffc085e47..ef0047a0a8be2983ddcc26809e9317b9 kind: 'server', }), ), -@@ -101,7 +93,7 @@ const ${groupName}Layer = HttpApiBuilder.group( +@@ -102,7 +94,7 @@ const ${groupName}Layer = HttpApiBuilder.group( return result.pipe( Effect.withSpan('ultramodern.api.${groupName}.get', { @@ -1887,7 +1931,7 @@ index 92e2c050f9b499571236d28f6bb0c6fffc085e47..ef0047a0a8be2983ddcc26809e9317b9 kind: 'server', }), ); -@@ -118,21 +110,21 @@ const ${groupName}Layer = HttpApiBuilder.group( +@@ -119,21 +111,21 @@ const ${groupName}Layer = HttpApiBuilder.group( }, }).pipe( Effect.withSpan('ultramodern.api.${groupName}.create', { @@ -1918,11 +1962,12 @@ index 92e2c050f9b499571236d28f6bb0c6fffc085e47..ef0047a0a8be2983ddcc26809e9317b9 export default apiRuntime; `; -diff --git a/dist/esm/ultramodern-workspace/api/shared.js b/dist/esm/ultramodern-workspace/api/shared.js -index a01a243a186a98d9b48fbc420d8b74a8e3b0095e..3e99a71932122d02acffeacbea0ff99454a768a4 100644 ---- a/dist/esm/ultramodern-workspace/api/shared.js -+++ b/dist/esm/ultramodern-workspace/api/shared.js -@@ -1,9 +1,10 @@ +diff --git a/dist/esm-node/ultramodern-workspace/api/shared.js b/dist/esm-node/ultramodern-workspace/api/shared.js +index 0fa68d1a008de027e7859edfb45c09a540ae8fbd..22c904fc55f5d1773b9e913bc4d2822fc6a30aef 100644 +--- a/dist/esm-node/ultramodern-workspace/api/shared.js ++++ b/dist/esm-node/ultramodern-workspace/api/shared.js +@@ -1,10 +1,11 @@ + import "node:module"; import { resolveApiPrefix, resolveApiStem } from "../descriptors.js"; import { renderTemplate } from "../fs-io.js"; -import { toPascalCase } from "../naming.js"; @@ -1935,7 +1980,7 @@ index a01a243a186a98d9b48fbc420d8b74a8e3b0095e..3e99a71932122d02acffeacbea0ff994 return `import { HttpApi, HttpApiEndpoint, -@@ -11,6 +12,16 @@ function createSharedApiImports() { +@@ -12,6 +13,16 @@ function createSharedApiImports() { HttpApiSchema, Schema, } from '@modern-js/plugin-bff/effect-client'; @@ -1952,7 +1997,7 @@ index a01a243a186a98d9b48fbc420d8b74a8e3b0095e..3e99a71932122d02acffeacbea0ff994 `; } function createSharedApiContract(service) { -@@ -34,30 +45,26 @@ function createSharedApiContract(service) { +@@ -35,30 +46,26 @@ function createSharedApiContract(service) { const apiPrefix = resolveApiPrefix(service); const checkoutCartSharedSchemas = createCheckoutCartSharedSchemas(service); const checkoutCartSharedSchemaSection = '' === checkoutCartSharedSchemas ? '' : `${checkoutCartSharedSchemas}\n`; @@ -1991,7 +2036,7 @@ index a01a243a186a98d9b48fbc420d8b74a8e3b0095e..3e99a71932122d02acffeacbea0ff994 const checkoutCartOperationContextTemplate = createCheckoutCartOperationContexts(service); const operationContextEntries = '' === checkoutCartOperationContextTemplate ? [ createOperationContext, -@@ -70,17 +77,7 @@ function createSharedApiContract(service) { +@@ -71,17 +78,7 @@ function createSharedApiContract(service) { listOperationContext, readinessOperationContext }).trim(); @@ -2010,7 +2055,7 @@ index a01a243a186a98d9b48fbc420d8b74a8e3b0095e..3e99a71932122d02acffeacbea0ff994 export interface ${itemType} { readonly id: string; -@@ -88,17 +85,7 @@ export interface ${itemType} { +@@ -89,17 +86,7 @@ export interface ${itemType} { readonly title: string; } @@ -2029,7 +2074,7 @@ index a01a243a186a98d9b48fbc420d8b74a8e3b0095e..3e99a71932122d02acffeacbea0ff994 export interface ${createPayloadType} { readonly title: string; -@@ -117,17 +104,8 @@ export interface ${notFoundErrorExport} { +@@ -118,17 +105,8 @@ export interface ${notFoundErrorExport} { readonly id: string; } @@ -2049,7 +2094,7 @@ index a01a243a186a98d9b48fbc420d8b74a8e3b0095e..3e99a71932122d02acffeacbea0ff994 export const ${schemaExport}: Schema.Codec<${itemType}> = Schema.Struct({ id: Schema.String, -@@ -135,17 +113,8 @@ export const ${schemaExport}: Schema.Codec<${itemType}> = Schema.Struct({ +@@ -136,17 +114,8 @@ export const ${schemaExport}: Schema.Codec<${itemType}> = Schema.Struct({ title: Schema.String, }); @@ -2069,7 +2114,7 @@ index a01a243a186a98d9b48fbc420d8b74a8e3b0095e..3e99a71932122d02acffeacbea0ff994 export const ${createPayloadSchemaExport}: Schema.Codec<${createPayloadType}> = Schema.Struct({ title: Schema.String, -@@ -157,38 +126,31 @@ ${checkoutCartSharedSchemaSection}export const ${notFoundSchemaExport}: Schema.C +@@ -158,38 +127,31 @@ ${checkoutCartSharedSchemaSection}export const ${notFoundSchemaExport}: Schema.C HttpApiSchema.status(404), ); @@ -2131,7 +2176,7 @@ index a01a243a186a98d9b48fbc420d8b74a8e3b0095e..3e99a71932122d02acffeacbea0ff994 HttpApiEndpoint.get('get', '/${stem}/:id', { error: ${notFoundSchemaExport}, params: { -@@ -219,8 +181,8 @@ ${createCheckoutCartApiContractFields(service)} ownerId: '${service.id}', +@@ -220,8 +182,8 @@ ${createCheckoutCartApiContractFields(service)} ownerId: '${service.id}', } as const; `; } @@ -2142,11 +2187,11 @@ index a01a243a186a98d9b48fbc420d8b74a8e3b0095e..3e99a71932122d02acffeacbea0ff994 ${createSharedApiContract(service)}`; } export { createSharedApi }; -diff --git a/dist/esm/ultramodern-workspace/demo-components.js b/dist/esm/ultramodern-workspace/demo-components.js -index 4baf232ad01064de66c3dcf8b913a99b4754e5ce..3b5ff3fbf21fdd396bd0a230457798e07a6603fd 100644 ---- a/dist/esm/ultramodern-workspace/demo-components.js -+++ b/dist/esm/ultramodern-workspace/demo-components.js -@@ -266,13 +266,13 @@ function createLayout(appId) { +diff --git a/dist/esm-node/ultramodern-workspace/demo-components.js b/dist/esm-node/ultramodern-workspace/demo-components.js +index 503564abb6a3ea1be8e3c209197f55ef49b6209f..d0e7878f9d82d9fe8996ed1987eee2f9c12a0862 100644 +--- a/dist/esm-node/ultramodern-workspace/demo-components.js ++++ b/dist/esm-node/ultramodern-workspace/demo-components.js +@@ -267,13 +267,13 @@ function createLayout(appId) { return `import { Outlet } from '@modern-js/plugin-tanstack/runtime'; import './index.css'; @@ -2167,22 +2212,23 @@ index 4baf232ad01064de66c3dcf8b913a99b4754e5ce..3b5ff3fbf21fdd396bd0a230457798e0 `; } function createRemoteEntry(app) { -diff --git a/dist/esm/ultramodern-workspace/mf-validation/constants.js b/dist/esm/ultramodern-workspace/mf-validation/constants.js -index eb2e286b9e3f8dfd6fdb3b0e4473efcc87d2f0a6..32509a4e998fa4dec3ef4d53fc58a5a2b92c3a20 100644 ---- a/dist/esm/ultramodern-workspace/mf-validation/constants.js -+++ b/dist/esm/ultramodern-workspace/mf-validation/constants.js -@@ -1,5 +1,5 @@ +diff --git a/dist/esm-node/ultramodern-workspace/mf-validation/constants.js b/dist/esm-node/ultramodern-workspace/mf-validation/constants.js +index f52edfd11fbd7ade07b5b37ec90186e66f3573b8..cfdd41d84464e05894479b3e4c09a28f3f952e38 100644 +--- a/dist/esm-node/ultramodern-workspace/mf-validation/constants.js ++++ b/dist/esm-node/ultramodern-workspace/mf-validation/constants.js +@@ -1,6 +1,6 @@ + import "node:module"; const moduleFederationConfigFile = 'module-federation.config.ts'; -const mfTypesArchive = 'dist/@mf-types.zip'; +const mfTypesArchive = process.env.ULTRAMODERN_MF_TYPES_ARCHIVE || 'dist/@mf-types.zip'; const generatedMetadataPaths = [ '.modernjs/ultramodern.json' ]; -diff --git a/dist/esm/ultramodern-workspace/mf-validation/inspect.js b/dist/esm/ultramodern-workspace/mf-validation/inspect.js -index d3d50e38814b4f1c95815a1bb61065c163ab8772..7f8bc5f5d7699225fadf3d711ab0571fbc4f2e68 100644 ---- a/dist/esm/ultramodern-workspace/mf-validation/inspect.js -+++ b/dist/esm/ultramodern-workspace/mf-validation/inspect.js -@@ -11,8 +11,12 @@ function extractExposes(configPath, value) { +diff --git a/dist/esm-node/ultramodern-workspace/mf-validation/inspect.js b/dist/esm-node/ultramodern-workspace/mf-validation/inspect.js +index c333334745820eaf95b7a9783e481b500c669590..5bfe33f755f0ad4460fb93a47f4f2fd042067afc 100644 +--- a/dist/esm-node/ultramodern-workspace/mf-validation/inspect.js ++++ b/dist/esm-node/ultramodern-workspace/mf-validation/inspect.js +@@ -12,8 +12,12 @@ function extractExposes(configPath, value) { if (array) return array.sort(); throw new Error(`Cannot statically extract Module Federation exposes from ${configPath}; use a literal exposes object or string array.`); } @@ -2196,7 +2242,7 @@ index d3d50e38814b4f1c95815a1bb61065c163ab8772..7f8bc5f5d7699225fadf3d711ab0571f const dts = parseObjectLiteral(value); if (!dts || dts.hasSpread) throw new Error(`Cannot statically extract Module Federation DTS settings from ${configPath}; use a literal dts object.`); const generateTypes = parseObjectLiteral(dts.properties.get('generateTypes')); -@@ -38,7 +42,7 @@ function inspectModuleFederationConfigSource(source, appDir, configPath) { +@@ -39,7 +43,7 @@ function inspectModuleFederationConfigSource(source, appDir, configPath) { return { appDir, configPath, @@ -2205,11 +2251,11 @@ index d3d50e38814b4f1c95815a1bb61065c163ab8772..7f8bc5f5d7699225fadf3d711ab0571f exposes, hostOnlyNoExposes }; -diff --git a/dist/esm/ultramodern-workspace/module-federation/config.js b/dist/esm/ultramodern-workspace/module-federation/config.js -index d5dead9cf23d170edaa7d30fc60c2f7d64c217df..eb3e2e74650a06d7130b77594239ef8b6b3c16a0 100644 ---- a/dist/esm/ultramodern-workspace/module-federation/config.js -+++ b/dist/esm/ultramodern-workspace/module-federation/config.js -@@ -11,19 +11,18 @@ import { createSharedModuleFederationConfig, formatTsObjectLiteral } from "./sha +diff --git a/dist/esm-node/ultramodern-workspace/module-federation/config.js b/dist/esm-node/ultramodern-workspace/module-federation/config.js +index 5956501f8ec070d086690b2be4096c40c0c249c0..6ac78eac22d08322d0546fc25bcd5c642ed216c1 100644 +--- a/dist/esm-node/ultramodern-workspace/module-federation/config.js ++++ b/dist/esm-node/ultramodern-workspace/module-federation/config.js +@@ -12,19 +12,18 @@ import { createSharedModuleFederationConfig, formatTsObjectLiteral } from "./sha function createAppModernConfig(scope, app, remotes = [], enableTailwind = true, configuredDevPorts) { const deliveryUnit = createDeliveryUnitRecord(scope, app); const emitsUi = appEmitsBrowserUi(app); @@ -2235,7 +2281,7 @@ index d5dead9cf23d170edaa7d30fc60c2f7d64c217df..eb3e2e74650a06d7130b77594239ef8b // Zephyr uploads federated build artifacts to Zephyr Cloud (the fast // rollback path). Uploading REQUIRES a Zephyr Cloud account and, in CI, a // deploy-scoped ZE_CI_TOKEN; without it Zephyr fatally fails to load its -@@ -34,8 +33,7 @@ import { ultramodernLocalisedUrls } from './src/routes/ultramodern-route-metadat +@@ -35,8 +34,7 @@ import { ultramodernLocalisedUrls } from './src/routes/ultramodern-route-metadat // (this gate keys on Zephyr's native deploy token, not any UltraModern // opt-out). When deploying, ZE_FAIL_BUILD=true makes an upload failure a // hard build failure. @@ -2245,7 +2291,7 @@ index d5dead9cf23d170edaa7d30fc60c2f7d64c217df..eb3e2e74650a06d7130b77594239ef8b if (!zephyrCiDeploy) { return; } -@@ -46,7 +44,7 @@ import { ultramodernLocalisedUrls } from './src/routes/ultramodern-route-metadat +@@ -47,7 +45,7 @@ import { ultramodernLocalisedUrls } from './src/routes/ultramodern-route-metadat }); ` : ''; @@ -2254,7 +2300,7 @@ index d5dead9cf23d170edaa7d30fc60c2f7d64c217df..eb3e2e74650a06d7130b77594239ef8b const uiPluginEntries = emitsUi ? ' moduleFederationPlugin(),\n zephyrRspackPlugin(),\n' : ''; const tailwindImport = enableTailwind ? "import { pluginTailwindcss } from '@rsbuild/plugin-tailwindcss';\n" : ''; const bffConfig = appHasApi(app) ? ` bff: { -@@ -82,17 +80,16 @@ ${distributedSsrExposes(service).map((expose)=>` { +@@ -83,17 +81,16 @@ ${distributedSsrExposes(service).map((expose)=>` { ], ` : ''; const defaultAssetPrefixSource = 'shell' === app.kind ? "const defaultAssetPrefix = '/';" : `const remoteAssetOrigin = @@ -2276,7 +2322,7 @@ index d5dead9cf23d170edaa7d30fc60c2f7d64c217df..eb3e2e74650a06d7130b77594239ef8b const defaultAssetPrefix = defaultRemoteAssetPrefix;`; const devAssetPrefixSource = 'shell' === app.kind ? ` // Keep shell dev assets origin-relative so the shell works through // tunnels and local previews without rewriting its own chunks. -@@ -107,7 +104,7 @@ const defaultAssetPrefix = defaultRemoteAssetPrefix;`; +@@ -108,7 +105,7 @@ const defaultAssetPrefix = defaultRemoteAssetPrefix;`; ]).filter((port)=>'number' == typeof port && Number.isFinite(port))) ].toSorted((left, right)=>left - right); const legacyCorsSource = `const moduleFederationDevServerOrigin = @@ -2285,7 +2331,7 @@ index d5dead9cf23d170edaa7d30fc60c2f7d64c217df..eb3e2e74650a06d7130b77594239ef8b const configuredCorsSource = `const moduleFederationDevServerAllowedOrigins = [ ${developmentPorts.map((port)=>` 'http://localhost:${port}',`).join('\n')} ];`; -@@ -119,7 +116,125 @@ ${developmentPorts.map((port)=>` 'http://localhost:${port}',`).join('\n')} +@@ -120,7 +117,125 @@ ${developmentPorts.map((port)=>` 'http://localhost:${port}',`).join('\n')} }, },`; const useConfiguredCorsAllowlist = void 0 !== configuredDevPorts; @@ -2411,7 +2457,7 @@ index d5dead9cf23d170edaa7d30fc60c2f7d64c217df..eb3e2e74650a06d7130b77594239ef8b return renderFileTemplate('workspace/apps/modern.config.ts', { value0: `${bffImport}${tailwindImport}`, value1: app.id, -@@ -141,7 +256,7 @@ ${developmentPorts.map((port)=>` 'http://localhost:${port}',`).join('\n')} +@@ -142,7 +257,7 @@ ${developmentPorts.map((port)=>` 'http://localhost:${port}',`).join('\n')} value17: createRspackChunkLoadingGlobal(app), value18: tailwindBuilderPluginsConfig, value19: useConfiguredCorsAllowlist ? configuredCorsSource : legacyCorsSource, @@ -2420,7 +2466,7 @@ index d5dead9cf23d170edaa7d30fc60c2f7d64c217df..eb3e2e74650a06d7130b77594239ef8b value21: configuredCorsHeader, value22: uiImports, value23: zephyrPluginSource, -@@ -149,7 +264,19 @@ ${developmentPorts.map((port)=>` 'http://localhost:${port}',`).join('\n')} +@@ -150,7 +265,19 @@ ${developmentPorts.map((port)=>` 'http://localhost:${port}',`).join('\n')} value25: uiPluginEntries, value26: deliveryUnit.unitId, value27: deliveryUnit.buildMarker, @@ -2441,7 +2487,7 @@ index d5dead9cf23d170edaa7d30fc60c2f7d64c217df..eb3e2e74650a06d7130b77594239ef8b }); } function createModuleFederationBridgeConfig(enableBridgeRouter) { -@@ -204,15 +331,15 @@ export default moduleFederationConfig; +@@ -205,15 +332,15 @@ export default moduleFederationConfig; function createBackendModuleFederationConfig(app) { return `import { createRequire } from 'node:module'; import { createModuleFederationConfig } from '@module-federation/modern-js-v3'; @@ -2463,11 +2509,11 @@ index d5dead9cf23d170edaa7d30fc60c2f7d64c217df..eb3e2e74650a06d7130b77594239ef8b const moduleFederationConfig: Parameters< typeof createModuleFederationConfig -diff --git a/dist/esm/ultramodern-workspace/module-federation/reexport-module.js b/dist/esm/ultramodern-workspace/module-federation/reexport-module.js -index 1e2bef1edab5bff011cc1913b7746c39312ca90b..d8b5407100b33140151d7044abbcf828ea61017a 100644 ---- a/dist/esm/ultramodern-workspace/module-federation/reexport-module.js -+++ b/dist/esm/ultramodern-workspace/module-federation/reexport-module.js -@@ -13,14 +13,24 @@ function createUltramodernBuildModule(scope, app) { +diff --git a/dist/esm-node/ultramodern-workspace/module-federation/reexport-module.js b/dist/esm-node/ultramodern-workspace/module-federation/reexport-module.js +index be7c63e9f560d2f31a3ee28d4047d700a51b2abb..6e7cf4f8d2162d7f5a6af390bff98261db5a3fbe 100644 +--- a/dist/esm-node/ultramodern-workspace/module-federation/reexport-module.js ++++ b/dist/esm-node/ultramodern-workspace/module-federation/reexport-module.js +@@ -14,14 +14,24 @@ function createUltramodernBuildModule(scope, app) { declare const ULTRAMODERN_SOURCE_REVISION: string; const ultramodernGeneratedBuildArtifact = ${JSON.stringify(createUltramodernBuildArtifact(record), null, 2)} as const; @@ -2500,11 +2546,34 @@ index 1e2bef1edab5bff011cc1913b7746c39312ca90b..d8b5407100b33140151d7044abbcf828 const ultramodernBuildArtifact = { ...ultramodernGeneratedBuildArtifact, deliveryUnit: { -diff --git a/dist/esm/ultramodern-workspace/package-json.js b/dist/esm/ultramodern-workspace/package-json.js -index d97db9c90294bd70a94d5ccc10296a0079cd014e..053864d01b5e842b49f083a26753c60b91aa648f 100644 ---- a/dist/esm/ultramodern-workspace/package-json.js -+++ b/dist/esm/ultramodern-workspace/package-json.js -@@ -171,7 +171,7 @@ function createAppPackage(scope, app, packageSource, enableTailwind, remotes = [ +@@ -46,22 +56,17 @@ const ultramodernBuildArtifact = { + }, + } as const; + +-export { ultramodernBuildArtifact }; +- + export const ultramodernDeliveryUnit = + ultramodernBuildArtifact.deliveryUnit; +-export const ultramodernVerticalIdentity = ultramodernDeliveryUnit; + export const ultramodernUiMarker = ultramodernBuildArtifact.surfaces.ui; + export const ultramodernApiMarker = ultramodernBuildArtifact.surfaces.api; + `; + } + function createUltramodernBuildReexportModule() { + return `export { +- ultramodernBuildArtifact, + ultramodernApiMarker, + ultramodernDeliveryUnit, + ultramodernUiMarker, +- ultramodernVerticalIdentity, + } from '../shared/ultramodern-build'; + `; + } +diff --git a/dist/esm-node/ultramodern-workspace/package-json.js b/dist/esm-node/ultramodern-workspace/package-json.js +index f901de089342afc1a1462696799a58e5821fe68e..f2690ad470ea517556177e6d109f9060e7bde92f 100644 +--- a/dist/esm-node/ultramodern-workspace/package-json.js ++++ b/dist/esm-node/ultramodern-workspace/package-json.js +@@ -172,7 +172,7 @@ function createAppPackage(scope, app, packageSource, enableTailwind, remotes = [ if (Object.keys(packageExports).length > 0) packageJson.exports = packageExports; return packageJson; } @@ -2513,7 +2582,7 @@ index d97db9c90294bd70a94d5ccc10296a0079cd014e..053864d01b5e842b49f083a26753c60b const packageJson = { private: true, name: packageName(scope, id), -@@ -188,10 +188,21 @@ function createSharedPackage(scope, id, description) { +@@ -189,10 +189,21 @@ function createSharedPackage(scope, id, description) { '@effect/tsgo': ULTRAMODERN_PACKAGE_PINS.appDevDependencies["@effect/tsgo"] } }; @@ -2535,11 +2604,11 @@ index d97db9c90294bd70a94d5ccc10296a0079cd014e..053864d01b5e842b49f083a26753c60b return packageJson; } function createSharedContractsIndex() { -diff --git a/dist/esm/ultramodern-workspace/workspace-script-plan.js b/dist/esm/ultramodern-workspace/workspace-script-plan.js -index 683e43743c53e41b163455e8c8138929a44480c2..2d082066f2c5f5b489ec69d9fd7faa4dad5a8ce6 100644 ---- a/dist/esm/ultramodern-workspace/workspace-script-plan.js -+++ b/dist/esm/ultramodern-workspace/workspace-script-plan.js -@@ -108,7 +108,7 @@ function createWorkspaceRootScriptPlan(remotes = [], options = {}) { +diff --git a/dist/esm-node/ultramodern-workspace/workspace-script-plan.js b/dist/esm-node/ultramodern-workspace/workspace-script-plan.js +index d5c918d9687cc706d40994e9526d3639624b4acd..f64f423af3cfcc62459c9a6919d63760eca271b8 100644 +--- a/dist/esm-node/ultramodern-workspace/workspace-script-plan.js ++++ b/dist/esm-node/ultramodern-workspace/workspace-script-plan.js +@@ -109,7 +109,7 @@ function createWorkspaceRootScriptPlan(remotes = [], options = {}) { migrateStrictEffect: rootToolingWrapperCommand('migrateStrictEffect'), zeropsMaterialize: "node ./scripts/materialize-zerops-runtime.mjs", contractCheck: rootToolingWrapperCommand('validate'), @@ -2548,11 +2617,11 @@ index 683e43743c53e41b163455e8c8138929a44480c2..2d082066f2c5f5b489ec69d9fd7faa4d check: `pnpm format:check && pnpm lint && pnpm typecheck && pnpm skills:check && pnpm i18n:boundaries && pnpm api:check && pnpm contract:check && pnpm performance:readiness${bridgeCheck}` }; } -diff --git a/dist/esm/ultramodern-workspace/workspace-scripts.js b/dist/esm/ultramodern-workspace/workspace-scripts.js -index ce720ef74bd8b5c9a09d29d87db247834d54121e..b31c1beef859c3374dea6b6473d97d2ab5eaef91 100644 ---- a/dist/esm/ultramodern-workspace/workspace-scripts.js -+++ b/dist/esm/ultramodern-workspace/workspace-scripts.js -@@ -115,6 +115,9 @@ function createWorkspaceI18nBoundaryValidationScript() { +diff --git a/dist/esm-node/ultramodern-workspace/workspace-scripts.js b/dist/esm-node/ultramodern-workspace/workspace-scripts.js +index 7854e7fa839cccd828c491664fdd2c3b2ca0365f..f4b1fe155da8c0a624014374769fb3b32f891395 100644 +--- a/dist/esm-node/ultramodern-workspace/workspace-scripts.js ++++ b/dist/esm-node/ultramodern-workspace/workspace-scripts.js +@@ -116,6 +116,9 @@ function createWorkspaceI18nBoundaryValidationScript() { function createWorkspaceApiBoundaryValidationScript() { return external_fs_io_js_readFileTemplate("workspace-scripts/check-ultramodern-api-boundaries.mts"); } @@ -2562,7 +2631,7 @@ index ce720ef74bd8b5c9a09d29d87db247834d54121e..b31c1beef859c3374dea6b6473d97d2a function createPerformanceReadinessConfigScript() { return external_fs_io_js_readFileTemplate("workspace-scripts/ultramodern-performance-readiness.config.mjs"); } -@@ -129,6 +132,7 @@ function writeGeneratedWorkspaceScripts(targetDir, scope, enableTailwind, remote +@@ -130,6 +133,7 @@ function writeGeneratedWorkspaceScripts(targetDir, scope, enableTailwind, remote const hasBackendSurface = remotes.some(appHasApi); writeWorkspaceOwnedMtsScript(targetDir, 'check-ultramodern-i18n-boundaries', createWorkspaceI18nBoundaryValidationScript()); writeWorkspaceOwnedMtsScript(targetDir, 'check-ultramodern-api-boundaries', createWorkspaceApiBoundaryValidationScript()); @@ -2570,7 +2639,7 @@ index ce720ef74bd8b5c9a09d29d87db247834d54121e..b31c1beef859c3374dea6b6473d97d2a if (!shellOnly) { writeFileReplacing(targetDir, "scripts/materialize-zerops-runtime.mjs", createZeropsRuntimeMaterializationScript()); writeWorkspaceOwnedMtsScript(targetDir, 'proof-workerd-ssr', createWorkerdSsrProofScript()); -@@ -161,6 +165,10 @@ function migratedWorkspaceScriptArtifacts(options) { +@@ -162,6 +166,10 @@ function migratedWorkspaceScriptArtifacts(options) { content: createWorkspaceApiBoundaryValidationScript(), legacyPath: "scripts/check-ultramodern-api-boundaries.mjs" }, @@ -2581,7 +2650,7 @@ index ce720ef74bd8b5c9a09d29d87db247834d54121e..b31c1beef859c3374dea6b6473d97d2a { relativePath: "scripts/ultramodern-performance-readiness.config.mjs", content: createPerformanceReadinessConfigScript() -@@ -192,6 +200,7 @@ function migratedWorkspaceScriptArtifacts(options) { +@@ -193,6 +201,7 @@ function migratedWorkspaceScriptArtifacts(options) { const migratedWorkspaceScriptBasenames = [ 'check-ultramodern-i18n-boundaries', 'check-ultramodern-api-boundaries', @@ -2589,11 +2658,11 @@ index ce720ef74bd8b5c9a09d29d87db247834d54121e..b31c1beef859c3374dea6b6473d97d2a 'bootstrap-agent-skills', 'setup-agent-reference-repos', 'proof-workerd-ssr', -diff --git a/dist/esm/ultramodern-workspace/write-app.js b/dist/esm/ultramodern-workspace/write-app.js -index 15a02949f43f418dd305a50e9c2edebd7f7db907..b99e450abba5a47f2cc0c768425b4d5c484ce3ed 100644 ---- a/dist/esm/ultramodern-workspace/write-app.js -+++ b/dist/esm/ultramodern-workspace/write-app.js -@@ -88,8 +88,8 @@ function writeAppRouteAndShellFiles({ targetDir, scope, resolvedApp, emitsUi, re +diff --git a/dist/esm-node/ultramodern-workspace/write-app.js b/dist/esm-node/ultramodern-workspace/write-app.js +index 60cc1188ac1f6174f7c5c1bdfe62d8d04e44735f..7d3744fac4dcd0675c8dc03ca2d8e7a1658589d8 100644 +--- a/dist/esm-node/ultramodern-workspace/write-app.js ++++ b/dist/esm-node/ultramodern-workspace/write-app.js +@@ -89,8 +89,8 @@ function writeAppRouteAndShellFiles({ targetDir, scope, resolvedApp, emitsUi, re function writeAppApiAndRemoteExposeFiles({ targetDir, scope, resolvedApp, emitsUi, writeAppFile }) { if (appHasApi(resolvedApp)) { const rpcProtocol = 'rpc' === resolveApiProtocol(resolvedApp); @@ -2604,11 +2673,11 @@ index 15a02949f43f418dd305a50e9c2edebd7f7db907..b99e450abba5a47f2cc0c768425b4d5c writeFile(targetDir, `${resolvedApp.directory}/api/backend-federation.ts`, createBackendFederationContractFile(resolvedApp)); writeFile(targetDir, `${resolvedApp.directory}/api/effect-api.ts`, createBackendEffectApiExpose(scope, resolvedApp)); rpcProtocol ? writeFile(targetDir, `${resolvedApp.directory}/src/api/${resolvedApp.api.stem}-rpc-client.ts`, createRpcClientFile(resolvedApp)) : writeFile(targetDir, `${resolvedApp.directory}/src/api/${resolvedApp.api.stem}-client.ts`, createApiClient(resolvedApp, '../../shared/api')); -diff --git a/dist/esm/ultramodern-workspace/write-workspace.js b/dist/esm/ultramodern-workspace/write-workspace.js -index afd450197f1a313fe7f8edd6b349d10b9e0ad8a2..0e34c9589d469b542916dd57ff3fead0384c4591 100644 ---- a/dist/esm/ultramodern-workspace/write-workspace.js -+++ b/dist/esm/ultramodern-workspace/write-workspace.js -@@ -4,7 +4,7 @@ import { createSharedDesignTokensCss } from "./app-files.js"; +diff --git a/dist/esm-node/ultramodern-workspace/write-workspace.js b/dist/esm-node/ultramodern-workspace/write-workspace.js +index 5b5c2103812a3a6be778cb9aa4a591614ecf88bd..7f3dcf6d110aa65295167396c44bc4f5d73f19ce 100644 +--- a/dist/esm-node/ultramodern-workspace/write-workspace.js ++++ b/dist/esm-node/ultramodern-workspace/write-workspace.js +@@ -5,7 +5,7 @@ import { createSharedDesignTokensCss } from "./app-files.js"; import { normalizeUltramodernBridgeConfig } from "./bridge-config.js"; import { createDevelopmentOverlay, createOwnership, createTopology, createUltramodernConfig } from "./contracts.js"; import { ULTRAMODERN_CONFIG_PATH, createShellHost, sharedPackages, shellApp } from "./descriptors.js"; @@ -2617,7 +2686,7 @@ index afd450197f1a313fe7f8edd6b349d10b9e0ad8a2..0e34c9589d469b542916dd57ff3fead0 import { createFileSnapshot, createGenerationResult, diffFileSnapshots } from "./generation-result.js"; import { assertUniqueTailwindPrefixes, toPackageScope } from "./naming.js"; import { runCodeSmithOverlays } from "./overlays.js"; -@@ -18,12 +18,13 @@ import { createZeropsYaml } from "./zerops.js"; +@@ -19,12 +19,13 @@ import { createZeropsYaml } from "./zerops.js"; function hasExplicitInstallRequest(options) { return void 0 !== options.packageSource && 'workspace' !== options.packageSource.strategy; } @@ -2633,7 +2702,7 @@ index afd450197f1a313fe7f8edd6b349d10b9e0ad8a2..0e34c9589d469b542916dd57ff3fead0 writeFile(targetDir, 'packages/shared-design-tokens/src/index.ts', `export const sharedDesignTokens = { color: { accent: '#2f8f68', -@@ -134,7 +135,7 @@ function generateUltramodernWorkspace(options) { +@@ -135,7 +136,7 @@ function generateUltramodernWorkspace(options) { writeJson(options.targetDir, ULTRAMODERN_CONFIG_PATH, createCompactUltramodernConfig(scope, options.modernVersion, packageSource, createdApps, enableTailwind, bridge)); writeApp(options.targetDir, scope, shellApp, packageSource, enableTailwind, initialVerticals, bridge); for (const remote of initialVerticals)writeApp(options.targetDir, scope, remote, packageSource, enableTailwind, initialVerticals, bridge); @@ -2844,84 +2913,370 @@ index 198beb018e1d6985953748cea5000448f381abe5..7d2acb37a8058c1c7b94c1df033d34e7 export type UltramodernPublicSitemapChangeFrequency = | 'always' | 'hourly' -diff --git a/templates/workspace-scripts/check-ultramodern-api-boundaries.mts b/templates/workspace-scripts/check-ultramodern-api-boundaries.mts -index d606a2265cb438d32a612cfdd6ebb9561529d4f5..b31f281e7ab5bbea289fc2aae117fe39202023cf 100644 ---- a/templates/workspace-scripts/check-ultramodern-api-boundaries.mts -+++ b/templates/workspace-scripts/check-ultramodern-api-boundaries.mts -@@ -1,6 +1,10 @@ - #!/usr/bin/env node - import fs from 'node:fs'; - import path from 'node:path'; -+import { -+ configuredMicroVerticalApiStem, -+ microVerticalApiBaselineViolation, -+} from './microvertical-api-baseline-boundary.mts'; - - const workspaceRoot = process.env.ULTRAMODERN_WORKSPACE_ROOT ?? process.cwd(); - const failures = []; -@@ -182,11 +186,1184 @@ for (const file of textFiles) { - } - - const verticalDirectories = listDirectories('verticals'); -+const topology = exists('topology/reference-topology.json') -+ ? JSON.parse(readText('topology/reference-topology.json')) -+ : { verticals: [] }; - const shellClient = 'apps/shell-super-app/src/api/vertical-clients.ts'; - if (exists('apps/shell-super-app') && verticalDirectories.length > 0) { - assert(exists(shellClient), `${shellClient} must aggregate vertical API clients.`); - } - -+const runtimeIdentifierPattern = String.raw`[$A-Z_a-z][$\w]*`; -+ -+function escapeRegularExpression(value) { -+ return value.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`); -+} -+ -+const sourceTriviaPattern = -+ /'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"|`(?:\\.|[^`\\])*`|\/\/[^\n\r]*|\/\*[\s\S]*?\*\//gu; -+const regularExpressionLiteralPattern = -+ /(?(?:^|[!(:,;=[{]|=>|\b(?:case|return|throw))[\t ]*)\/(?![*/])(?:\\.|\[(?:\\.|[^\]\\\n\r])*\]|[^/\\\n\r])+\/[dgimsuvy]*/gmu; -+ -+function mask(value) { -+ return value.replaceAll(/[^\n\r]/gu, ' '); -+} -+ -+function withoutRegularExpressionLiterals(source) { -+ return source.replaceAll( -+ regularExpressionLiteralPattern, -+ (value, prefix) => `${prefix}${mask(value.slice(prefix.length))}`, -+ ); -+} -+ -+function withoutComments(source) { -+ return withoutRegularExpressionLiterals(source).replaceAll(sourceTriviaPattern, (value) => -+ value.startsWith('//') || value.startsWith('/*') ? mask(value) : value, -+ ); -+} -+ -+function withoutCommentsOrLiterals(source) { -+ return withoutRegularExpressionLiterals(source).replaceAll(sourceTriviaPattern, mask); -+} -+ -+function importsNamedValueMatchingSpecifier(source, name, specifierPattern) { -+ const visibleSource = withoutComments(source); -+ const code = withoutCommentsOrLiterals(source); -+ const imports = visibleSource.matchAll( -+ new RegExp( -+ String.raw`^(?[\t ]*)import\s*\{(?[^}]*)\}\s*from\s*['"]${specifierPattern}['"]`, -+ 'gmu', +diff --git a/templates/workspace/apps/modern.config.ts.handlebars b/templates/workspace/apps/modern.config.ts.handlebars +index 2144abc7232f756a7d824e18bd90ce881e0dadc2..000ece862905b277cc002c7437ce3746da48d75e 100644 +--- a/templates/workspace/apps/modern.config.ts.handlebars ++++ b/templates/workspace/apps/modern.config.ts.handlebars +@@ -1,25 +1,60 @@ +-import { ++{{value36}}import { {{value29}}createRequire } from 'node:module'; ++{{value30}}import { + appTools, + defineConfig, + presetUltramodern, + } from '@modern-js/app-tools'; ++import type { ++ {{value34}}AppToolsUserConfig{{value35}} ++} from '@modern-js/app-tools'; + import { + getBuildConfigEnvironment, + withBuildConfigEnvironment, + } from '@modern-js/app-tools/config'; + {{value0}}import { i18nPlugin } from '@modern-js/plugin-i18n'; + import { tanstackRouterPlugin } from '@modern-js/plugin-tanstack'; ++import { Config, Option, Result, Schema } from 'effect'; + {{value22}} +-const cloudflareDeployEnabled = +- getBuildConfigEnvironment('MODERNJS_DEPLOY') === 'cloudflare'; +- ++Object.assign(globalThis, { require: createRequire(import.meta.url) }); ++{{value37}} ++const nonEmptyBuildStringSchema = Schema.Trim.pipe(Schema.check(Schema.isMinLength(1))); ++const getOptionalBuildConfig = (name: string): string | undefined => { ++ const decoded = Schema.decodeUnknownResult( ++ Schema.OptionFromUndefinedOr(nonEmptyBuildStringSchema), ++ )(getBuildConfigEnvironment(name)); ++ return Result.isSuccess(decoded) ? Option.getOrUndefined(decoded.success) : undefined; ++}; ++const envValue = getOptionalBuildConfig; ++const getBuildBoolean = (name: string): boolean => ++ Option.getOrElse( ++ Result.getOrThrow( ++ Schema.decodeUnknownResult(Schema.OptionFromUndefinedOr(Config.Boolean))( ++ getBuildConfigEnvironment(name), ++ ), + ), ++ () => false, + ); -+ return [...imports].some((candidate) => { -+ const indentLength = candidate.groups?.indent?.length ?? 0; -+ const isRealImport = code.slice(candidate.index + indentLength).startsWith('import'); -+ return ( -+ isRealImport && -+ (candidate.groups?.bindings?.split(',').some((binding) => { -+ const [imported, local = imported] = binding.trim().split(/\s+as\s+/u); -+ return local === name; -+ }) ?? false) -+ ); ++const cloudflareDeployMode = Result.getOrThrow( ++ Schema.decodeUnknownResult(Schema.OptionFromUndefinedOr(Schema.Literals(['cloudflare', 'node'])))( ++ getBuildConfigEnvironment('MODERNJS_DEPLOY'), ++ ), ++); ++const cloudflareDeployEnabled = Option.contains(cloudflareDeployMode, 'cloudflare'); ++{{value31}} + {{value23}}const appId = '{{value1}}'; + const cloudflareWorkerName = '{{value2}}'; +-const port = Number(getBuildConfigEnvironment('{{value3}}') ?? {{value4}}); +-const envValue = (name: string) => { +- const value = getBuildConfigEnvironment(name)?.trim(); +- return value !== undefined && value.length > 0 ? value : undefined; +-}; ++const port = Option.getOrElse( ++ Result.getOrThrow( ++ Schema.decodeUnknownResult( ++ Schema.OptionFromUndefinedOr( ++ Schema.NumberFromString.pipe( ++ Schema.check(Schema.isInt(), Schema.isBetween({ maximum: 65_535, minimum: 1 })), ++ ), ++ ), ++ )(getBuildConfigEnvironment('{{value3}}')), ++ ), ++ () => {{value4}}, ++); + const configuredSiteUrl = envValue('MODERN_PUBLIC_SITE_URL'); + const configuredCloudflareUrl = envValue('{{value5}}'); + const configuredUltramodernAssetPrefix = envValue('ULTRAMODERN_ASSET_PREFIX'); +@@ -35,16 +70,16 @@ const inferredCloudflareUrl = + // Site origin (SEO: canonical/hreflang URLs) prefers the site-wide public URL; + // the per-app deployment URL only fills in when no site origin is configured. + const siteUrl = +- configuredSiteUrl || +- configuredCloudflareUrl || +- inferredCloudflareUrl || ++ configuredSiteUrl ?? ++ configuredCloudflareUrl ?? ++ inferredCloudflareUrl ?? + `http://localhost:${port}`; + {{value7}} + // Asset loading is intentionally independent from the canonical site URL. + // Module Federation remotes must publish an absolute publicPath so browsers + // load remoteEntry.js and exposed chunks from the remote origin, not the host. + const assetPrefix = +- configuredModernAssetPrefix || configuredUltramodernAssetPrefix || defaultAssetPrefix; ++ configuredModernAssetPrefix ?? configuredUltramodernAssetPrefix ?? defaultAssetPrefix; + const buildTarget = cloudflareDeployEnabled ? 'cloudflare' : 'web'; + const buildOutputRoot = cloudflareDeployEnabled ? 'dist-cloudflare' : 'dist'; + const buildTempDirectory = `node_modules/.modern-js-${appId}-${buildTarget}`; +@@ -52,7 +87,7 @@ const buildCacheDirectory = `node_modules/.cache/rspack-${appId}-${buildTarget}` + + if ( + cloudflareDeployEnabled && +- getBuildConfigEnvironment('ULTRAMODERN_CLOUDFLARE_REQUIRE_PUBLIC_URLS') === 'true' && ++ getBuildBoolean('ULTRAMODERN_CLOUDFLARE_REQUIRE_PUBLIC_URLS') && + configuredCloudflareUrl === undefined && + configuredSiteUrl === undefined && + inferredCloudflareUrl === undefined +@@ -62,23 +97,26 @@ if ( + ); + } + ++const whenEnabled = (enabled: boolean, configuration: Configuration) => ++ enabled ? configuration : undefined; ++ ++{{value33}}const cloudflareDeployment = whenEnabled(cloudflareDeployEnabled, { ++ deploy: { ++ worker: { ++ compatibilityDate: '{{value10}}', ++ name: cloudflareWorkerName, ++ security: {{value11}}, ++{{value12}} ssr: true, ++ }, ++ }, ++} satisfies Pick); ++ + export default defineConfig( + presetUltramodern( + { +-{{value9}}{{value18}} ...(cloudflareDeployEnabled +- ? { +- deploy: { +- worker: { +- compatibilityDate: '{{value10}}', +- name: cloudflareWorkerName, +- security: {{value11}}, +-{{value12}} ssr: true, +- }, +- }, +- } +- : {}), ++{{value9}}{{value18}} ...cloudflareDeployment, + dev: { +-{{value13}}{{value20}} ++{{value13}}{{value20}}{{value38}} + }, + html: { + outputStructure: 'flat', +@@ -101,7 +139,7 @@ export default defineConfig( + }, + rsdoctor: { + disableClientServer: true, +- enabled: getBuildConfigEnvironment('ULTRAMODERN_RSDOCTOR') === 'true', ++ enabled: getBuildBoolean('ULTRAMODERN_RSDOCTOR'), + }, + }, + plugins: [ +@@ -115,6 +153,7 @@ export default defineConfig( + localeDetection: { + fallbackLanguage: 'en', + ignoreRedirectRoutes: [ ++{{value39}} + '/@mf-types', + '/assets', + '/bundles', +@@ -137,7 +176,7 @@ export default defineConfig( + {{value15}}{{value25}} ], + server: { + port, +- publicDir: ['./locales', './assets'], ++ publicDir: ['./locales', './assets'{{value40}}], + }, + source: { + alias: { +@@ -145,6 +184,8 @@ export default defineConfig( + '@modern-js/plugin-i18n/runtime/no-react-i18next', + }, + globalVars: { ++ ULTRAMODERN_SHELL_ORIGIN: ++ envValue('ULTRAMODERN_MF_DEV_ORIGIN') ?? 'http://localhost:{{value6}}', + ULTRAMODERN_SITE_URL: siteUrl, + }, + mainEntryName: 'index', +@@ -153,21 +194,13 @@ export default defineConfig( + autoprefixer: { + overrideBrowserslist: ['defaults'], + }, +- bundlerChain: chain => { ++ bundlerChain: (chain) => { + chain.output + .uniqueName('{{value16}}') + .chunkLoadingGlobal('{{value17}}'); + }, +- devServer: { +- headers: { +- 'Access-Control-Allow-Headers': +- 'Accept, Authorization, Content-Type, X-Requested-With', +- 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', +- {{value21}} +- }, +- }, +- }, +- }, ++{{value32}} }, ++ } satisfies AppToolsUserConfig, + { + appId, + deliveryUnit: { +diff --git a/templates/workspace/verticals/server/checkout-cart-handlers.ts.handlebars b/templates/workspace/verticals/server/checkout-cart-handlers.ts.handlebars +index 881ed1c31b29fa7a6db8da25bdf95fe23aefb84b..b8e360c82fcb543c4afccc994503277c20782690 100644 +--- a/templates/workspace/verticals/server/checkout-cart-handlers.ts.handlebars ++++ b/templates/workspace/verticals/server/checkout-cart-handlers.ts.handlebars +@@ -2,7 +2,7 @@ + .handle('getCart', () => + Effect.sync(() => createCheckoutCartSnapshot()).pipe( + Effect.withSpan('ultramodern.api.{{value0}}.checkout.getCart', { +- attributes: operationAttributes({{value1}}OperationContexts.getCart), ++ attributes: microVerticalOperationAttributes({{value1}}OperationContexts.getCart), + kind: 'server', + }), + ), +@@ -20,7 +20,7 @@ + return createCheckoutCartSnapshot(); + }).pipe( + Effect.withSpan('ultramodern.api.{{value2}}.checkout.addCartItem', { +- attributes: operationAttributes({{value3}}OperationContexts.addCartItem), ++ attributes: microVerticalOperationAttributes({{value3}}OperationContexts.addCartItem), + kind: 'server', + }), + ), +@@ -31,7 +31,7 @@ + return createCheckoutCartSnapshot(); + }).pipe( + Effect.withSpan('ultramodern.api.{{value4}}.checkout.removeCartItem', { +- attributes: operationAttributes({{value5}}OperationContexts.removeCartItem), ++ attributes: microVerticalOperationAttributes({{value5}}OperationContexts.removeCartItem), + kind: 'server', + }), + ), +@@ -42,7 +42,7 @@ + return createCheckoutCartSnapshot(); + }).pipe( + Effect.withSpan('ultramodern.api.{{value6}}.checkout.clearCart', { +- attributes: operationAttributes({{value7}}OperationContexts.clearCart), ++ attributes: microVerticalOperationAttributes({{value7}}OperationContexts.clearCart), + kind: 'server', + }), + ), +diff --git a/templates/workspace/verticals/shared/api.checkout-cart-operation-contexts.ts.handlebars b/templates/workspace/verticals/shared/api.checkout-cart-operation-contexts.ts.handlebars +index 04820fd4264e7df2d2fa5c803df181e3e1401a34..3eb4b2387a43ba4a163f93b3dc309df37b9bdd14 100644 +--- a/templates/workspace/verticals/shared/api.checkout-cart-operation-contexts.ts.handlebars ++++ b/templates/workspace/verticals/shared/api.checkout-cart-operation-contexts.ts.handlebars +@@ -1,29 +1,25 @@ + +- addCartItem: { ++ addCartItem: createMicroVerticalOperationContext({ + method: 'POST', + operationId: '{{value0}}:{{value1}}:addCartItem', + routePath: '/checkout/cart/items', +- source: 'generated-client', +- }, +- clearCart: { ++ }), ++ clearCart: createMicroVerticalOperationContext({ + method: 'POST', + operationId: '{{value2}}:{{value3}}:clearCart', + routePath: '/checkout/cart/clear', +- source: 'generated-client', +- }, ++ }), + {{createOperationContext}} + {{getOperationContext}} +- getCart: { ++ getCart: createMicroVerticalOperationContext({ + method: 'GET', + operationId: '{{value4}}:{{value5}}:getCart', + routePath: '/checkout/cart', +- source: 'generated-client', +- }, ++ }), + {{listOperationContext}} + {{readinessOperationContext}} +- removeCartItem: { ++ removeCartItem: createMicroVerticalOperationContext({ + method: 'POST', + operationId: '{{value6}}:{{value7}}:removeCartItem', + routePath: '/checkout/cart/remove', +- source: 'generated-client', +- }, ++ }), +diff --git a/templates/workspace-scripts/check-ultramodern-api-boundaries.mts b/templates/workspace-scripts/check-ultramodern-api-boundaries.mts +index d606a2265cb438d32a612cfdd6ebb9561529d4f5..b31f281e7ab5bbea289fc2aae117fe39202023cf 100644 +--- a/templates/workspace-scripts/check-ultramodern-api-boundaries.mts ++++ b/templates/workspace-scripts/check-ultramodern-api-boundaries.mts +@@ -1,6 +1,10 @@ + #!/usr/bin/env node + import fs from 'node:fs'; + import path from 'node:path'; ++import { ++ configuredMicroVerticalApiStem, ++ microVerticalApiBaselineViolation, ++} from './microvertical-api-baseline-boundary.mts'; + + const workspaceRoot = process.env.ULTRAMODERN_WORKSPACE_ROOT ?? process.cwd(); + const failures = []; +@@ -182,11 +186,1184 @@ for (const file of textFiles) { + } + + const verticalDirectories = listDirectories('verticals'); ++const topology = exists('topology/reference-topology.json') ++ ? JSON.parse(readText('topology/reference-topology.json')) ++ : { verticals: [] }; + const shellClient = 'apps/shell-super-app/src/api/vertical-clients.ts'; + if (exists('apps/shell-super-app') && verticalDirectories.length > 0) { + assert(exists(shellClient), `${shellClient} must aggregate vertical API clients.`); + } + ++const runtimeIdentifierPattern = String.raw`[$A-Z_a-z][$\w]*`; ++ ++function escapeRegularExpression(value) { ++ return value.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`); ++} ++ ++const sourceTriviaPattern = ++ /'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"|`(?:\\.|[^`\\])*`|\/\/[^\n\r]*|\/\*[\s\S]*?\*\//gu; ++const regularExpressionLiteralPattern = ++ /(?(?:^|[!(:,;=[{]|=>|\b(?:case|return|throw))[\t ]*)\/(?![*/])(?:\\.|\[(?:\\.|[^\]\\\n\r])*\]|[^/\\\n\r])+\/[dgimsuvy]*/gmu; ++ ++function mask(value) { ++ return value.replaceAll(/[^\n\r]/gu, ' '); ++} ++ ++function withoutRegularExpressionLiterals(source) { ++ return source.replaceAll( ++ regularExpressionLiteralPattern, ++ (value, prefix) => `${prefix}${mask(value.slice(prefix.length))}`, ++ ); ++} ++ ++function withoutComments(source) { ++ return withoutRegularExpressionLiterals(source).replaceAll(sourceTriviaPattern, (value) => ++ value.startsWith('//') || value.startsWith('/*') ? mask(value) : value, ++ ); ++} ++ ++function withoutCommentsOrLiterals(source) { ++ return withoutRegularExpressionLiterals(source).replaceAll(sourceTriviaPattern, mask); ++} ++ ++function importsNamedValueMatchingSpecifier(source, name, specifierPattern) { ++ const visibleSource = withoutComments(source); ++ const code = withoutCommentsOrLiterals(source); ++ const imports = visibleSource.matchAll( ++ new RegExp( ++ String.raw`^(?[\t ]*)import\s*\{(?[^}]*)\}\s*from\s*['"]${specifierPattern}['"]`, ++ 'gmu', ++ ), ++ ); ++ return [...imports].some((candidate) => { ++ const indentLength = candidate.groups?.indent?.length ?? 0; ++ const isRealImport = code.slice(candidate.index + indentLength).startsWith('import'); ++ return ( ++ isRealImport && ++ (candidate.groups?.bindings?.split(',').some((binding) => { ++ const [imported, local = imported] = binding.trim().split(/\s+as\s+/u); ++ return local === name; ++ }) ?? false) ++ ); + }); +} + @@ -4162,10 +4517,10 @@ index d606a2265cb438d32a612cfdd6ebb9561529d4f5..b31f281e7ab5bbea289fc2aae117fe39 assert( diff --git a/templates/workspace-scripts/microvertical-api-baseline-boundary.mts b/templates/workspace-scripts/microvertical-api-baseline-boundary.mts new file mode 100644 -index 0000000000000000000000000000000000000000..d39b20299baf434ed109fea7d93c586cc99f421e +index 0000000000000000000000000000000000000000..6101ce929421a39eaa8fd41af0130453e5827db4 --- /dev/null +++ b/templates/workspace-scripts/microvertical-api-baseline-boundary.mts -@@ -0,0 +1,686 @@ +@@ -0,0 +1,685 @@ +import { + isAsExpression, + isCallExpression, @@ -4333,6 +4688,15 @@ index 0000000000000000000000000000000000000000..d39b20299baf434ed109fea7d93c586c + return assignments; +}; + ++const exactCall = ( ++ expression: Expression | undefined, ++ callee: readonly string[], ++ argumentCount: number, ++): CallExpression | undefined => { ++ const call = callExpression(expression, callee); ++ return call?.arguments.length === argumentCount ? call : undefined; ++}; ++ +interface SharedSchemaObject { + readonly assignments: ReadonlyMap; + readonly identity: boolean; @@ -4350,25 +4714,18 @@ index 0000000000000000000000000000000000000000..d39b20299baf434ed109fea7d93c586c + if (isIdentifier(initializer) && initializer.text === sharedSchemaName) { + return { assignments: new Map(), identity: true }; + } -+ const struct = callExpression(initializer, ['Schema', 'Struct']); ++ const struct = exactCall(initializer, ['Schema', 'Struct'], 1); + const schemaObject = objectLiteral(struct?.arguments[0]); -+ if (struct?.arguments.length !== 1 || schemaObject === undefined) { ++ if (schemaObject === undefined) { + return undefined; + } -+ const sharedSpreads = schemaObject.properties.filter( -+ (property) => -+ isSpreadAssignment(property) && -+ isAccessPath(property.expression, [sharedSchemaName, 'fields']), ++ const spreads = schemaObject.properties.filter(isSpreadAssignment); ++ const assignments = propertyAssignments( ++ schemaObject.properties.filter((property) => !isSpreadAssignment(property)), + ); -+ const nonSpreadProperties = schemaObject.properties.filter( -+ (property) => !isSpreadAssignment(property), -+ ); -+ const assignments = propertyAssignments(nonSpreadProperties); + if ( -+ sharedSpreads.length !== 1 || -+ schemaObject.properties.some( -+ (property) => isSpreadAssignment(property) && property !== sharedSpreads[0], -+ ) || ++ spreads.length !== 1 || ++ !spreads.every((spread) => isAccessPath(spread.expression, [sharedSchemaName, 'fields'])) || + assignments === undefined || + protectedFields.some((field) => assignments.has(field)) + ) { @@ -4396,22 +4753,13 @@ index 0000000000000000000000000000000000000000..d39b20299baf434ed109fea7d93c586c + isPropertyAccessExpression(current.expression) && + !isIdentifier(current.expression.expression) + ) { -+ methods.unshift({ arguments: current.arguments, name: current.expression.name.text }); -+ current = unwrapExpression(current.expression.expression); -+ } -+ if (!isCallExpression(current)) { -+ return undefined; -+ } -+ return { base: current, methods }; -+}; -+ -+const exactCall = ( -+ expression: Expression | undefined, -+ callee: readonly string[], -+ argumentCount: number, -+): CallExpression | undefined => { -+ const call = callExpression(expression, callee); -+ return call?.arguments.length === argumentCount ? call : undefined; ++ methods.unshift({ arguments: current.arguments, name: current.expression.name.text }); ++ current = unwrapExpression(current.expression.expression); ++ } ++ if (!isCallExpression(current)) { ++ return undefined; ++ } ++ return { base: current, methods }; +}; + +const brandedStringSchemaIsExact = ( @@ -4436,33 +4784,36 @@ index 0000000000000000000000000000000000000000..d39b20299baf434ed109fea7d93c586c + return stringLiteral(brandCall?.arguments[0]) === brand; +}; + ++const importedRuntimeNames = (statement: Node, expectedPackage: string): readonly string[] => { ++ if ( ++ !isImportDeclaration(statement) || ++ stringLiteral(statement.moduleSpecifier) !== expectedPackage ++ ) { ++ return []; ++ } ++ const clause = statement.importClause; ++ const bindings = clause?.namedBindings; ++ if ( ++ clause?.phaseModifier === SyntaxKind.TypeKeyword || ++ bindings === undefined || ++ !isNamedImports(bindings) ++ ) { ++ return []; ++ } ++ return bindings.elements ++ .filter((element) => !element.isTypeOnly && element.propertyName === undefined) ++ .map((element) => element.name.text); ++}; ++ +const importsExactBindings = ( + sourceFile: SourceFile, + expectedPackage: string, + expectedBindings: readonly string[], +): boolean => { -+ const required = new Set(expectedBindings); -+ for (const statement of sourceFile.statements) { -+ if ( -+ !isImportDeclaration(statement) || -+ stringLiteral(statement.moduleSpecifier) !== expectedPackage || -+ statement.importClause?.phaseModifier === SyntaxKind.TypeKeyword || -+ statement.importClause?.namedBindings === undefined || -+ !isNamedImports(statement.importClause.namedBindings) -+ ) { -+ continue; -+ } -+ for (const element of statement.importClause.namedBindings.elements) { -+ if ( -+ !element.isTypeOnly && -+ element.propertyName === undefined && -+ required.has(element.name.text) -+ ) { -+ required.delete(element.name.text); -+ } -+ } -+ } -+ return required.size === 0; ++ const names = new Set( ++ sourceFile.statements.flatMap((statement) => importedRuntimeNames(statement, expectedPackage)), ++ ); ++ return expectedBindings.every((name) => names.has(name)); +}; + +const importsSharedBaselinePrimitives = ( @@ -4475,41 +4826,40 @@ index 0000000000000000000000000000000000000000..d39b20299baf434ed109fea7d93c586c + 'createMicroVerticalOperationContext', + ]); + -+// oxlint-disable-next-line complexity -- The AST shape is intentionally validated fail-closed in one expression. expires: 2026-12-31. -+const foundationIsExact = ( -+ declaration: VariableDeclaration | undefined, -+ stem: string, -+ readinessSchemaName: string, -+): boolean => { -+ const chain = directCallChain(declaration?.initializer); -+ const expectedApiNames = new Set([ -+ `${pascalCaseStem(stem)}FoundationApi`, -+ `${pascalCaseStem(stem)}ApiFoundation`, -+ ]); ++const singleAddedArgument = ( ++ expression: Expression | undefined, ++ factory: readonly string[], ++ names: readonly string[], ++): Expression | undefined => { ++ const chain = directCallChain(expression); ++ if (chain === undefined || !isAccessPath(chain.base.expression, factory)) { ++ return undefined; ++ } ++ const [method] = chain.methods; + if ( -+ chain === undefined || -+ !isAccessPath(chain.base.expression, ['HttpApi', 'make']) || + chain.base.arguments.length !== 1 || -+ !expectedApiNames.has(stringLiteral(chain.base.arguments[0]) ?? '') || ++ !names.includes(stringLiteral(chain.base.arguments[0]) ?? '') || + chain.methods.length !== 1 || -+ chain.methods[0]?.name !== 'add' || -+ chain.methods[0].arguments.length !== 1 -+ ) { -+ return false; -+ } -+ const groupChain = directCallChain(chain.methods[0].arguments[0]); -+ if ( -+ groupChain === undefined || -+ !isAccessPath(groupChain.base.expression, ['HttpApiGroup', 'make']) || -+ groupChain.base.arguments.length !== 1 || -+ stringLiteral(groupChain.base.arguments[0]) !== 'foundation' || -+ groupChain.methods.length !== 1 || -+ groupChain.methods[0]?.name !== 'add' || -+ groupChain.methods[0].arguments.length !== 1 ++ method?.name !== 'add' || ++ method.arguments.length !== 1 + ) { -+ return false; ++ return undefined; + } -+ const endpoint = exactCall(groupChain.methods[0].arguments[0], ['HttpApiEndpoint', 'get'], 3); ++ return method.arguments[0]; ++}; ++ ++const foundationIsExact = ( ++ declaration: VariableDeclaration | undefined, ++ stem: string, ++ readinessSchemaName: string, ++): boolean => { ++ const group = singleAddedArgument( ++ declaration?.initializer, ++ ['HttpApi', 'make'], ++ [`${pascalCaseStem(stem)}FoundationApi`, `${pascalCaseStem(stem)}ApiFoundation`], ++ ); ++ const endpointExpression = singleAddedArgument(group, ['HttpApiGroup', 'make'], ['foundation']); ++ const endpoint = exactCall(endpointExpression, ['HttpApiEndpoint', 'get'], 3); + const endpointOptions = objectLiteral(endpoint?.arguments[2]); + const endpointProperties = + endpointOptions === undefined ? undefined : propertyAssignments(endpointOptions.properties); @@ -4551,19 +4901,22 @@ index 0000000000000000000000000000000000000000..d39b20299baf434ed109fea7d93c586c + ); +}; + -+// oxlint-disable-next-line complexity -- One fail-closed predicate ties each generated operation identity to its method and route. expires: 2026-12-31. -+const operationContextIsConstructed = ( -+ property: PropertyAssignment, -+ stem: string, -+ propertyKey: string, -+): boolean => { ++const operationContextFields = (property: PropertyAssignment) => { + const constructorCall = exactCall( + property.initializer, + ['createMicroVerticalOperationContext'], + 1, + ); + const input = objectLiteral(constructorCall?.arguments[0]); -+ const fields = input === undefined ? undefined : propertyAssignments(input.properties); ++ return input === undefined ? undefined : propertyAssignments(input.properties); ++}; ++ ++const operationContextIdentity = ( ++ property: PropertyAssignment, ++): ++ | { readonly method: string; readonly operationId: string; readonly routePath: string } ++ | undefined => { ++ const fields = operationContextFields(property); + const method = stringLiteral(fields?.get('method')?.initializer); + const operationId = stringLiteral(fields?.get('operationId')?.initializer); + const routePath = stringLiteral(fields?.get('routePath')?.initializer); @@ -4571,35 +4924,46 @@ index 0000000000000000000000000000000000000000..d39b20299baf434ed109fea7d93c586c + fields?.size !== 3 || + method === undefined || + operationId === undefined || -+ routePath === undefined || -+ !/^[A-Z]+$/u.test(method) || -+ !/^\/(?!.*(?:^|\/)\.\.?\/)[^\s?#]*$/u.test(routePath) ++ routePath === undefined + ) { ++ return undefined; ++ } ++ return { method, operationId, routePath }; ++}; ++ ++const operationContextIsConstructed = ( ++ property: PropertyAssignment, ++ stem: string, ++ propertyKey: string, ++): boolean => { ++ const identity = operationContextIdentity(property); ++ if (identity === undefined) { ++ return false; ++ } ++ const { method, operationId, routePath } = identity; ++ if (!/^[A-Z]+$/u.test(method) || !/^\/(?!.*(?:^|\/)\.\.?\/)[^\s?#]*$/u.test(routePath)) { + return false; + } + const apiName = `${pascalCaseStem(stem)}Api`; -+ if (propertyKey !== 'readiness') { -+ const generatedOperation = new Map([ -+ ['addCartItem', { method: 'POST', routePath: `/${stem}/cart/items` }], -+ ['clearCart', { method: 'POST', routePath: `/${stem}/cart/clear` }], -+ ['create', { method: 'POST', routePath: `/${stem}` }], -+ ['get', { method: 'GET', routePath: `/${stem}/:id` }], -+ ['getCart', { method: 'GET', routePath: `/${stem}/cart` }], -+ ['list', { method: 'GET', routePath: `/${stem}` }], -+ ['removeCartItem', { method: 'POST', routePath: `/${stem}/cart/remove` }], -+ ]).get(propertyKey); -+ return ( -+ operationId === `${apiName}:${routePath}` || -+ (operationId === `${apiName}:${camelCaseStem(stem)}:${propertyKey}` && -+ generatedOperation?.method === method && -+ generatedOperation.routePath === routePath) -+ ); ++ const generatedOperation = new Map([ ++ ['addCartItem', ['POST', `/${stem}/cart/items`]], ++ ['clearCart', ['POST', `/${stem}/cart/clear`]], ++ ['create', ['POST', `/${stem}`]], ++ ['get', ['GET', `/${stem}/:id`]], ++ ['getCart', ['GET', `/${stem}/cart`]], ++ ['list', ['GET', `/${stem}`]], ++ ['readiness', ['GET', `/${stem}/readiness`]], ++ ['removeCartItem', ['POST', `/${stem}/cart/remove`]], ++ ]).get(propertyKey); ++ const requestedOperation = [method, routePath]; ++ const matchesGenerated = ++ generatedOperation?.every((value, index) => value === requestedOperation[index]) === true; ++ if (propertyKey === 'readiness' && !matchesGenerated) { ++ return false; + } + return ( -+ method === 'GET' && -+ routePath === `/${stem}/readiness` && -+ (operationId === `${apiName}:/${stem}/readiness` || -+ operationId === `${apiName}:${camelCaseStem(stem)}:readiness`) ++ operationId === `${apiName}:${routePath}` || ++ (operationId === `${apiName}:${camelCaseStem(stem)}:${propertyKey}` && matchesGenerated) + ); +}; + @@ -4636,27 +5000,19 @@ index 0000000000000000000000000000000000000000..d39b20299baf434ed109fea7d93c586c +): boolean => { + const object = constAssertionObject(declaration); + const fields = object === undefined ? undefined : propertyAssignments(object.properties); -+ const expectedFields = new Map([ ++ const expectedFields = [ + ['apiPrefix', expectation.apiPrefix], + ['basePath', expectation.basePath], + ['ownerId', expectation.ownerId], + ['readinessPath', expectation.readinessPath], + ...Object.entries(expectation.additionalPaths), -+ ]); -+ if ( -+ fields === undefined || -+ fields.size !== expectedFields.size || -+ [...expectedFields].some( -+ ([field, value]) => stringLiteral(fields.get(field)?.initializer) !== value, -+ ) -+ ) { -+ return false; -+ } ++ ] as const; + return ( -+ stringLiteral(fields.get('apiPrefix')?.initializer) === expectation.apiPrefix && -+ stringLiteral(fields.get('basePath')?.initializer) === expectation.basePath && -+ stringLiteral(fields.get('ownerId')?.initializer) === expectation.ownerId && -+ stringLiteral(fields.get('readinessPath')?.initializer) === expectation.readinessPath ++ fields !== undefined && ++ fields.size === new Map(expectedFields).size && ++ [...expectedFields].every( ++ ([field, value]) => stringLiteral(fields.get(field)?.initializer) === value, ++ ) + ); +}; + @@ -4676,29 +5032,27 @@ index 0000000000000000000000000000000000000000..d39b20299baf434ed109fea7d93c586c + if (schema === undefined || schema.identity) { + return schema?.identity === true; + } -+ if ( -+ [...schema.assignments.keys()].some( -+ (field) => !['appId', 'kind', 'schemaVersion', 'unitId'].includes(field), -+ ) || -+ (schema.assignments.has('appId') && -+ (identifierName(schema.assignments.get('appId')?.initializer) !== 'AppIdSchema' || -+ !brandedStringSchemaIsExact(localConst(sourceFile, 'AppIdSchema'), 'AppId'))) || -+ (schema.assignments.has('unitId') && -+ (identifierName(schema.assignments.get('unitId')?.initializer) !== 'UnitIdSchema' || -+ !brandedStringSchemaIsExact(localConst(sourceFile, 'UnitIdSchema'), 'UnitId'))) -+ ) { -+ return false; -+ } -+ const kind = exactCall(schema.assignments.get('kind')?.initializer, ['Schema', 'Literal'], 1); -+ const schemaVersion = exactCall( -+ schema.assignments.get('schemaVersion')?.initializer, -+ ['Schema', 'Literal'], -+ 1, -+ ); -+ return ( -+ (!schema.assignments.has('kind') || -+ stringLiteral(kind?.arguments[0]) === 'microvertical-delivery-unit') && -+ (!schema.assignments.has('schemaVersion') || numericLiteral(schemaVersion?.arguments[0]) === 1) ++ const brandedField = (property: PropertyAssignment, brand: string): boolean => ++ identifierName(property.initializer) === `${brand}Schema` && ++ brandedStringSchemaIsExact(localConst(sourceFile, `${brand}Schema`), brand); ++ const validators = new Map boolean>([ ++ ['appId', (property) => brandedField(property, 'AppId')], ++ ['unitId', (property) => brandedField(property, 'UnitId')], ++ [ ++ 'kind', ++ (property) => ++ stringLiteral(exactCall(property.initializer, ['Schema', 'Literal'], 1)?.arguments[0]) === ++ 'microvertical-delivery-unit', ++ ], ++ [ ++ 'schemaVersion', ++ (property) => ++ numericLiteral(exactCall(property.initializer, ['Schema', 'Literal'], 1)?.arguments[0]) === ++ 1, ++ ], ++ ]); ++ return [...schema.assignments].every( ++ ([field, property]) => validators.get(field)?.(property) === true, + ); +}; + @@ -4919,361 +5273,75 @@ index e26615204dea65bcd0562ea04229cb51659da4af..b06de806dbe48cee148ffbf97fbc65c1 ? { - apiReadiness: `${app.api.prefix}/${app.api.stem}/readiness`, + apiReadiness: app.cloudflareRoutes?.apiReadiness ?? `${app.api.prefix}/${app.api.stem}/readiness`, - } - : {}), - }; -diff --git a/templates/workspace-scripts/ultramodern-cloudflare-proof.mjs b/templates/workspace-scripts/ultramodern-cloudflare-proof.mjs -index fb7872e47d2d2844aafe7577783a01df393d6563..6b01d7f41d9fcbaebb07411ba4c91d2d8704ab61 100644 ---- a/templates/workspace-scripts/ultramodern-cloudflare-proof.mjs -+++ b/templates/workspace-scripts/ultramodern-cloudflare-proof.mjs -@@ -696,7 +696,7 @@ async function validateSsrEvidence(evidence, app, publicUrl, routes) { - const qualityGates = cloudflare?.qualityGates ?? {}; - const budgets = qualityGates.budgets ?? {}; - -- const ssrRoute = routes.ssr ?? '/en'; -+ const ssrRoute = routes.ssr; - const ssr = await fetchText(joinUrl(publicUrl, ssrRoute)); - evidence.assertions.push({ - type: 'ssr', -@@ -864,7 +864,7 @@ async function validateI18nEvidence(evidence, app, publicUrl, routes) { - const qualityGates = app.deploy?.cloudflare?.qualityGates ?? {}; - const budgets = qualityGates.budgets ?? {}; - -- const localeRoute = routes.locale ?? `/locales/en/${app.i18n?.namespace}.json`; -+ const localeRoute = routes.locale; - const locale = await fetchText(joinUrl(publicUrl, localeRoute)); - const localeJson = parseMaybeJson(locale.body); - evidence.assertions.push({ -@@ -1048,10 +1048,22 @@ async function validateApp(app, publicUrl) { - const routes = app.deploy?.cloudflare?.routes ?? {}; - const evidence = createAppEvidence(app, publicUrl); - -- const ssr = await validateSsrEvidence(evidence, app, publicUrl, routes); -- await validateRenderedAssetEvidence(evidence, app, publicUrl, ssr); -+ if (routes.ssr !== undefined) { -+ assert( -+ typeof routes.ssr === 'string' && routes.ssr.startsWith('/'), -+ `${app.id} declared SSR route must be a root-relative path`, -+ ); -+ const ssr = await validateSsrEvidence(evidence, app, publicUrl, routes); -+ await validateRenderedAssetEvidence(evidence, app, publicUrl, ssr); -+ } - await validateModuleFederationManifestEvidence(evidence, app, publicUrl, routes); -- await validateI18nEvidence(evidence, app, publicUrl, routes); -+ if (routes.locale !== undefined) { -+ assert( -+ typeof routes.locale === 'string' && routes.locale.startsWith('/'), -+ `${app.id} declared locale route must be a root-relative path`, -+ ); -+ await validateI18nEvidence(evidence, app, publicUrl, routes); -+ } - await validateReadinessEvidence(evidence, app, publicUrl, routes); - await validateServiceBindingEvidence(evidence, app, publicUrl); - await validateJsonSmokeEvidence(evidence, app, publicUrl); -diff --git a/templates/workspace-scripts/validate-ultramodern-workspace.mjs.handlebars b/templates/workspace-scripts/validate-ultramodern-workspace.mjs.handlebars -index 75c2005c26ed73272eae9dfa6bd59466d7819944..2177b087999ed0729b6bb0cdbd5b99c15030cda9 100644 ---- a/templates/workspace-scripts/validate-ultramodern-workspace.mjs.handlebars -+++ b/templates/workspace-scripts/validate-ultramodern-workspace.mjs.handlebars -@@ -2660,6 +2660,7 @@ const assertProjectReferenceEmitConfig = (tsConfig, packagePath) => { - assert(compilerOptions.declarationMap === false, `${packagePath} must not emit declaration maps during checks`); - assert(compilerOptions.emitDeclarationOnly === true, `${packagePath} must only emit declarations during checks`); - assert(compilerOptions.noEmit === false, `${packagePath} must override root noEmit for TS-Go build mode`); -+ assert(compilerOptions.skipLibCheck !== true, `${packagePath} must not bypass dependency declarations with skipLibCheck`); - assert( - compilerOptions.outDir === `${relativeRoot}/node_modules/.cache/tsgo/declarations/${tsgoCacheKey(packagePath)}`, - `${packagePath} must emit TS-Go declarations into the generated cache`, -@@ -3575,7 +3576,7 @@ if (bridgeConfig) { - } - assert(rootPackage.scripts?.['bridge:check'], 'Bridge workspaces must expose bridge:check'); - } else { -- assert(rootPackage.scripts?.typecheck === 'node ./scripts/ultramodern-typecheck.mts --project tsconfig.json', 'Root typecheck must run TS-Go across the root project reference graph'); -+ assert(rootPackage.scripts?.typecheck === 'node ./scripts/ultramodern-typecheck.mts --build tsconfig.json', 'Root typecheck must run TS-Go across the root project reference graph'); - } - assert(rootPackage.scripts?.['contract:check'] === 'node ./scripts/validate-ultramodern-workspace.mts', 'Root must expose contract:check'); - assert(rootPackage.scripts?.['api:check'] === 'node ./scripts/check-ultramodern-api-boundaries.mts', 'Root must expose api:check'); -diff --git a/templates/workspace/apps/modern.config.ts.handlebars b/templates/workspace/apps/modern.config.ts.handlebars -index 2144abc7232f756a7d824e18bd90ce881e0dadc2..000ece862905b277cc002c7437ce3746da48d75e 100644 ---- a/templates/workspace/apps/modern.config.ts.handlebars -+++ b/templates/workspace/apps/modern.config.ts.handlebars -@@ -1,25 +1,60 @@ --import { -+{{value36}}import { {{value29}}createRequire } from 'node:module'; -+{{value30}}import { - appTools, - defineConfig, - presetUltramodern, - } from '@modern-js/app-tools'; -+import type { -+ {{value34}}AppToolsUserConfig{{value35}} -+} from '@modern-js/app-tools'; - import { - getBuildConfigEnvironment, - withBuildConfigEnvironment, - } from '@modern-js/app-tools/config'; - {{value0}}import { i18nPlugin } from '@modern-js/plugin-i18n'; - import { tanstackRouterPlugin } from '@modern-js/plugin-tanstack'; -+import { Config, Option, Result, Schema } from 'effect'; - {{value22}} --const cloudflareDeployEnabled = -- getBuildConfigEnvironment('MODERNJS_DEPLOY') === 'cloudflare'; -- -+Object.assign(globalThis, { require: createRequire(import.meta.url) }); -+{{value37}} -+const nonEmptyBuildStringSchema = Schema.Trim.pipe(Schema.check(Schema.isMinLength(1))); -+const getOptionalBuildConfig = (name: string): string | undefined => { -+ const decoded = Schema.decodeUnknownResult( -+ Schema.OptionFromUndefinedOr(nonEmptyBuildStringSchema), -+ )(getBuildConfigEnvironment(name)); -+ return Result.isSuccess(decoded) ? Option.getOrUndefined(decoded.success) : undefined; -+}; -+const envValue = getOptionalBuildConfig; -+const getBuildBoolean = (name: string): boolean => -+ Option.getOrElse( -+ Result.getOrThrow( -+ Schema.decodeUnknownResult(Schema.OptionFromUndefinedOr(Config.Boolean))( -+ getBuildConfigEnvironment(name), -+ ), -+ ), -+ () => false, -+ ); -+const cloudflareDeployMode = Result.getOrThrow( -+ Schema.decodeUnknownResult(Schema.OptionFromUndefinedOr(Schema.Literals(['cloudflare', 'node'])))( -+ getBuildConfigEnvironment('MODERNJS_DEPLOY'), -+ ), -+); -+const cloudflareDeployEnabled = Option.contains(cloudflareDeployMode, 'cloudflare'); -+{{value31}} - {{value23}}const appId = '{{value1}}'; - const cloudflareWorkerName = '{{value2}}'; --const port = Number(getBuildConfigEnvironment('{{value3}}') ?? {{value4}}); --const envValue = (name: string) => { -- const value = getBuildConfigEnvironment(name)?.trim(); -- return value !== undefined && value.length > 0 ? value : undefined; --}; -+const port = Option.getOrElse( -+ Result.getOrThrow( -+ Schema.decodeUnknownResult( -+ Schema.OptionFromUndefinedOr( -+ Schema.NumberFromString.pipe( -+ Schema.check(Schema.isInt(), Schema.isBetween({ maximum: 65_535, minimum: 1 })), -+ ), -+ ), -+ )(getBuildConfigEnvironment('{{value3}}')), -+ ), -+ () => {{value4}}, -+); - const configuredSiteUrl = envValue('MODERN_PUBLIC_SITE_URL'); - const configuredCloudflareUrl = envValue('{{value5}}'); - const configuredUltramodernAssetPrefix = envValue('ULTRAMODERN_ASSET_PREFIX'); -@@ -35,16 +70,16 @@ const inferredCloudflareUrl = - // Site origin (SEO: canonical/hreflang URLs) prefers the site-wide public URL; - // the per-app deployment URL only fills in when no site origin is configured. - const siteUrl = -- configuredSiteUrl || -- configuredCloudflareUrl || -- inferredCloudflareUrl || -+ configuredSiteUrl ?? -+ configuredCloudflareUrl ?? -+ inferredCloudflareUrl ?? - `http://localhost:${port}`; - {{value7}} - // Asset loading is intentionally independent from the canonical site URL. - // Module Federation remotes must publish an absolute publicPath so browsers - // load remoteEntry.js and exposed chunks from the remote origin, not the host. - const assetPrefix = -- configuredModernAssetPrefix || configuredUltramodernAssetPrefix || defaultAssetPrefix; -+ configuredModernAssetPrefix ?? configuredUltramodernAssetPrefix ?? defaultAssetPrefix; - const buildTarget = cloudflareDeployEnabled ? 'cloudflare' : 'web'; - const buildOutputRoot = cloudflareDeployEnabled ? 'dist-cloudflare' : 'dist'; - const buildTempDirectory = `node_modules/.modern-js-${appId}-${buildTarget}`; -@@ -52,7 +87,7 @@ const buildCacheDirectory = `node_modules/.cache/rspack-${appId}-${buildTarget}` + } + : {}), + }; +diff --git a/templates/workspace-scripts/ultramodern-cloudflare-proof.mjs b/templates/workspace-scripts/ultramodern-cloudflare-proof.mjs +index fb7872e47d2d2844aafe7577783a01df393d6563..6b01d7f41d9fcbaebb07411ba4c91d2d8704ab61 100644 +--- a/templates/workspace-scripts/ultramodern-cloudflare-proof.mjs ++++ b/templates/workspace-scripts/ultramodern-cloudflare-proof.mjs +@@ -696,7 +696,7 @@ async function validateSsrEvidence(evidence, app, publicUrl, routes) { + const qualityGates = cloudflare?.qualityGates ?? {}; + const budgets = qualityGates.budgets ?? {}; - if ( - cloudflareDeployEnabled && -- getBuildConfigEnvironment('ULTRAMODERN_CLOUDFLARE_REQUIRE_PUBLIC_URLS') === 'true' && -+ getBuildBoolean('ULTRAMODERN_CLOUDFLARE_REQUIRE_PUBLIC_URLS') && - configuredCloudflareUrl === undefined && - configuredSiteUrl === undefined && - inferredCloudflareUrl === undefined -@@ -62,23 +97,26 @@ if ( - ); - } +- const ssrRoute = routes.ssr ?? '/en'; ++ const ssrRoute = routes.ssr; + const ssr = await fetchText(joinUrl(publicUrl, ssrRoute)); + evidence.assertions.push({ + type: 'ssr', +@@ -864,7 +864,7 @@ async function validateI18nEvidence(evidence, app, publicUrl, routes) { + const qualityGates = app.deploy?.cloudflare?.qualityGates ?? {}; + const budgets = qualityGates.budgets ?? {}; -+const whenEnabled = (enabled: boolean, configuration: Configuration) => -+ enabled ? configuration : undefined; -+ -+{{value33}}const cloudflareDeployment = whenEnabled(cloudflareDeployEnabled, { -+ deploy: { -+ worker: { -+ compatibilityDate: '{{value10}}', -+ name: cloudflareWorkerName, -+ security: {{value11}}, -+{{value12}} ssr: true, -+ }, -+ }, -+} satisfies Pick); -+ - export default defineConfig( - presetUltramodern( - { --{{value9}}{{value18}} ...(cloudflareDeployEnabled -- ? { -- deploy: { -- worker: { -- compatibilityDate: '{{value10}}', -- name: cloudflareWorkerName, -- security: {{value11}}, --{{value12}} ssr: true, -- }, -- }, -- } -- : {}), -+{{value9}}{{value18}} ...cloudflareDeployment, - dev: { --{{value13}}{{value20}} -+{{value13}}{{value20}}{{value38}} - }, - html: { - outputStructure: 'flat', -@@ -101,7 +139,7 @@ export default defineConfig( - }, - rsdoctor: { - disableClientServer: true, -- enabled: getBuildConfigEnvironment('ULTRAMODERN_RSDOCTOR') === 'true', -+ enabled: getBuildBoolean('ULTRAMODERN_RSDOCTOR'), - }, - }, - plugins: [ -@@ -115,6 +153,7 @@ export default defineConfig( - localeDetection: { - fallbackLanguage: 'en', - ignoreRedirectRoutes: [ -+{{value39}} - '/@mf-types', - '/assets', - '/bundles', -@@ -137,7 +176,7 @@ export default defineConfig( - {{value15}}{{value25}} ], - server: { - port, -- publicDir: ['./locales', './assets'], -+ publicDir: ['./locales', './assets'{{value40}}], - }, - source: { - alias: { -@@ -145,6 +184,8 @@ export default defineConfig( - '@modern-js/plugin-i18n/runtime/no-react-i18next', - }, - globalVars: { -+ ULTRAMODERN_SHELL_ORIGIN: -+ envValue('ULTRAMODERN_MF_DEV_ORIGIN') ?? 'http://localhost:{{value6}}', - ULTRAMODERN_SITE_URL: siteUrl, - }, - mainEntryName: 'index', -@@ -153,21 +194,13 @@ export default defineConfig( - autoprefixer: { - overrideBrowserslist: ['defaults'], - }, -- bundlerChain: chain => { -+ bundlerChain: (chain) => { - chain.output - .uniqueName('{{value16}}') - .chunkLoadingGlobal('{{value17}}'); - }, -- devServer: { -- headers: { -- 'Access-Control-Allow-Headers': -- 'Accept, Authorization, Content-Type, X-Requested-With', -- 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', -- {{value21}} -- }, -- }, -- }, -- }, -+{{value32}} }, -+ } satisfies AppToolsUserConfig, - { - appId, - deliveryUnit: { -diff --git a/templates/workspace/verticals/server/checkout-cart-handlers.ts.handlebars b/templates/workspace/verticals/server/checkout-cart-handlers.ts.handlebars -index 881ed1c31b29fa7a6db8da25bdf95fe23aefb84b..b8e360c82fcb543c4afccc994503277c20782690 100644 ---- a/templates/workspace/verticals/server/checkout-cart-handlers.ts.handlebars -+++ b/templates/workspace/verticals/server/checkout-cart-handlers.ts.handlebars -@@ -2,7 +2,7 @@ - .handle('getCart', () => - Effect.sync(() => createCheckoutCartSnapshot()).pipe( - Effect.withSpan('ultramodern.api.{{value0}}.checkout.getCart', { -- attributes: operationAttributes({{value1}}OperationContexts.getCart), -+ attributes: microVerticalOperationAttributes({{value1}}OperationContexts.getCart), - kind: 'server', - }), - ), -@@ -20,7 +20,7 @@ - return createCheckoutCartSnapshot(); - }).pipe( - Effect.withSpan('ultramodern.api.{{value2}}.checkout.addCartItem', { -- attributes: operationAttributes({{value3}}OperationContexts.addCartItem), -+ attributes: microVerticalOperationAttributes({{value3}}OperationContexts.addCartItem), - kind: 'server', - }), - ), -@@ -31,7 +31,7 @@ - return createCheckoutCartSnapshot(); - }).pipe( - Effect.withSpan('ultramodern.api.{{value4}}.checkout.removeCartItem', { -- attributes: operationAttributes({{value5}}OperationContexts.removeCartItem), -+ attributes: microVerticalOperationAttributes({{value5}}OperationContexts.removeCartItem), - kind: 'server', - }), - ), -@@ -42,7 +42,7 @@ - return createCheckoutCartSnapshot(); - }).pipe( - Effect.withSpan('ultramodern.api.{{value6}}.checkout.clearCart', { -- attributes: operationAttributes({{value7}}OperationContexts.clearCart), -+ attributes: microVerticalOperationAttributes({{value7}}OperationContexts.clearCart), - kind: 'server', - }), - ), -diff --git a/templates/workspace/verticals/shared/api.checkout-cart-operation-contexts.ts.handlebars b/templates/workspace/verticals/shared/api.checkout-cart-operation-contexts.ts.handlebars -index 04820fd4264e7df2d2fa5c803df181e3e1401a34..3eb4b2387a43ba4a163f93b3dc309df37b9bdd14 100644 ---- a/templates/workspace/verticals/shared/api.checkout-cart-operation-contexts.ts.handlebars -+++ b/templates/workspace/verticals/shared/api.checkout-cart-operation-contexts.ts.handlebars -@@ -1,29 +1,25 @@ +- const localeRoute = routes.locale ?? `/locales/en/${app.i18n?.namespace}.json`; ++ const localeRoute = routes.locale; + const locale = await fetchText(joinUrl(publicUrl, localeRoute)); + const localeJson = parseMaybeJson(locale.body); + evidence.assertions.push({ +@@ -1048,10 +1048,22 @@ async function validateApp(app, publicUrl) { + const routes = app.deploy?.cloudflare?.routes ?? {}; + const evidence = createAppEvidence(app, publicUrl); -- addCartItem: { -+ addCartItem: createMicroVerticalOperationContext({ - method: 'POST', - operationId: '{{value0}}:{{value1}}:addCartItem', - routePath: '/checkout/cart/items', -- source: 'generated-client', -- }, -- clearCart: { -+ }), -+ clearCart: createMicroVerticalOperationContext({ - method: 'POST', - operationId: '{{value2}}:{{value3}}:clearCart', - routePath: '/checkout/cart/clear', -- source: 'generated-client', -- }, -+ }), - {{createOperationContext}} - {{getOperationContext}} -- getCart: { -+ getCart: createMicroVerticalOperationContext({ - method: 'GET', - operationId: '{{value4}}:{{value5}}:getCart', - routePath: '/checkout/cart', -- source: 'generated-client', -- }, -+ }), - {{listOperationContext}} - {{readinessOperationContext}} -- removeCartItem: { -+ removeCartItem: createMicroVerticalOperationContext({ - method: 'POST', - operationId: '{{value6}}:{{value7}}:removeCartItem', - routePath: '/checkout/cart/remove', -- source: 'generated-client', -- }, -+ }), +- const ssr = await validateSsrEvidence(evidence, app, publicUrl, routes); +- await validateRenderedAssetEvidence(evidence, app, publicUrl, ssr); ++ if (routes.ssr !== undefined) { ++ assert( ++ typeof routes.ssr === 'string' && routes.ssr.startsWith('/'), ++ `${app.id} declared SSR route must be a root-relative path`, ++ ); ++ const ssr = await validateSsrEvidence(evidence, app, publicUrl, routes); ++ await validateRenderedAssetEvidence(evidence, app, publicUrl, ssr); ++ } + await validateModuleFederationManifestEvidence(evidence, app, publicUrl, routes); +- await validateI18nEvidence(evidence, app, publicUrl, routes); ++ if (routes.locale !== undefined) { ++ assert( ++ typeof routes.locale === 'string' && routes.locale.startsWith('/'), ++ `${app.id} declared locale route must be a root-relative path`, ++ ); ++ await validateI18nEvidence(evidence, app, publicUrl, routes); ++ } + await validateReadinessEvidence(evidence, app, publicUrl, routes); + await validateServiceBindingEvidence(evidence, app, publicUrl); + await validateJsonSmokeEvidence(evidence, app, publicUrl); +diff --git a/templates/workspace-scripts/validate-ultramodern-workspace.mjs.handlebars b/templates/workspace-scripts/validate-ultramodern-workspace.mjs.handlebars +index 75c2005c26ed73272eae9dfa6bd59466d7819944..2177b087999ed0729b6bb0cdbd5b99c15030cda9 100644 +--- a/templates/workspace-scripts/validate-ultramodern-workspace.mjs.handlebars ++++ b/templates/workspace-scripts/validate-ultramodern-workspace.mjs.handlebars +@@ -2660,6 +2660,7 @@ const assertProjectReferenceEmitConfig = (tsConfig, packagePath) => { + assert(compilerOptions.declarationMap === false, `${packagePath} must not emit declaration maps during checks`); + assert(compilerOptions.emitDeclarationOnly === true, `${packagePath} must only emit declarations during checks`); + assert(compilerOptions.noEmit === false, `${packagePath} must override root noEmit for TS-Go build mode`); ++ assert(compilerOptions.skipLibCheck !== true, `${packagePath} must not bypass dependency declarations with skipLibCheck`); + assert( + compilerOptions.outDir === `${relativeRoot}/node_modules/.cache/tsgo/declarations/${tsgoCacheKey(packagePath)}`, + `${packagePath} must emit TS-Go declarations into the generated cache`, +@@ -3575,7 +3576,7 @@ if (bridgeConfig) { + } + assert(rootPackage.scripts?.['bridge:check'], 'Bridge workspaces must expose bridge:check'); + } else { +- assert(rootPackage.scripts?.typecheck === 'node ./scripts/ultramodern-typecheck.mts --project tsconfig.json', 'Root typecheck must run TS-Go across the root project reference graph'); ++ assert(rootPackage.scripts?.typecheck === 'node ./scripts/ultramodern-typecheck.mts --build tsconfig.json', 'Root typecheck must run TS-Go across the root project reference graph'); + } + assert(rootPackage.scripts?.['contract:check'] === 'node ./scripts/validate-ultramodern-workspace.mts', 'Root must expose contract:check'); + assert(rootPackage.scripts?.['api:check'] === 'node ./scripts/check-ultramodern-api-boundaries.mts', 'Root must expose api:check'); diff --git a/app/patches/drizzle-orm-rc5-declarations.patch b/app/patches/drizzle-orm-rc5-declarations.patch new file mode 100644 index 000000000..275bde80e --- /dev/null +++ b/app/patches/drizzle-orm-rc5-declarations.patch @@ -0,0 +1,148 @@ +diff --git a/cockroach-core/policies.d.cts b/cockroach-core/policies.d.cts +index b45ab02b7fe66eb1cc43907a6a7d939b802d0629..b366ec670db1b8e366c700ced51cdc2d67f086bc 100644 +--- a/cockroach-core/policies.d.cts ++++ b/cockroach-core/policies.d.cts +@@ -6,11 +6,11 @@ import { SQL } from "../sql/sql.cjs"; + //#region src/cockroach-core/policies.d.ts + type CockroachPolicyToOption = 'public' | 'current_user' | 'session_user' | (string & {}) | CockroachPolicyToOption[] | CockroachRole; + interface CockroachPolicyConfig { +- as?: 'permissive' | 'restrictive'; +- for?: 'all' | 'select' | 'insert' | 'update' | 'delete'; +- to?: CockroachPolicyToOption; +- using?: SQL; +- withCheck?: SQL; ++ as?: 'permissive' | 'restrictive' | undefined; ++ for?: 'all' | 'select' | 'insert' | 'update' | 'delete' | undefined; ++ to?: CockroachPolicyToOption | undefined; ++ using?: SQL | undefined; ++ withCheck?: SQL | undefined; + } + declare class CockroachPolicy implements CockroachPolicyConfig { + readonly name: string; +diff --git a/cockroach-core/policies.d.ts b/cockroach-core/policies.d.ts +index 3d000078a7c7eb9e1fbf6663079d34cc4f065692..3dbb2d88c15f7a9a5b0e0773499980a5d6d18e1a 100644 +--- a/cockroach-core/policies.d.ts ++++ b/cockroach-core/policies.d.ts +@@ -6,11 +6,11 @@ import { SQL } from "../sql/sql.js"; + //#region src/cockroach-core/policies.d.ts + type CockroachPolicyToOption = 'public' | 'current_user' | 'session_user' | (string & {}) | CockroachPolicyToOption[] | CockroachRole; + interface CockroachPolicyConfig { +- as?: 'permissive' | 'restrictive'; +- for?: 'all' | 'select' | 'insert' | 'update' | 'delete'; +- to?: CockroachPolicyToOption; +- using?: SQL; +- withCheck?: SQL; ++ as?: 'permissive' | 'restrictive' | undefined; ++ for?: 'all' | 'select' | 'insert' | 'update' | 'delete' | undefined; ++ to?: CockroachPolicyToOption | undefined; ++ using?: SQL | undefined; ++ withCheck?: SQL | undefined; + } + declare class CockroachPolicy implements CockroachPolicyConfig { + readonly name: string; +diff --git a/cockroach-core/roles.d.cts b/cockroach-core/roles.d.cts +index 02aafb13838c27022945099e6a367a36a9d8ee84..9b08def91b938367aa0a52b22fe4549a21b9b46b 100644 +--- a/cockroach-core/roles.d.cts ++++ b/cockroach-core/roles.d.cts +@@ -2,8 +2,8 @@ import { entityKind } from "../entity.cjs"; + + //#region src/cockroach-core/roles.d.ts + interface CockroachRoleConfig { +- createDb?: boolean; +- createRole?: boolean; ++ createDb?: boolean | undefined; ++ createRole?: boolean | undefined; + } + declare class CockroachRole implements CockroachRoleConfig { + readonly name: string; +diff --git a/cockroach-core/roles.d.ts b/cockroach-core/roles.d.ts +index 6621824c2665a67e12de04ef8c8570455ac3b6c8..0b2785f6311d7bf46f3923f516e4d9b66118b5b4 100644 +--- a/cockroach-core/roles.d.ts ++++ b/cockroach-core/roles.d.ts +@@ -2,8 +2,8 @@ import { entityKind } from "../entity.js"; + + //#region src/cockroach-core/roles.d.ts + interface CockroachRoleConfig { +- createDb?: boolean; +- createRole?: boolean; ++ createDb?: boolean | undefined; ++ createRole?: boolean | undefined; + } + declare class CockroachRole implements CockroachRoleConfig { + readonly name: string; +diff --git a/pg-core/policies.d.cts b/pg-core/policies.d.cts +index 7229cdd896f19cefc544f755eddf7c9b99912a2d..664838eed6ee6c16f34c3eb0884ede82c6c1066c 100644 +--- a/pg-core/policies.d.cts ++++ b/pg-core/policies.d.cts +@@ -6,11 +6,11 @@ import { SQL } from "../sql/sql.cjs"; + //#region src/pg-core/policies.d.ts + type PgPolicyToOption = 'public' | 'current_role' | 'current_user' | 'session_user' | (string & {}) | PgPolicyToOption[] | PgRole; + interface PgPolicyConfig { +- as?: 'permissive' | 'restrictive'; +- for?: 'all' | 'select' | 'insert' | 'update' | 'delete'; +- to?: PgPolicyToOption; +- using?: SQL; +- withCheck?: SQL; ++ as?: 'permissive' | 'restrictive' | undefined; ++ for?: 'all' | 'select' | 'insert' | 'update' | 'delete' | undefined; ++ to?: PgPolicyToOption | undefined; ++ using?: SQL | undefined; ++ withCheck?: SQL | undefined; + } + declare class PgPolicy implements PgPolicyConfig { + readonly name: string; +diff --git a/pg-core/policies.d.ts b/pg-core/policies.d.ts +index 8dddf4ca5865abfcd754bfcc793d16272aeecf13..5a163e3af3e309447435aff7465c4abb96d19b76 100644 +--- a/pg-core/policies.d.ts ++++ b/pg-core/policies.d.ts +@@ -6,11 +6,11 @@ import { SQL } from "../sql/sql.js"; + //#region src/pg-core/policies.d.ts + type PgPolicyToOption = 'public' | 'current_role' | 'current_user' | 'session_user' | (string & {}) | PgPolicyToOption[] | PgRole; + interface PgPolicyConfig { +- as?: 'permissive' | 'restrictive'; +- for?: 'all' | 'select' | 'insert' | 'update' | 'delete'; +- to?: PgPolicyToOption; +- using?: SQL; +- withCheck?: SQL; ++ as?: 'permissive' | 'restrictive' | undefined; ++ for?: 'all' | 'select' | 'insert' | 'update' | 'delete' | undefined; ++ to?: PgPolicyToOption | undefined; ++ using?: SQL | undefined; ++ withCheck?: SQL | undefined; + } + declare class PgPolicy implements PgPolicyConfig { + readonly name: string; +diff --git a/pg-core/roles.d.cts b/pg-core/roles.d.cts +index e12c2f60761b99161ee3622ae5c6ffa01c6e0678..8b4533731d6218c0847a9e058ded037db0dc259c 100644 +--- a/pg-core/roles.d.cts ++++ b/pg-core/roles.d.cts +@@ -2,9 +2,9 @@ import { entityKind } from "../entity.cjs"; + + //#region src/pg-core/roles.d.ts + interface PgRoleConfig { +- createDb?: boolean; +- createRole?: boolean; +- inherit?: boolean; ++ createDb?: boolean | undefined; ++ createRole?: boolean | undefined; ++ inherit?: boolean | undefined; + } + declare class PgRole implements PgRoleConfig { + readonly name: string; +diff --git a/pg-core/roles.d.ts b/pg-core/roles.d.ts +index 265648e047b90b9cf78a00bcfe3fe40ad6582984..28d9e6c9457e0b0a98a153cfc14bd7c2da79da6e 100644 +--- a/pg-core/roles.d.ts ++++ b/pg-core/roles.d.ts +@@ -2,9 +2,9 @@ import { entityKind } from "../entity.js"; + + //#region src/pg-core/roles.d.ts + interface PgRoleConfig { +- createDb?: boolean; +- createRole?: boolean; +- inherit?: boolean; ++ createDb?: boolean | undefined; ++ createRole?: boolean | undefined; ++ inherit?: boolean | undefined; + } + declare class PgRole implements PgRoleConfig { + readonly name: string; diff --git a/app/patches/effect-schema-sentinel.patch b/app/patches/effect-schema-sentinel.patch index 186df620d..3135224fb 100644 --- a/app/patches/effect-schema-sentinel.patch +++ b/app/patches/effect-schema-sentinel.patch @@ -1,8 +1,9 @@ diff --git a/dist/Schema.d.ts b/dist/Schema.d.ts -index 1f86aac..b01e02d 100644 +index 9547bd05cb7b91e5e5decc43b64c10a47a86186a..e58693c3742604ccb703045dedd259fca2c66b6e 100644 --- a/dist/Schema.d.ts +++ b/dist/Schema.d.ts -@@ -10813,6 +10813,6 @@ export declare namespace Annotations { +@@ -10812,7 +10812,7 @@ export declare namespace Annotations { + * * Reserved to internal use only. */ - readonly "~sentinels"?: ReadonlyArray | undefined; @@ -10,3 +11,24 @@ index 1f86aac..b01e02d 100644 } /** * Annotations for filter schema nodes (created via `Schema.filter`). Extends +diff --git a/dist/unstable/cli/Param.d.ts b/dist/unstable/cli/Param.d.ts +index 707f44eef3212747e1b9ad4101ecaa7432c48cce..52ebc128c5c66c86bcd2493fb8b46d7214221295 100644 +--- a/dist/unstable/cli/Param.d.ts ++++ b/dist/unstable/cli/Param.d.ts +@@ -2309,5 +2309,16 @@ export declare const orElseResult: { + */ + (self: Param, orElse: LazyArg>): Param>; + }; ++/** ++ * Gets param metadata by traversing the structure. ++ * ++ * @internal ++ */ ++export declare const getParamMetadata: (param: Param) => { ++ readonly isOptional: boolean; ++ readonly isVariadic: boolean; ++ readonly variadicMin: Option.Option; ++ readonly variadicMax: Option.Option; ++}; + export {}; + //# sourceMappingURL=Param.d.ts.map diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index da7d8821d..f6ae91b2d 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -17,15 +17,16 @@ patchedDependencies: '@better-fetch/fetch@1.3.1': 9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747 '@bleedingdev/modern-js-app-tools@3.8.2-ultramodern.12': 92106508bb75f01d2d27454563d5030569bcf89a49155b2cc91a2b9111360d4c '@bleedingdev/modern-js-builder@3.8.2-ultramodern.12': c5bdbbf89a17e39cb43c17f66a1904ed29a1462b09fd19aaf05c7301cbe4601c - '@bleedingdev/modern-js-code-tools@3.8.2-ultramodern.12': f47cc9b656ff270a7ec5d1407c048fa8ff807bd72b5c491490b4ae07ffffc595 - '@bleedingdev/modern-js-create@3.8.2-ultramodern.12': 94333aea6925d878a885616925eaefbc1668fcc0b91bf03f5e7caa12e79da131 + '@bleedingdev/modern-js-code-tools@3.8.2-ultramodern.12': 227ad960c0ce793da1dee90eeaba8edc310b14a58334be185c7cce71ae59a110 + '@bleedingdev/modern-js-create@3.8.2-ultramodern.12': c0b541c048a1d25b651f0d6bac2df86969944fc47d3e39f3af6ee7e83250faba '@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12': e96021e5be6d0ee85e6656b9110807d08e4b8dda295d17933606ff0472b61ed0 '@bleedingdev/modern-js-server-utils@3.8.2-ultramodern.12': 254be68a353f0c3a57aed91447a2de7b86afe26d1badeb9786945fd466fe760d '@module-federation/bridge-react@2.8.0': 54bfc79e097473222f83cbfa6d717792e3026bf16b5097c2ed91715b7da126be '@module-federation/modern-js-v3@2.8.0': 56ff0f8c26c40b18be1de105abd019422ebaa648941607d1d30b94cd620b57f3 '@tanstack/router-core@1.171.21': 413c2453d06aa521ed65ab7fcfb16bac8700e58e97693c2ba4d40727d7c9790d '@vercel/nft@0.29.2': c0ed4897b98e9055716031187bb8ea16739f6ae0843d35e4873f1a177472cac7 - effect@4.0.0-beta.107: ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f + drizzle-orm@1.0.0-rc.5-ab785fc: b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe + effect@4.0.0-beta.107: 88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98 importers: @@ -36,35 +37,35 @@ importers: version: 1.6.1 '@effect/sql-pg': specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)) + version: 4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)) better-auth: specifier: 1.7.2 - version: 1.7.2(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(drizzle-kit@1.0.0-rc.5-ab785fc)(drizzle-orm@1.0.0-rc.5-ab785fc(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(pg@8.22.0)(zod@4.4.3))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.7.2(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(drizzle-kit@1.0.0-rc.5-ab785fc)(drizzle-orm@1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) drizzle-orm: specifier: 1.0.0-rc.5-ab785fc - version: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(pg@8.22.0)(zod@4.4.3) + version: 1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3) pg: specifier: 8.22.0 version: 8.22.0 devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(ioredis@5.11.1(supports-color@10.2.2)) + version: 4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(ioredis@5.11.1(supports-color@10.2.2)) '@effect/tsgo': specifier: 0.19.0 version: 0.19.0 '@modern-js/code-tools': specifier: npm:@bleedingdev/modern-js-code-tools@3.8.2-ultramodern.12 - version: '@bleedingdev/modern-js-code-tools@3.8.2-ultramodern.12(patch_hash=f47cc9b656ff270a7ec5d1407c048fa8ff807bd72b5c491490b4ae07ffffc595)(oxlint-tsgolint@7.0.2001)' + version: '@bleedingdev/modern-js-code-tools@3.8.2-ultramodern.12(patch_hash=227ad960c0ce793da1dee90eeaba8edc310b14a58334be185c7cce71ae59a110)(oxlint-tsgolint@7.0.2001)' '@modern-js/codesmith': specifier: 2.6.9 version: 2.6.9(supports-color@10.2.2) '@modern-js/create': specifier: npm:@bleedingdev/modern-js-create@3.8.2-ultramodern.12 - version: '@bleedingdev/modern-js-create@3.8.2-ultramodern.12(patch_hash=94333aea6925d878a885616925eaefbc1668fcc0b91bf03f5e7caa12e79da131)(oxlint@1.79.0(oxlint-tsgolint@7.0.2001))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)' + version: '@bleedingdev/modern-js-create@3.8.2-ultramodern.12(patch_hash=c0b541c048a1d25b651f0d6bac2df86969944fc47d3e39f3af6ee7e83250faba)(oxlint@1.79.0(oxlint-tsgolint@7.0.2001))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)' '@modern-js/plugin-bff': specifier: npm:@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12 - version: '@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12(patch_hash=e96021e5be6d0ee85e6656b9110807d08e4b8dda295d17933606ff0472b61ed0)(02f8732f8d0ac3252ab424ea7a1b32c5)' + version: '@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12(patch_hash=e96021e5be6d0ee85e6656b9110807d08e4b8dda295d17933606ff0472b61ed0)(3a233a8c5baa0ff66c13c170d030459d)' '@nkzw/eslint-plugin': specifier: 2.0.0 version: 2.0.0(eslint@9.39.5(jiti@2.7.0)(supports-color@10.2.2)) @@ -88,7 +89,7 @@ importers: version: typescript@7.0.2 effect: specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f) + version: 4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98) esbuild: specifier: 0.28.1 version: 0.28.1 @@ -160,16 +161,16 @@ importers: version: 1.6.1 '@better-auth/api-key': specifier: 1.7.2 - version: 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(better-auth@1.7.2(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(drizzle-kit@1.0.0-rc.5-ab785fc)(drizzle-orm@1.0.0-rc.5-ab785fc(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(pg@8.22.0)(zod@4.4.3))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(better-call@1.4.0(zod@4.4.3)) + version: 1.7.2(831f340a6e103a07b479cd8fecebd372) '@better-auth/drizzle-adapter': specifier: 1.7.2 - version: 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.5-ab785fc(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(pg@8.22.0)(zod@4.4.3)) + version: 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3)) '@effect/sql-pg': specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)) + version: 4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)) '@modern-js/plugin-bff': specifier: npm:@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12 - version: '@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12(patch_hash=e96021e5be6d0ee85e6656b9110807d08e4b8dda295d17933606ff0472b61ed0)(76a9f4d76a32c6bb3b280341cf123861)' + version: '@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12(patch_hash=e96021e5be6d0ee85e6656b9110807d08e4b8dda295d17933606ff0472b61ed0)(e7b2bba40f7fca088d40067165acde85)' '@modern-js/plugin-i18n': specifier: npm:@bleedingdev/modern-js-plugin-i18n@3.8.2-ultramodern.12 version: '@bleedingdev/modern-js-plugin-i18n@3.8.2-ultramodern.12(@bleedingdev/modern-js-runtime@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(i18next@26.3.6(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' @@ -190,13 +191,13 @@ importers: version: 0.25.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tailwindcss@4.3.3) better-auth: specifier: 1.7.2 - version: 1.7.2(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(drizzle-kit@1.0.0-rc.5-ab785fc)(drizzle-orm@1.0.0-rc.5-ab785fc(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(pg@8.22.0)(zod@4.4.3))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.7.2(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(drizzle-kit@1.0.0-rc.5-ab785fc)(drizzle-orm@1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) drizzle-orm: specifier: 1.0.0-rc.5-ab785fc - version: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(pg@8.22.0)(zod@4.4.3) + version: 1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3) effect: specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f) + version: 4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98) i18next: specifier: 26.3.6 version: 26.3.6(typescript@7.0.2) @@ -284,16 +285,16 @@ importers: version: 1.6.1 '@effect/platform-node': specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(ioredis@5.11.1(supports-color@10.2.2)) + version: 4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(ioredis@5.11.1(supports-color@10.2.2)) '@effect/sql-pg': specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)) + version: 4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)) drizzle-orm: specifier: 1.0.0-rc.5-ab785fc - version: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(pg@8.22.0)(zod@4.4.3) + version: 1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3) effect: specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f) + version: 4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98) pg: specifier: 8.22.0 version: 8.22.0 @@ -318,7 +319,7 @@ importers: version: link:../shared-contracts effect: specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f) + version: 4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98) jose: specifier: 6.2.5 version: 6.2.5 @@ -334,10 +335,10 @@ importers: version: link:../core-runtime '@modern-js/plugin-bff': specifier: npm:@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12 - version: '@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12(patch_hash=e96021e5be6d0ee85e6656b9110807d08e4b8dda295d17933606ff0472b61ed0)(76a9f4d76a32c6bb3b280341cf123861)' + version: '@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12(patch_hash=e96021e5be6d0ee85e6656b9110807d08e4b8dda295d17933606ff0472b61ed0)(e7b2bba40f7fca088d40067165acde85)' effect: specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f) + version: 4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98) devDependencies: '@types/node': specifier: 20.19.43 @@ -361,13 +362,13 @@ importers: version: link:../../packages/shared-design-tokens '@effect/opentelemetry': specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(@opentelemetry/api-logs@0.220.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)) + version: 4.0.0-beta.107(@opentelemetry/api-logs@0.220.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)) '@effect/sql-pg': specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)) + version: 4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)) '@modern-js/plugin-bff': specifier: npm:@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12 - version: '@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12(patch_hash=e96021e5be6d0ee85e6656b9110807d08e4b8dda295d17933606ff0472b61ed0)(76a9f4d76a32c6bb3b280341cf123861)' + version: '@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12(patch_hash=e96021e5be6d0ee85e6656b9110807d08e4b8dda295d17933606ff0472b61ed0)(e7b2bba40f7fca088d40067165acde85)' '@modern-js/plugin-i18n': specifier: npm:@bleedingdev/modern-js-plugin-i18n@3.8.2-ultramodern.12 version: '@bleedingdev/modern-js-plugin-i18n@3.8.2-ultramodern.12(@bleedingdev/modern-js-runtime@3.8.2-ultramodern.12(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0)(i18next@26.3.6(typescript@7.0.2))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' @@ -388,10 +389,10 @@ importers: version: 1.170.25(react-dom@19.2.8(react@19.2.8))(react@19.2.8) drizzle-orm: specifier: 1.0.0-rc.5-ab785fc - version: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(pg@8.22.0)(zod@4.4.3) + version: 1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3) effect: specifier: 4.0.0-beta.107 - version: 4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f) + version: 4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98) i18next: specifier: 26.3.6 version: 26.3.6(typescript@7.0.2) @@ -9168,11 +9169,11 @@ snapshots: '@babel/helper-string-parser': 8.0.0 '@babel/helper-validator-identifier': 8.0.4 - '@better-auth/api-key@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(better-auth@1.7.2(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(drizzle-kit@1.0.0-rc.5-ab785fc)(drizzle-orm@1.0.0-rc.5-ab785fc(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(pg@8.22.0)(zod@4.4.3))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(better-call@1.4.0(zod@4.4.3))': + '@better-auth/api-key@1.7.2(831f340a6e103a07b479cd8fecebd372)': dependencies: '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) '@better-auth/utils': 0.4.2 - better-auth: 1.7.2(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(drizzle-kit@1.0.0-rc.5-ab785fc)(drizzle-orm@1.0.0-rc.5-ab785fc(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(pg@8.22.0)(zod@4.4.3))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + better-auth: 1.7.2(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(drizzle-kit@1.0.0-rc.5-ab785fc)(drizzle-orm@1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) better-call: 1.4.0(zod@4.4.3) zod: 4.4.3 @@ -9191,12 +9192,12 @@ snapshots: '@cloudflare/workers-types': 5.20260810.1 '@opentelemetry/api': 1.9.1 - '@better-auth/drizzle-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.5-ab785fc(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(pg@8.22.0)(zod@4.4.3))': + '@better-auth/drizzle-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3))': dependencies: '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) '@better-auth/utils': 0.4.2 optionalDependencies: - drizzle-orm: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(pg@8.22.0)(zod@4.4.3) + drizzle-orm: 1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3) '@better-auth/kysely-adapter@1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(kysely@0.29.4)': dependencies: @@ -9456,7 +9457,7 @@ snapshots: - utf-8-validate - webpack - '@bleedingdev/modern-js-code-tools@3.8.2-ultramodern.12(patch_hash=f47cc9b656ff270a7ec5d1407c048fa8ff807bd72b5c491490b4ae07ffffc595)(oxlint-tsgolint@7.0.2001)': + '@bleedingdev/modern-js-code-tools@3.8.2-ultramodern.12(patch_hash=227ad960c0ce793da1dee90eeaba8edc310b14a58334be185c7cce71ae59a110)(oxlint-tsgolint@7.0.2001)': dependencies: oxlint: 1.78.0(oxlint-tsgolint@7.0.2001) transitivePeerDependencies: @@ -9475,7 +9476,7 @@ snapshots: - react - react-dom - '@bleedingdev/modern-js-create@3.8.2-ultramodern.12(patch_hash=94333aea6925d878a885616925eaefbc1668fcc0b91bf03f5e7caa12e79da131)(oxlint@1.79.0(oxlint-tsgolint@7.0.2001))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)': + '@bleedingdev/modern-js-create@3.8.2-ultramodern.12(patch_hash=c0b541c048a1d25b651f0d6bac2df86969944fc47d3e39f3af6ee7e83250faba)(oxlint@1.79.0(oxlint-tsgolint@7.0.2001))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)': dependencies: '@modern-js/codesmith': 2.6.9(supports-color@10.2.2) '@modern-js/i18n-utils': '@bleedingdev/modern-js-i18n-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' @@ -9499,7 +9500,7 @@ snapshots: - react - react-dom - '@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12(patch_hash=e96021e5be6d0ee85e6656b9110807d08e4b8dda295d17933606ff0472b61ed0)(02f8732f8d0ac3252ab424ea7a1b32c5)': + '@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12(patch_hash=e96021e5be6d0ee85e6656b9110807d08e4b8dda295d17933606ff0472b61ed0)(3a233a8c5baa0ff66c13c170d030459d)': dependencies: '@modern-js/bff-core': '@bleedingdev/modern-js-bff-core@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tsconfig-paths@4.2.0)(zod@4.4.3)' '@modern-js/builder': '@bleedingdev/modern-js-builder@3.8.2-ultramodern.12(patch_hash=c5bdbbf89a17e39cb43c17f66a1904ed29a1462b09fd19aaf05c7301cbe4601c)(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@module-federation/runtime-tools@2.8.0)(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rsbuild-plugin-rsc@0.1.1(@rsbuild/core@2.2.0-rc.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)))(supports-color@10.2.2)(tslib@2.8.1)(typescript@7.0.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26))' @@ -9523,8 +9524,8 @@ snapshots: qs: 6.15.3 type-is: 2.1.0 optionalDependencies: - '@effect/opentelemetry': 4.0.0-beta.107(@opentelemetry/api-logs@0.220.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)) - effect: 4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f) + '@effect/opentelemetry': 4.0.0-beta.107(@opentelemetry/api-logs@0.220.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)) + effect: 4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -9550,7 +9551,7 @@ snapshots: - webpack - zod - '@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12(patch_hash=e96021e5be6d0ee85e6656b9110807d08e4b8dda295d17933606ff0472b61ed0)(76a9f4d76a32c6bb3b280341cf123861)': + '@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12(patch_hash=e96021e5be6d0ee85e6656b9110807d08e4b8dda295d17933606ff0472b61ed0)(e7b2bba40f7fca088d40067165acde85)': dependencies: '@modern-js/bff-core': '@bleedingdev/modern-js-bff-core@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tsconfig-paths@4.2.0)(zod@4.4.3)' '@modern-js/builder': '@bleedingdev/modern-js-builder@3.8.2-ultramodern.12(patch_hash=c5bdbbf89a17e39cb43c17f66a1904ed29a1462b09fd19aaf05c7301cbe4601c)(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@module-federation/runtime-tools@2.8.0)(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(clean-css@5.3.3)(csso@5.0.5)(esbuild@0.28.1)(lightningcss@1.33.0)(react-dom@19.2.8(react@19.2.8))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rsbuild-plugin-rsc@0.1.1(@rsbuild/core@2.2.0(@module-federation/runtime-tools@2.8.0)(core-js@3.49.0))(react-server-dom-rspack@0.1.0(@rspack/core@2.2.0(@module-federation/runtime-tools@2.8.0)(@swc/helpers@0.5.23))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)))(supports-color@10.2.2)(tslib@2.8.1)(typescript@7.0.2)(webpack@5.108.4(@swc/core@1.15.43(@swc/helpers@0.5.23))(clean-css@5.3.3)(cssnano@8.0.2(postcss@8.5.26))(csso@5.0.5)(esbuild@0.28.1)(html-minifier-terser@7.2.0)(lightningcss@1.33.0)(postcss@8.5.26))' @@ -9574,8 +9575,8 @@ snapshots: qs: 6.15.3 type-is: 2.1.0 optionalDependencies: - '@effect/opentelemetry': 4.0.0-beta.107(@opentelemetry/api-logs@0.220.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)) - effect: 4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f) + '@effect/opentelemetry': 4.0.0-beta.107(@opentelemetry/api-logs@0.220.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)) + effect: 4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -9904,10 +9905,10 @@ snapshots: '@drizzle-team/brocli@0.12.0': {} - '@effect/opentelemetry@4.0.0-beta.107(@opentelemetry/api-logs@0.220.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))': + '@effect/opentelemetry@4.0.0-beta.107(@opentelemetry/api-logs@0.220.0)(@opentelemetry/api@1.9.1)(@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.220.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-node@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-web@2.9.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.43.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))': dependencies: '@opentelemetry/semantic-conventions': 1.43.0 - effect: 4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f) + effect: 4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98) optionalDependencies: '@opentelemetry/api': 1.9.1 '@opentelemetry/api-logs': 0.220.0 @@ -9918,19 +9919,19 @@ snapshots: '@opentelemetry/sdk-trace-node': 2.9.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-web': 2.9.0(@opentelemetry/api@1.9.1) - '@effect/platform-node-shared@4.0.0-rc.112(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))': + '@effect/platform-node-shared@4.0.0-rc.112(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))': dependencies: '@types/ws': 8.18.1 - effect: 4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f) + effect: 4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98) ws: 8.21.3 transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(ioredis@5.11.1(supports-color@10.2.2))': + '@effect/platform-node@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(ioredis@5.11.1(supports-color@10.2.2))': dependencies: - '@effect/platform-node-shared': 4.0.0-rc.112(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)) - effect: 4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f) + '@effect/platform-node-shared': 4.0.0-rc.112(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)) + effect: 4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98) ioredis: 5.11.1(supports-color@10.2.2) mime: 4.1.0 undici: 8.10.1 @@ -9938,9 +9939,9 @@ snapshots: - bufferutil - utf-8-validate - '@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))': + '@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))': dependencies: - effect: 4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f) + effect: 4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98) pg: 8.22.0 pg-connection-string: 2.14.0 pg-cursor: 2.22.0(pg@8.22.0) @@ -13245,10 +13246,10 @@ snapshots: baseline-browser-mapping@2.11.19: {} - better-auth@1.7.2(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(drizzle-kit@1.0.0-rc.5-ab785fc)(drizzle-orm@1.0.0-rc.5-ab785fc(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(pg@8.22.0)(zod@4.4.3))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + better-auth@1.7.2(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(drizzle-kit@1.0.0-rc.5-ab785fc)(drizzle-orm@1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3))(pg@8.22.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2) - '@better-auth/drizzle-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.5-ab785fc(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(pg@8.22.0)(zod@4.4.3)) + '@better-auth/drizzle-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3)) '@better-auth/kysely-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2)(kysely@0.29.4) '@better-auth/memory-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2) '@better-auth/mongo-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1(patch_hash=9276628e25c79910215b343c608abe336b3636f093565a04953069224d890747))(@cloudflare/workers-types@5.20260810.1)(@opentelemetry/api@1.9.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.5)(kysely@0.29.4)(nanostores@1.4.2))(@better-auth/utils@0.4.2) @@ -13266,7 +13267,7 @@ snapshots: zod: 4.4.3 optionalDependencies: drizzle-kit: 1.0.0-rc.5-ab785fc - drizzle-orm: 1.0.0-rc.5-ab785fc(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(pg@8.22.0)(zod@4.4.3) + drizzle-orm: 1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3) pg: 8.22.0 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) @@ -13826,15 +13827,15 @@ snapshots: get-tsconfig: 4.14.3 jiti: 2.7.0 - drizzle-orm@1.0.0-rc.5-ab785fc(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f))(pg@8.22.0)(zod@4.4.3): + drizzle-orm@1.0.0-rc.5-ab785fc(patch_hash=b99614c9074d41f359743addc0f2a131dbd9c9c479d57cc0f0bb2bb1344bf5fe)(@cloudflare/workers-types@5.20260810.1)(@effect/sql-pg@4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)))(@opentelemetry/api@1.9.1)(@sinclair/typebox@0.34.52)(@types/pg@8.20.0)(bun-types@1.4.0)(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98))(pg@8.22.0)(zod@4.4.3): optionalDependencies: '@cloudflare/workers-types': 5.20260810.1 - '@effect/sql-pg': 4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f)) + '@effect/sql-pg': 4.0.0-beta.107(effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98)) '@opentelemetry/api': 1.9.1 '@sinclair/typebox': 0.34.52 '@types/pg': 8.20.0 bun-types: 1.4.0 - effect: 4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f) + effect: 4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98) pg: 8.22.0 zod: 4.4.3 @@ -13846,7 +13847,7 @@ snapshots: eastasianwidth@0.2.0: {} - effect@4.0.0-beta.107(patch_hash=ba67c866590cfab0c3cbbab16475ec80811dce9a0add857294c0053d9149640f): + effect@4.0.0-beta.107(patch_hash=88f3b14039aa2f5b39430af1b0034f5dfed1b4d4191bef99e8dfc58fc9ab3e98): dependencies: '@standard-schema/spec': 1.1.0 fast-check: 4.9.0 diff --git a/app/pnpm-workspace.yaml b/app/pnpm-workspace.yaml index 73d0ee427..87d92b794 100644 --- a/app/pnpm-workspace.yaml +++ b/app/pnpm-workspace.yaml @@ -135,3 +135,4 @@ patchedDependencies: '@bleedingdev/modern-js-create@3.8.2-ultramodern.12': patches/@bleedingdev__modern-js-create@3.8.2-ultramodern.12.patch '@better-fetch/fetch@1.3.1': patches/@better-fetch__fetch@1.3.1.patch '@bleedingdev/modern-js-code-tools@3.8.2-ultramodern.12': patches/@bleedingdev__modern-js-code-tools@3.8.2-ultramodern.12.patch + drizzle-orm@1.0.0-rc.5-ab785fc: patches/drizzle-orm-rc5-declarations.patch diff --git a/app/quality-audit/knip-model.mts b/app/quality-audit/knip-model.mts index 947c10875..f3ac2a355 100644 --- a/app/quality-audit/knip-model.mts +++ b/app/quality-audit/knip-model.mts @@ -57,7 +57,7 @@ const unprovenResolver = { kind: 'resolver-unproven' } as const; export const KnipModelEvidenceSchema = Schema.Struct({ anchor: Schema.optional(Schema.String), - column: Schema.optional(Schema.Number), + column: Schema.optional(Schema.Finite.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), kind: Schema.Literals([ 'entry', 'file', @@ -67,7 +67,7 @@ export const KnipModelEvidenceSchema = Schema.Struct({ unprovenResolver.kind, 'compiler-option', ]), - line: Schema.Number, + line: Schema.Finite.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)), owningManifest: Schema.optional(Schema.String), producerManifest: Schema.optional(Schema.String), producerResolved: Schema.optional(Schema.String), @@ -218,11 +218,9 @@ const parseSource = Effect.fn('QualityAudit.parseKnipModelSource')(function* par try: () => parseSync(file, source), }); if (result.errors.length > 0) { - return yield* Effect.fail( - new KnipModelError({ - reason: `Invalid quality model source ${file}: ${result.errors[0]?.message}`, - }), - ); + return yield* new KnipModelError({ + reason: `Invalid quality model source ${file}: ${result.errors[0]?.message}`, + }); } return { file, program: result.program, source, variables: declarations(result.program) }; }); @@ -501,7 +499,8 @@ const validatedBuildExport = ( return undefined; } const expected = staticString(node.arguments[0], variables); - const name = expected?.match(/^export const (?[A-Za-z_$][A-Za-z0-9_$]*)\b/u)?.groups?.name; + const { name } = + expected?.match(/^export const (?[A-Za-z_$][A-Za-z0-9_$]*)\b/u)?.groups ?? {}; return name === undefined ? undefined : { name, source: node.callee.object }; }; diff --git a/app/quality-audit/knip-runtime-model.mts b/app/quality-audit/knip-runtime-model.mts index 5c2bebd18..97ffadf48 100644 --- a/app/quality-audit/knip-runtime-model.mts +++ b/app/quality-audit/knip-runtime-model.mts @@ -24,7 +24,7 @@ const documentsBuiltInPlugin = (readme: string): boolean => readme.includes('"name": "@effect/language-service"'); class InvalidTsconfig extends Schema.TaggedError()('InvalidTsconfig', { file: Schema.String, - offset: Schema.Number, + offset: Schema.Finite.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)), }) {} const Tsconfig = Schema.Struct({ compilerOptions: Schema.optional( @@ -42,7 +42,7 @@ const parseTsconfig = Effect.fn('QualityAudit.parseTsconfig')(function* parseTsc const parsed: unknown = parseJsonc(source, errors, { allowTrailingComma: true }); const [error] = errors; if (error !== undefined) { - yield* new InvalidTsconfig({ file, offset: error.offset }); + return yield* new InvalidTsconfig({ file, offset: error.offset }); } return yield* Schema.decodeUnknownEffect(Tsconfig)(parsed); }); @@ -90,8 +90,8 @@ const uncomment = (file: string, source: string | undefined): string | undefined }; const invokedShell = (command: string): string | undefined => { - const shell = /^(?:sh|bash)\s+(?:\.\/)?(?[\w./-]+\.sh)(?:\s|$)/u.exec(command)?.groups - ?.shell; + const { shell } = + /^(?:sh|bash)\s+(?:\.\/)?(?[\w./-]+\.sh)(?:\s|$)/u.exec(command)?.groups ?? {}; if (shell === undefined || shell.includes('..')) { return undefined; } @@ -110,7 +110,8 @@ const cssDependencies = ( for (const match of withoutComments.matchAll( /@import\s+(?:url\(\s*)?["'](?[^"']+)["']/gu, )) { - const target = packageName(match.groups?.specifier ?? ''); + const { specifier = '' } = match.groups ?? {}; + const target = packageName(specifier); if (target === undefined || target.length === 0) { continue; } @@ -183,7 +184,7 @@ export const buildKnipRuntimeEvidence = Effect.fn('QualityAudit.buildKnipRuntime for (const match of source.matchAll( /^\s*node\s+(?[\w./-]+\.[cm]?[jt]s)(?:\s|$)/gmu, )) { - const target = match.groups?.target; + const { target } = match.groups ?? {}; if ( target !== undefined && !target.includes('..') && @@ -280,7 +281,7 @@ export const buildKnipRuntimeEvidence = Effect.fn('QualityAudit.buildKnipRuntime for (const match of source.matchAll( /^\s*-\s+cd app && (?:[A-Z_]+=\S+\s+)*node\s+(?[\w./-]+\.[cm]?[jt]s)(?:\s|$)/gmu, )) { - const target = match.groups?.target; + const { target } = match.groups ?? {}; if ( target !== undefined && !target.includes('..') && @@ -392,9 +393,11 @@ export const buildKnipRuntimeEvidence = Effect.fn('QualityAudit.buildKnipRuntime vendor.includes('import(moduleUrl)') ) { const match = /const configPath = '(?[^']+)'/u.exec(vendor); - const target = match?.groups?.target; + if (match === null) { + return; + } + const [, target] = match; if ( - match !== null && target !== undefined && !target.includes('..') && (yield* read(target)) !== undefined @@ -437,7 +440,7 @@ export const buildKnipRuntimeEvidence = Effect.fn('QualityAudit.buildKnipRuntime for (const match of source.matchAll( /^(?:pre-commit|pre-push):\r?\n(?(?:^[ \t].*(?:\r?\n|$))*)/gmu, )) { - const body = match.groups?.body ?? ''; + const { body = '' } = match.groups ?? {}; if (/^\s+commands:\s*$/mu.test(body) && /^\s+run:\s+\S.+$/mu.test(body)) { evidence.push( at( diff --git a/app/quality-audit/scope.json b/app/quality-audit/scope.json index b077e2341..87bec3ebc 100644 --- a/app/quality-audit/scope.json +++ b/app/quality-audit/scope.json @@ -5,6 +5,7 @@ ], "exclude": [ "**/node_modules/**", + "**/@mf-types/**", "**/dist/**", "**/dist-cloudflare/**", "**/cloudflare-dist/**", diff --git a/app/scripts/microvertical-api-baseline-boundary.mts b/app/scripts/microvertical-api-baseline-boundary.mts index d39b20299..6101ce929 100644 --- a/app/scripts/microvertical-api-baseline-boundary.mts +++ b/app/scripts/microvertical-api-baseline-boundary.mts @@ -165,6 +165,15 @@ const propertyAssignments = ( return assignments; }; +const exactCall = ( + expression: Expression | undefined, + callee: readonly string[], + argumentCount: number, +): CallExpression | undefined => { + const call = callExpression(expression, callee); + return call?.arguments.length === argumentCount ? call : undefined; +}; + interface SharedSchemaObject { readonly assignments: ReadonlyMap; readonly identity: boolean; @@ -182,25 +191,18 @@ const sharedSchemaObject = ( if (isIdentifier(initializer) && initializer.text === sharedSchemaName) { return { assignments: new Map(), identity: true }; } - const struct = callExpression(initializer, ['Schema', 'Struct']); + const struct = exactCall(initializer, ['Schema', 'Struct'], 1); const schemaObject = objectLiteral(struct?.arguments[0]); - if (struct?.arguments.length !== 1 || schemaObject === undefined) { + if (schemaObject === undefined) { return undefined; } - const sharedSpreads = schemaObject.properties.filter( - (property) => - isSpreadAssignment(property) && - isAccessPath(property.expression, [sharedSchemaName, 'fields']), + const spreads = schemaObject.properties.filter(isSpreadAssignment); + const assignments = propertyAssignments( + schemaObject.properties.filter((property) => !isSpreadAssignment(property)), ); - const nonSpreadProperties = schemaObject.properties.filter( - (property) => !isSpreadAssignment(property), - ); - const assignments = propertyAssignments(nonSpreadProperties); if ( - sharedSpreads.length !== 1 || - schemaObject.properties.some( - (property) => isSpreadAssignment(property) && property !== sharedSpreads[0], - ) || + spreads.length !== 1 || + !spreads.every((spread) => isAccessPath(spread.expression, [sharedSchemaName, 'fields'])) || assignments === undefined || protectedFields.some((field) => assignments.has(field)) ) { @@ -237,15 +239,6 @@ const directCallChain = (expression: Expression | undefined): DirectCallChain | return { base: current, methods }; }; -const exactCall = ( - expression: Expression | undefined, - callee: readonly string[], - argumentCount: number, -): CallExpression | undefined => { - const call = callExpression(expression, callee); - return call?.arguments.length === argumentCount ? call : undefined; -}; - const brandedStringSchemaIsExact = ( declaration: VariableDeclaration | undefined, brand: string, @@ -268,33 +261,36 @@ const brandedStringSchemaIsExact = ( return stringLiteral(brandCall?.arguments[0]) === brand; }; +const importedRuntimeNames = (statement: Node, expectedPackage: string): readonly string[] => { + if ( + !isImportDeclaration(statement) || + stringLiteral(statement.moduleSpecifier) !== expectedPackage + ) { + return []; + } + const clause = statement.importClause; + const bindings = clause?.namedBindings; + if ( + clause?.phaseModifier === SyntaxKind.TypeKeyword || + bindings === undefined || + !isNamedImports(bindings) + ) { + return []; + } + return bindings.elements + .filter((element) => !element.isTypeOnly && element.propertyName === undefined) + .map((element) => element.name.text); +}; + const importsExactBindings = ( sourceFile: SourceFile, expectedPackage: string, expectedBindings: readonly string[], ): boolean => { - const required = new Set(expectedBindings); - for (const statement of sourceFile.statements) { - if ( - !isImportDeclaration(statement) || - stringLiteral(statement.moduleSpecifier) !== expectedPackage || - statement.importClause?.phaseModifier === SyntaxKind.TypeKeyword || - statement.importClause?.namedBindings === undefined || - !isNamedImports(statement.importClause.namedBindings) - ) { - continue; - } - for (const element of statement.importClause.namedBindings.elements) { - if ( - !element.isTypeOnly && - element.propertyName === undefined && - required.has(element.name.text) - ) { - required.delete(element.name.text); - } - } - } - return required.size === 0; + const names = new Set( + sourceFile.statements.flatMap((statement) => importedRuntimeNames(statement, expectedPackage)), + ); + return expectedBindings.every((name) => names.has(name)); }; const importsSharedBaselinePrimitives = ( @@ -307,41 +303,40 @@ const importsSharedBaselinePrimitives = ( 'createMicroVerticalOperationContext', ]); -// oxlint-disable-next-line complexity -- The AST shape is intentionally validated fail-closed in one expression. expires: 2026-12-31. -const foundationIsExact = ( - declaration: VariableDeclaration | undefined, - stem: string, - readinessSchemaName: string, -): boolean => { - const chain = directCallChain(declaration?.initializer); - const expectedApiNames = new Set([ - `${pascalCaseStem(stem)}FoundationApi`, - `${pascalCaseStem(stem)}ApiFoundation`, - ]); +const singleAddedArgument = ( + expression: Expression | undefined, + factory: readonly string[], + names: readonly string[], +): Expression | undefined => { + const chain = directCallChain(expression); + if (chain === undefined || !isAccessPath(chain.base.expression, factory)) { + return undefined; + } + const [method] = chain.methods; if ( - chain === undefined || - !isAccessPath(chain.base.expression, ['HttpApi', 'make']) || chain.base.arguments.length !== 1 || - !expectedApiNames.has(stringLiteral(chain.base.arguments[0]) ?? '') || + !names.includes(stringLiteral(chain.base.arguments[0]) ?? '') || chain.methods.length !== 1 || - chain.methods[0]?.name !== 'add' || - chain.methods[0].arguments.length !== 1 + method?.name !== 'add' || + method.arguments.length !== 1 ) { - return false; - } - const groupChain = directCallChain(chain.methods[0].arguments[0]); - if ( - groupChain === undefined || - !isAccessPath(groupChain.base.expression, ['HttpApiGroup', 'make']) || - groupChain.base.arguments.length !== 1 || - stringLiteral(groupChain.base.arguments[0]) !== 'foundation' || - groupChain.methods.length !== 1 || - groupChain.methods[0]?.name !== 'add' || - groupChain.methods[0].arguments.length !== 1 - ) { - return false; + return undefined; } - const endpoint = exactCall(groupChain.methods[0].arguments[0], ['HttpApiEndpoint', 'get'], 3); + return method.arguments[0]; +}; + +const foundationIsExact = ( + declaration: VariableDeclaration | undefined, + stem: string, + readinessSchemaName: string, +): boolean => { + const group = singleAddedArgument( + declaration?.initializer, + ['HttpApi', 'make'], + [`${pascalCaseStem(stem)}FoundationApi`, `${pascalCaseStem(stem)}ApiFoundation`], + ); + const endpointExpression = singleAddedArgument(group, ['HttpApiGroup', 'make'], ['foundation']); + const endpoint = exactCall(endpointExpression, ['HttpApiEndpoint', 'get'], 3); const endpointOptions = objectLiteral(endpoint?.arguments[2]); const endpointProperties = endpointOptions === undefined ? undefined : propertyAssignments(endpointOptions.properties); @@ -383,19 +378,22 @@ const rootComposesFoundation = ( ); }; -// oxlint-disable-next-line complexity -- One fail-closed predicate ties each generated operation identity to its method and route. expires: 2026-12-31. -const operationContextIsConstructed = ( - property: PropertyAssignment, - stem: string, - propertyKey: string, -): boolean => { +const operationContextFields = (property: PropertyAssignment) => { const constructorCall = exactCall( property.initializer, ['createMicroVerticalOperationContext'], 1, ); const input = objectLiteral(constructorCall?.arguments[0]); - const fields = input === undefined ? undefined : propertyAssignments(input.properties); + return input === undefined ? undefined : propertyAssignments(input.properties); +}; + +const operationContextIdentity = ( + property: PropertyAssignment, +): + | { readonly method: string; readonly operationId: string; readonly routePath: string } + | undefined => { + const fields = operationContextFields(property); const method = stringLiteral(fields?.get('method')?.initializer); const operationId = stringLiteral(fields?.get('operationId')?.initializer); const routePath = stringLiteral(fields?.get('routePath')?.initializer); @@ -403,35 +401,46 @@ const operationContextIsConstructed = ( fields?.size !== 3 || method === undefined || operationId === undefined || - routePath === undefined || - !/^[A-Z]+$/u.test(method) || - !/^\/(?!.*(?:^|\/)\.\.?\/)[^\s?#]*$/u.test(routePath) + routePath === undefined ) { + return undefined; + } + return { method, operationId, routePath }; +}; + +const operationContextIsConstructed = ( + property: PropertyAssignment, + stem: string, + propertyKey: string, +): boolean => { + const identity = operationContextIdentity(property); + if (identity === undefined) { + return false; + } + const { method, operationId, routePath } = identity; + if (!/^[A-Z]+$/u.test(method) || !/^\/(?!.*(?:^|\/)\.\.?\/)[^\s?#]*$/u.test(routePath)) { return false; } const apiName = `${pascalCaseStem(stem)}Api`; - if (propertyKey !== 'readiness') { - const generatedOperation = new Map([ - ['addCartItem', { method: 'POST', routePath: `/${stem}/cart/items` }], - ['clearCart', { method: 'POST', routePath: `/${stem}/cart/clear` }], - ['create', { method: 'POST', routePath: `/${stem}` }], - ['get', { method: 'GET', routePath: `/${stem}/:id` }], - ['getCart', { method: 'GET', routePath: `/${stem}/cart` }], - ['list', { method: 'GET', routePath: `/${stem}` }], - ['removeCartItem', { method: 'POST', routePath: `/${stem}/cart/remove` }], - ]).get(propertyKey); - return ( - operationId === `${apiName}:${routePath}` || - (operationId === `${apiName}:${camelCaseStem(stem)}:${propertyKey}` && - generatedOperation?.method === method && - generatedOperation.routePath === routePath) - ); + const generatedOperation = new Map([ + ['addCartItem', ['POST', `/${stem}/cart/items`]], + ['clearCart', ['POST', `/${stem}/cart/clear`]], + ['create', ['POST', `/${stem}`]], + ['get', ['GET', `/${stem}/:id`]], + ['getCart', ['GET', `/${stem}/cart`]], + ['list', ['GET', `/${stem}`]], + ['readiness', ['GET', `/${stem}/readiness`]], + ['removeCartItem', ['POST', `/${stem}/cart/remove`]], + ]).get(propertyKey); + const requestedOperation = [method, routePath]; + const matchesGenerated = + generatedOperation?.every((value, index) => value === requestedOperation[index]) === true; + if (propertyKey === 'readiness' && !matchesGenerated) { + return false; } return ( - method === 'GET' && - routePath === `/${stem}/readiness` && - (operationId === `${apiName}:/${stem}/readiness` || - operationId === `${apiName}:${camelCaseStem(stem)}:readiness`) + operationId === `${apiName}:${routePath}` || + (operationId === `${apiName}:${camelCaseStem(stem)}:${propertyKey}` && matchesGenerated) ); }; @@ -468,27 +477,19 @@ const metadataIsExact = ( ): boolean => { const object = constAssertionObject(declaration); const fields = object === undefined ? undefined : propertyAssignments(object.properties); - const expectedFields = new Map([ + const expectedFields = [ ['apiPrefix', expectation.apiPrefix], ['basePath', expectation.basePath], ['ownerId', expectation.ownerId], ['readinessPath', expectation.readinessPath], ...Object.entries(expectation.additionalPaths), - ]); - if ( - fields === undefined || - fields.size !== expectedFields.size || - [...expectedFields].some( - ([field, value]) => stringLiteral(fields.get(field)?.initializer) !== value, - ) - ) { - return false; - } + ] as const; return ( - stringLiteral(fields.get('apiPrefix')?.initializer) === expectation.apiPrefix && - stringLiteral(fields.get('basePath')?.initializer) === expectation.basePath && - stringLiteral(fields.get('ownerId')?.initializer) === expectation.ownerId && - stringLiteral(fields.get('readinessPath')?.initializer) === expectation.readinessPath + fields !== undefined && + fields.size === new Map(expectedFields).size && + [...expectedFields].every( + ([field, value]) => stringLiteral(fields.get(field)?.initializer) === value, + ) ); }; @@ -508,29 +509,27 @@ const markerSchemaIsShared = ( if (schema === undefined || schema.identity) { return schema?.identity === true; } - if ( - [...schema.assignments.keys()].some( - (field) => !['appId', 'kind', 'schemaVersion', 'unitId'].includes(field), - ) || - (schema.assignments.has('appId') && - (identifierName(schema.assignments.get('appId')?.initializer) !== 'AppIdSchema' || - !brandedStringSchemaIsExact(localConst(sourceFile, 'AppIdSchema'), 'AppId'))) || - (schema.assignments.has('unitId') && - (identifierName(schema.assignments.get('unitId')?.initializer) !== 'UnitIdSchema' || - !brandedStringSchemaIsExact(localConst(sourceFile, 'UnitIdSchema'), 'UnitId'))) - ) { - return false; - } - const kind = exactCall(schema.assignments.get('kind')?.initializer, ['Schema', 'Literal'], 1); - const schemaVersion = exactCall( - schema.assignments.get('schemaVersion')?.initializer, - ['Schema', 'Literal'], - 1, - ); - return ( - (!schema.assignments.has('kind') || - stringLiteral(kind?.arguments[0]) === 'microvertical-delivery-unit') && - (!schema.assignments.has('schemaVersion') || numericLiteral(schemaVersion?.arguments[0]) === 1) + const brandedField = (property: PropertyAssignment, brand: string): boolean => + identifierName(property.initializer) === `${brand}Schema` && + brandedStringSchemaIsExact(localConst(sourceFile, `${brand}Schema`), brand); + const validators = new Map boolean>([ + ['appId', (property) => brandedField(property, 'AppId')], + ['unitId', (property) => brandedField(property, 'UnitId')], + [ + 'kind', + (property) => + stringLiteral(exactCall(property.initializer, ['Schema', 'Literal'], 1)?.arguments[0]) === + 'microvertical-delivery-unit', + ], + [ + 'schemaVersion', + (property) => + numericLiteral(exactCall(property.initializer, ['Schema', 'Literal'], 1)?.arguments[0]) === + 1, + ], + ]); + return [...schema.assignments].every( + ([field, property]) => validators.get(field)?.(property) === true, ); }; diff --git a/app/scripts/quality-audit-gate.mts b/app/scripts/quality-audit-gate.mts index c62cb136a..66ae600a6 100644 --- a/app/scripts/quality-audit-gate.mts +++ b/app/scripts/quality-audit-gate.mts @@ -1,11 +1,11 @@ #!/usr/bin/env node -import { NodeRuntime, NodeServices } from '@effect/platform-node'; -import { Console, Data, Effect, FileSystem, Layer, Match, Path, Schema } from 'effect'; +import { Console, Data, Effect, FileSystem, Match, Path, Schema } from 'effect'; import { Command, Flag } from 'effect/unstable/cli'; +import { runQualityCli } from './quality-cli-lifecycle.mts'; const FALLOW_FILES = 'fallow-files'; const FALLOW_HEALTH = 'fallow-health'; -const Count = Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)); +const Count = Schema.Finite.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)); const PositiveCount = Count.check(Schema.isGreaterThan(0)); const Counts = Schema.Record(Schema.String, Count); const base = { @@ -156,12 +156,5 @@ const cli = Command.make( ); if (Schema.is(Schema.Struct({ main: Schema.Literal(true) }))(import.meta)) { - const mainLayer = Layer.effectDiscard( - Command.run(cli, { version: '1.0.0' }).pipe( - Effect.tapError((issue) => Console.error(String(issue))), - ), - ).pipe(Layer.provide(NodeServices.layer)); - NodeRuntime.runMain(Effect.scoped(Layer.build(mainLayer)).pipe(Effect.asVoid), { - disableErrorReporting: true, - }); + runQualityCli(Command.run(cli, { version: '1.0.0' })); } diff --git a/app/scripts/quality-audit.mts b/app/scripts/quality-audit.mts index ee42396dd..fea41bc8b 100644 --- a/app/scripts/quality-audit.mts +++ b/app/scripts/quality-audit.mts @@ -1,13 +1,11 @@ #!/usr/bin/env node import nodePath from 'node:path'; -import { NodeRuntime, NodeServices } from '@effect/platform-node'; import { Array as EffectArray, Clock, Console, Effect, FileSystem, - Layer, Order, Path, Result, @@ -15,6 +13,7 @@ import { Stream, } from 'effect'; import { Command, Flag } from 'effect/unstable/cli'; +import { runQualityCli } from './quality-cli-lifecycle.mts'; import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; import { buildKnipModel, @@ -37,7 +36,7 @@ const SOURCE_GROUPS = { } as const; const ToolSchema = Schema.Literals(['all', 'knip', 'jscpd', 'fallow']); type AuditTool = typeof ToolSchema.Type; -const CountSchema = Schema.Number.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)); +const CountSchema = Schema.Finite.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0)); const ScopeSchema = Schema.Struct({ exclude: Schema.Array(Schema.String), patterns: Schema.Array(Schema.String), @@ -304,7 +303,8 @@ const evaluateKnip = Effect.fn('qualityAudit.evaluateKnip')(function* evaluateKn const validated = yield* validateKnip('knip', report); const modeledUsages = yield* calibrateKnip(report, directory); const nativeFindingCounts = validated.coverage.findingCounts; - const unlisted = (nativeFindingCounts.unlisted ?? 0) - modeledUsages; + const { unlisted: nativeUnlisted = 0 } = nativeFindingCounts; + const unlisted = nativeUnlisted - modeledUsages; if (unlisted < 0) { return yield* failure('Knip modeled usages exceed raw unlisted count'); } @@ -526,7 +526,7 @@ const readSourceProvenance = Effect.fn('qualityAudit.readSourceProvenance')( : 'unavailable (no Git HEAD)', sourceState: Result.match(status, { onFailure: () => 'unavailable', - onSuccess: (output) => (output.trim() ? 'modified' : 'clean'), + onSuccess: (output) => (output.trim().length > 0 ? 'modified' : 'clean'), }), workingTreeChanges: Result.isSuccess(status) ? status.success.trimEnd().split('\n').filter(Boolean) @@ -580,9 +580,15 @@ const snapshotConfiguration = Effect.fn('qualityAudit.snapshotConfiguration')( target, ); const relativeOutput = path.relative(root, path.dirname(directory)); + const outputIsInsideRoot = + relativeOutput !== '..' && + !relativeOutput.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativeOutput); yield* writeJson(target, { ...config, - ignorePatterns: [...ignores.ignorePatterns, `${relativeOutput}/**`], + ignorePatterns: outputIsInsideRoot + ? [...ignores.ignorePatterns, `${relativeOutput}/**`] + : ignores.ignorePatterns, }); } return configs; @@ -611,7 +617,7 @@ const reconcileFallowCoverage = Effect.fn('qualityAudit.reconcileFallowCoverage' const fallow = results.find( (result) => result.name === FALLOW_FILES && result.status === 'reported', ); - if (fallow) { + if (fallow !== undefined) { const report = yield* decodeReport( FallowFilesSchema, yield* fs.readFileString(path.join(fallow.directory, 'report.json')), @@ -635,12 +641,13 @@ const reconcileFallowCoverage = Effect.fn('qualityAudit.reconcileFallowCoverage' ] .filter(Boolean) .join('; '); - yield* failure(diagnostic); + return yield* failure(diagnostic); } if (Result.isFailure(counts)) { - yield* counts.failure; + return yield* counts.failure; } } + return yield* Effect.void; }, ); @@ -660,7 +667,7 @@ const reconcileCoverage = Effect.fn('qualityAudit.reconcileCoverage')( }); const expectedWorkspaces = ['.', ...expectedManifests.map((file) => path.dirname(file))]; const knip = results.find((result) => result.name === 'knip' && result.status === 'reported'); - if (knip) { + if (knip !== undefined) { const observed = (knip.coverage.workspaces ?? []).map( (workspace) => path.relative(canonicalRoot, workspace) || '.', ); @@ -668,12 +675,12 @@ const reconcileCoverage = Effect.fn('qualityAudit.reconcileCoverage')( observed.length !== expectedWorkspaces.length || expectedWorkspaces.some((workspace) => !observed.includes(workspace)) ) { - yield* failure( + return yield* failure( `Knip workspace coverage mismatch: expected ${expectedWorkspaces.join(', ')}, observed ${observed.join(', ')}`, ); } } - yield* reconcileFallowCoverage(directory, files, results, expectedWorkspaces); + return yield* reconcileFallowCoverage(directory, files, results, expectedWorkspaces); }, ); @@ -741,7 +748,7 @@ const executeStep = Effect.fn('qualityAudit.executeStep')(function* executeStepE verifiedVersion: Result.isSuccess(execution) ? execution.success.verifiedVersion : '', }); const evaluated = yield* Effect.gen(function* evaluateAnalyzer() { - if (executionError) { + if (executionError.length > 0) { return yield* failure(executionError); } if (exitCode !== 0) { @@ -749,7 +756,7 @@ const executeStep = Effect.fn('qualityAudit.executeStep')(function* executeStepE } const stderr = yield* fs.readFileString(stderrPath); if ( - stderr.trim() && + stderr.trim().length > 0 && !(step.name === 'jscpd' && stderr.trim() === `Using config from ${step.args[1]}`) ) { return yield* failure( @@ -1016,10 +1023,11 @@ export const runQualityAudit = Effect.fn('qualityAudit.runQualityAudit')( (result) => Console.error(`${result.name}: ${result.diagnostic}`), { concurrency: 1 }, ); - yield* failure( + return yield* failure( 'Quality audit analysis failed; diagnostics preserved in summary and raw artifacts', ); } + return yield* Effect.void; }, ); @@ -1038,12 +1046,5 @@ const cli = Command.make( ); if (Schema.is(Schema.Struct({ main: Schema.Literal(true) }))(import.meta)) { - const mainLayer = Layer.effectDiscard( - Command.run(cli, { version: '1.0.0' }).pipe( - Effect.tapError((issue) => Console.error(String(issue))), - ), - ).pipe(Layer.provide(NodeServices.layer)); - NodeRuntime.runMain(Effect.scoped(Layer.build(mainLayer)).pipe(Effect.asVoid), { - disableErrorReporting: true, - }); + runQualityCli(Command.run(cli, { version: '1.0.0' })); } diff --git a/app/scripts/quality-cli-lifecycle.mts b/app/scripts/quality-cli-lifecycle.mts new file mode 100644 index 000000000..54aa73409 --- /dev/null +++ b/app/scripts/quality-cli-lifecycle.mts @@ -0,0 +1,15 @@ +import { NodeRuntime, NodeServices } from '@effect/platform-node'; +import { Console, Effect, Layer } from 'effect'; +import type { Scope } from 'effect'; + +/** Run a CLI at its main-module edge, retaining scoped cleanup and one error reporter. */ +export const runQualityCli = ( + command: Effect.Effect, +) => { + const mainLayer = Layer.effectDiscard( + command.pipe(Effect.tapError((issue) => Console.error(String(issue)))), + ).pipe(Layer.provide(NodeServices.layer)); + NodeRuntime.runMain(Effect.scoped(Layer.build(mainLayer)).pipe(Effect.asVoid), { + disableErrorReporting: true, + }); +}; diff --git a/app/scripts/scaffolding/shared.mts b/app/scripts/scaffolding/shared.mts index 7fa567877..3ec854bad 100644 --- a/app/scripts/scaffolding/shared.mts +++ b/app/scripts/scaffolding/shared.mts @@ -1593,23 +1593,13 @@ const isGeneratedSlotFluentBoundary = ( /\n\s*\.$/u.test(source) && isCompleteFluentSlotTail(state, source.slice(0, -1)); -const splitGeneratedSlotEntries = (slotBody: string): readonly string[] => { - const body = dedentGeneratedSlotBody(slotBody); - if (body.length === 0) { - return []; - } - const entries: string[] = []; - const fluentTailBoundaries: number[] = []; +const scanGeneratedSlotEntries = ( + body: string, + state: GeneratedSlotScanState, + entries: string[], + fluentTailBoundaries: number[], +): string => { let current = ''; - const state: GeneratedSlotScanState = { - blockComment: false, - braces: 0, - brackets: 0, - escaped: false, - lineComment: false, - parentheses: 0, - quote: null, - }; for (let index = 0; index < body.length; index += 1) { const character = body.charAt(index); const previousCharacter = body.charAt(index - 1); @@ -1633,6 +1623,26 @@ const splitGeneratedSlotEntries = (slotBody: string): readonly string[] => { fluentTailBoundaries.length = 0; } } + return current; +}; + +const splitGeneratedSlotEntries = (slotBody: string): readonly string[] => { + const body = dedentGeneratedSlotBody(slotBody); + if (body.length === 0) { + return []; + } + const entries: string[] = []; + const fluentTailBoundaries: number[] = []; + const state: GeneratedSlotScanState = { + blockComment: false, + braces: 0, + brackets: 0, + escaped: false, + lineComment: false, + parentheses: 0, + quote: null, + }; + let current = scanGeneratedSlotEntries(body, state, entries, fluentTailBoundaries); if (isCompleteFluentSlotTail(state, current)) { entries.push( ...[0, ...fluentTailBoundaries].map((start, index) => diff --git a/app/scripts/tests/api-only-tooling.test.mts b/app/scripts/tests/api-only-tooling.test.mts index cb2db9c06..73ef1eba1 100644 --- a/app/scripts/tests/api-only-tooling.test.mts +++ b/app/scripts/tests/api-only-tooling.test.mts @@ -2144,11 +2144,17 @@ const evaluatedInfrastructureSource = async ( const code = result.outputFiles[0]?.text; assert.ok(code); const effectUrl = pathToFileURL(require.resolve('effect')).href; + const buildIdentityUrl = pathToFileURL( + createRequire(path.join(partyRoot, fileName)).resolve( + '@app/shared-contracts/ultramodern-build', + ), + ).href; return runNode([ '--input-type=module', '-e', ` import * as effect from ${JSON.stringify(effectUrl)}; +import * as buildIdentity from ${JSON.stringify(buildIdentityUrl)}; import * as nodeModule from 'node:module'; import * as nodePath from 'node:path'; import * as nodeUrl from 'node:url'; @@ -2175,7 +2181,7 @@ runInNewContext(${JSON.stringify(code)}, { exports: module.exports, module, URL, ULTRAMODERN_BUILD_MARKER: 'injected-build', ULTRAMODERN_SOURCE_REVISION: 'injected-revision', __resolve: name => 'file:///dependencies/' + name, - require: name => ({ effect, 'node:module': moduleShim, 'node:path': nodePath, 'node:url': nodeUrl }[name] ?? framework), + require: name => ({ effect, '@app/shared-contracts/ultramodern-build': buildIdentity, 'node:module': moduleShim, 'node:path': nodePath, 'node:url': nodeUrl }[name] ?? framework), }); const configuration = module.exports.default; const observations = {}; @@ -2456,6 +2462,11 @@ void test('all published scaffold formats emit the executable AST baseline valid const checker = artifacts.find(({ relativePath }) => relativePath === apiBoundaryCheckerPath); assert.ok(helper, `${moduleFormat} must emit the AST baseline helper`); assert.ok(checker, `${moduleFormat} must emit the API checker`); + assert.equal( + helper.content, + expectedHelper, + `${moduleFormat} must emit byte-exact baseline source`, + ); assert.equal( await normalizedGeneratedSource('microvertical-api-baseline-boundary.mts', helper.content), await normalizedGeneratedSource('microvertical-api-baseline-boundary.mts', expectedHelper), diff --git a/app/scripts/tests/code-tools-i18n.test.mts b/app/scripts/tests/code-tools-i18n.test.mts new file mode 100644 index 000000000..a0b0d0183 --- /dev/null +++ b/app/scripts/tests/code-tools-i18n.test.mts @@ -0,0 +1,132 @@ +/// + +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import test from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { NodeServices } from '@effect/platform-node'; +import { Effect, FileSystem, Layer, Path, Schema, Stream } from 'effect'; +import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; +import { makeEffectTestCallback } from '../../packages/core-runtime/src/testing/effect-runtime.ts'; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const codeToolsPackage = '@modern-js/code-tools'; +const malformedPluginCase = 'malformed plugin'; +const packageRoot = fileURLToPath(new URL('../..', import.meta.resolve(codeToolsPackage))); +const packageRequire = createRequire(import.meta.resolve(codeToolsPackage)); + +const cases = [ + { + diagnostic: null, + name: 'clean locale branch', + source: 'export const select = locale => locale === "cs" ? "page" : "undefined";', + }, + { + diagnostic: /no-literal-visible-jsx-attributes/u, + name: 'literal visible attribute', + source: 'export const View = () => ;', + }, + { + diagnostic: /no-manual-locale-copy-branching/u, + name: 'locale copy branch', + source: 'export const select = locale => locale === "cs" ? "Český text" : "English copy";', + }, + { + diagnostic: /deliberate-i18n-plugin-failure/u, + name: malformedPluginCase, + source: 'export const value = 1;', + }, +]; + +for (const format of ['cjs', 'esm', 'esm-node']) { + for (const fixture of cases) { + const testEffect = Effect.gen(function* verifyI18nAdapter() { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const oxlintRoot = path.dirname(packageRequire.resolve('oxlint/package.json')); + const root = yield* fs.makeTempDirectoryScoped({ prefix: 'ontos-code-tools-i18n-' }); + const extension = format === 'cjs' ? 'cjs' : 'js'; + const dist = path.join(root, 'dist', format); + yield* fs.makeDirectory(path.join(root, 'dist'), { recursive: true }); + yield* fs.copy(path.join(packageRoot, 'dist', format), dist); + yield* fs.writeFileString( + path.join(root, 'package.json'), + encodeJson({ name: codeToolsPackage, type: 'module' }), + ); + yield* fs.makeDirectory(path.join(root, 'node_modules')); + yield* fs.symlink(oxlintRoot, path.join(root, 'node_modules', 'oxlint')); + yield* fs.makeDirectory(path.join(root, 'src')); + yield* fs.writeFileString(path.join(root, 'src', 'fixture.tsx'), fixture.source); + if (fixture.name === malformedPluginCase) { + // A deliberately broken, isolated plugin exercises Oxlint's actual crash reporter. + const plugin = + '{ meta: { name: "ultramodern" }, rules: { "no-manual-locale-copy-branching": { meta: { schema: [] }, create() { return { Program() { throw new Error("deliberate-i18n-plugin-failure"); } }; } } } }'; + yield* fs.writeFileString( + path.join(root, 'src', 'oxlint-plugin.ts'), + `export default ${plugin};`, + ); + } else if (format === 'cjs') { + // Oxlint expects the plugin value, not the CJS module's named-export namespace. + yield* fs.writeFileString( + path.join(root, 'src', 'oxlint-plugin.ts'), + 'import plugin from "../dist/cjs/oxlint-plugin.cjs"; export default plugin.default;', + ); + } + const adapter = pathToFileURL(path.join(dist, 'cli', `oxlint.${extension}`)).href; + const rules = + fixture.name === malformedPluginCase + ? { 'ultramodern/no-manual-locale-copy-branching': 'error' } + : { + 'ultramodern/no-literal-visible-jsx-attributes': 'error', + 'ultramodern/no-manual-locale-copy-branching': 'error', + }; + const script = `import { runOxlintRules, printOxlintOutput } from ${encodeJson(adapter)}; +const result = runOxlintRules({ cwd: ${encodeJson(root)}, targets: ['src'], rules: ${encodeJson(rules)} }); +printOxlintOutput(result); process.exitCode = result.exitCode;`; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make(process.execPath, ['--input-type=module', '--eval', script], { + cwd: root, + env: { TMPDIR: root }, + extendEnv: true, + stderr: 'pipe', + stdin: 'ignore', + stdout: 'pipe', + }), + ); + const result = yield* Effect.all( + { + status: child.exitCode.pipe(Effect.map(Number)), + stderr: child.stderr.pipe(Stream.decodeText(), Stream.mkString), + stdout: child.stdout.pipe(Stream.decodeText(), Stream.mkString), + }, + { concurrency: 'unbounded' }, + ); + const output = result.stdout + result.stderr; + if (fixture.diagnostic === null) { + assert.equal(result.status, 0, output); + assert.equal(output, ''); + } else { + assert.equal(result.status, 1, output); + assert.match(output, fixture.diagnostic); + assert.match(output, /fixture\.tsx/u); + if (fixture.name === malformedPluginCase) { + assert.match(output, /Error running JS plugin/u); + assert.doesNotMatch(output, /:0:0: {2}\[Warning\]/u); + } else { + assert.match(output, /fixture\.tsx:1:\d+/u); + assert.doesNotMatch(output, /Error running JS plugin/u); + } + } + }); + void test( + `code-tools ${format}: ${fixture.name}`, + makeEffectTestCallback( + Effect.scoped( + Layer.build(Layer.effectDiscard(testEffect).pipe(Layer.provide(NodeServices.layer))), + ), + ), + ); + } +} diff --git a/app/scripts/tests/dependency-declarations.test.mts b/app/scripts/tests/dependency-declarations.test.mts new file mode 100644 index 000000000..1e010b3f6 --- /dev/null +++ b/app/scripts/tests/dependency-declarations.test.mts @@ -0,0 +1,189 @@ +/// + +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { appendFileSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { Option } from 'effect'; +import { Param } from 'effect/unstable/cli'; + +const configFilename = 'tsconfig.json'; +const metadataFilename = 'metadata.mts'; +const compilerRelativePath = 'node_modules/.bin/tsc'; +const workspaceRoot = fileURLToPath(new URL('../..', import.meta.url)); + +const countDiagnostics = (diagnostics: string, pattern: RegExp): number => + [...diagnostics.matchAll(pattern)].length; + +const verifyDrizzleRuntimeFormats = (fixture: string): void => { + for (const extension of ['mjs', 'cjs']) { + const runtimeSource = + extension === 'mjs' + ? "import assert from 'node:assert/strict'; import { pgPolicy, pgRole } from 'drizzle-orm/pg-core'; import { cockroachPolicy, cockroachRole } from 'drizzle-orm/cockroach-core';" + : "const assert = require('node:assert/strict'); const { pgPolicy, pgRole } = require('drizzle-orm/pg-core'); const { cockroachPolicy, cockroachRole } = require('drizzle-orm/cockroach-core');"; + const filename = path.join(fixture, `runtime.${extension}`); + writeFileSync( + filename, + `${runtimeSource} +for (const factory of [pgPolicy, cockroachPolicy]) { + for (const policy of [factory('absent'), factory('empty', {}), factory('undefined', { as: undefined, for: undefined, to: undefined, using: undefined, withCheck: undefined })]) { +for (const key of ['as', 'for', 'to', 'using', 'withCheck']) assert.equal(policy[key], undefined); + } +} +for (const factory of [pgRole, cockroachRole]) { + for (const role of [factory('absent'), factory('empty', {}), factory('undefined', { createDb: undefined, createRole: undefined })]) { +assert.equal(role.createDb, undefined); assert.equal(role.createRole, undefined); + } +} +assert.equal(pgRole('undefined', { inherit: undefined }).inherit, undefined); +`, + ); + const result = spawnSync(process.execPath, [filename], { encoding: 'utf-8' }); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stdout + result.stderr); + } +}; + +void test('published dependency declarations retain strict positive and negative contracts', () => { + const fixture = mkdtempSync(path.join(tmpdir(), 'ontos-declaration-contract-')); + try { + symlinkSync( + path.join(workspaceRoot, 'node_modules'), + path.join(fixture, 'node_modules'), + 'dir', + ); + const imports = `import { pgPolicy, pgRole, type PgPolicyConfig, type PgRoleConfig } from 'drizzle-orm/pg-core'; +import { cockroachPolicy, cockroachRole, type CockroachPolicyConfig, type CockroachRoleConfig } from 'drizzle-orm/cockroach-core'; +import { sql } from 'drizzle-orm';\n`; + const positive = `${imports} +const pg: PgPolicyConfig = pgPolicy('pg', { as: undefined, for: undefined, to: undefined, using: undefined, withCheck: undefined }); +const cr: CockroachPolicyConfig = cockroachPolicy('cr', { as: undefined, for: undefined, to: undefined, using: undefined, withCheck: undefined }); +const pr: PgRoleConfig = pgRole('pr', { createDb: undefined, createRole: undefined, inherit: undefined }); +const rr: CockroachRoleConfig = cockroachRole('rr', { createDb: undefined, createRole: undefined }); +pgPolicy('absent'); cockroachPolicy('absent'); pgRole('absent'); cockroachRole('absent'); +pgPolicy('empty', {}); cockroachPolicy('empty', {}); pgRole('empty', {}); cockroachRole('empty', {}); +pgPolicy('valid', { as: 'permissive', for: 'select', to: pgRole('role'), using: sql\`true\`, withCheck: sql\`true\` }); +cockroachPolicy('valid', { as: 'restrictive', for: 'update', to: cockroachRole('role'), using: sql\`true\`, withCheck: sql\`true\` }); +pgRole('valid', { createDb: true, createRole: false, inherit: true }); +cockroachRole('valid', { createDb: true, createRole: false }); +`; + const negative = `${imports}${['pgPolicy', 'cockroachPolicy'] + .flatMap((factory) => [ + `${factory}('invalid', { as: 'invalid' });`, + `${factory}('invalid', { for: 'invalid' });`, + `${factory}('invalid', { to: 42 });`, + `${factory}('invalid', { using: 'true' });`, + `${factory}('invalid', { withCheck: false });`, + ]) + .join('\n')} +pgRole('invalid', { createDb: 'yes' }); +pgRole('invalid', { createRole: 1 }); +pgRole('invalid', { inherit: null }); +cockroachRole('invalid', { createDb: 'yes' }); +cockroachRole('invalid', { createRole: 1 }); +`; + for (const extension of ['mts', 'cts']) { + for (const [name, source, errors] of [ + ['positive', positive, 0], + ['negative', negative, 15], + ] as const) { + const filename = `${name}.${extension}`; + writeFileSync(path.join(fixture, filename), source); + writeFileSync( + path.join(fixture, configFilename), + JSON.stringify({ + compilerOptions: { + exactOptionalPropertyTypes: true, + module: 'NodeNext', + noEmit: true, + skipLibCheck: false, + strict: true, + target: 'ESNext', + types: ['node'], + }, + files: [filename], + }), + ); + const result = spawnSync( + path.join(workspaceRoot, compilerRelativePath), + ['-p', path.join(fixture, configFilename), '--pretty', 'false'], + { encoding: 'utf-8' }, + ); + assert.ifError(result.error); + const diagnostics = result.stdout + result.stderr; + assert.equal(result.status, errors === 0 ? 0 : 1, diagnostics); + assert.equal(countDiagnostics(diagnostics, /error TS2322:/gu), errors, diagnostics); + assert.equal(countDiagnostics(diagnostics, /error TS\d+:/gu), errors, diagnostics); + } + } + verifyDrizzleRuntimeFormats(fixture); + writeFileSync( + path.join(fixture, metadataFilename), + `import { Option } from 'effect'; +import { Param } from 'effect/unstable/cli'; +const metadata = Param.getParamMetadata(Param.string(Param.flagKind, 'name')); +const expected: { readonly isOptional: boolean; readonly isVariadic: boolean; readonly variadicMin: Option.Option; readonly variadicMax: Option.Option } = metadata; +const reverse: typeof metadata = expected; +`, + ); + writeFileSync( + path.join(fixture, configFilename), + JSON.stringify({ + compilerOptions: { + exactOptionalPropertyTypes: true, + module: 'NodeNext', + noEmit: true, + skipLibCheck: false, + strict: true, + target: 'ESNext', + types: ['node'], + }, + files: [metadataFilename], + }), + ); + const result = spawnSync( + path.join(workspaceRoot, compilerRelativePath), + ['-p', path.join(fixture, configFilename), '--pretty', 'false'], + { encoding: 'utf-8' }, + ); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stdout + result.stderr); + appendFileSync( + path.join(fixture, metadataFilename), + ` +const wrongOptional: string = metadata.isOptional; +const wrongVariadic: number = metadata.isVariadic; +const wrongMin: Option.Option = metadata.variadicMin; +const wrongMax: Option.Option = metadata.variadicMax; +metadata.isOptional = true; +`, + ); + const invalidMetadata = spawnSync( + path.join(workspaceRoot, compilerRelativePath), + ['-p', path.join(fixture, configFilename), '--pretty', 'false'], + { encoding: 'utf-8' }, + ); + assert.ifError(invalidMetadata.error); + const diagnostics = invalidMetadata.stdout + invalidMetadata.stderr; + assert.equal(invalidMetadata.status, 1, diagnostics); + assert.equal(countDiagnostics(diagnostics, /error TS2322:/gu), 2, diagnostics); + assert.equal(countDiagnostics(diagnostics, /error TS2375:/gu), 2, diagnostics); + assert.equal(countDiagnostics(diagnostics, /error TS2540:/gu), 1, diagnostics); + assert.equal(countDiagnostics(diagnostics, /error TS\d+:/gu), 5, diagnostics); + } finally { + rmSync(fixture, { force: true, recursive: true }); + } +}); + +void test('published runtime exposes real parameter metadata', () => { + const metadata = Param.getParamMetadata(Param.string(Param.flagKind, 'name')); + assert.deepEqual(metadata, { + isOptional: false, + isVariadic: false, + variadicMax: Option.none(), + variadicMin: Option.none(), + }); +}); diff --git a/app/scripts/tests/generated-slot-entries.test.mts b/app/scripts/tests/generated-slot-entries.test.mts new file mode 100644 index 000000000..93fbc1299 --- /dev/null +++ b/app/scripts/tests/generated-slot-entries.test.mts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Result } from 'effect'; +import { readGeneratedSlotEntries } from '../scaffolding/shared.mts'; + +const start = '// slot:start'; +const end = '// slot:end'; +const readEntries = (body: string): readonly string[] => + readGeneratedSlotEntries(`${start}\n${body}\n${end}`, start, end); + +void test('fluent slots split only outer calls, retaining nested multiline fluent chains', () => { + const nested = `.addHttpApi( + FirstApi + .add(Group.make('nested')) + .pipe(identity), +)`; + assert.deepEqual(readEntries(`${nested}\n.addHttpApi(SecondApi)`), [ + nested, + '.addHttpApi(SecondApi)', + ]); +}); + +void test('slot delimiters inside strings and comments do not terminate entries', () => { + const first = "first: { value: 'a,;.[({', /* ; } ] ) */ nested: [1, 2] },"; + const second = 'second: call(`comma, semicolon;`, "escaped\\\";"),'; + assert.deepEqual(readEntries(`${first}\n${second}`), [first, second]); +}); + +void test('line comments protect fluent-looking text until the newline', () => { + const first = '.addHttpApi(\n FirstApi // .addHttpApi(FakeApi);\n)'; + assert.deepEqual(readEntries(`${first}\n.addHttpApi(SecondApi)`), [ + first, + '.addHttpApi(SecondApi)', + ]); +}); + +void test('empty generated slots remain empty', () => { + assert.deepEqual(readEntries(' \n'), []); +}); + +for (const source of ['.addHttpApi(FirstApi', 'first: "open,', 'first: /* open', 'first: ] ,']) { + void test(`incomplete or unbalanced generated slot fails closed: ${source}`, () => { + assert.ok(Result.isFailure(Result.try(() => readEntries(source)))); + }); +} diff --git a/app/scripts/tests/quality-audit-count-domain.test.mts b/app/scripts/tests/quality-audit-count-domain.test.mts new file mode 100644 index 000000000..d1bb73dd2 --- /dev/null +++ b/app/scripts/tests/quality-audit-count-domain.test.mts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Effect, Result, Schema } from 'effect'; +import { KnipModelEvidenceSchema } from '../../quality-audit/knip-model.mts'; +import { makeEffectTestCallback } from '../../packages/core-runtime/src/testing/effect-runtime.ts'; +import { validateReport } from '../quality-audit.mts'; +import { validateQualityAuditSummary } from '../quality-audit-gate.mts'; + +void test('audit evidence requires finite nonnegative integer source positions', () => { + const evidence = { + kind: 'entry', + line: 0, + reason: 'fixture', + source: 'fixture.mts', + target: 'fixture.mts', + workspace: '.', + }; + const valid = Schema.is(KnipModelEvidenceSchema); + assert.equal(valid(evidence), true); + assert.equal(valid({ ...evidence, column: 0, line: 1 }), true); + for (const value of [Number.NaN, Infinity, -Infinity, -1, 0.5]) { + assert.equal(valid({ ...evidence, line: value }), false); + assert.equal(valid({ ...evidence, column: value }), false); + } +}); + +void test( + 'audit and gate reject nonfinite, negative and fractional report counts', + makeEffectTestCallback( + Effect.gen(function* invalidCountReports() { + for (const count of ['1e400', '-1e400', '-1', '0.5']) { + const audit = yield* validateReport( + 'jscpd', + `{"duplicates":[],"statistics":{"total":{"clones":0,"sources":${count}}}}`, + ).pipe(Effect.result); + assert.equal(Result.isFailure(audit), true); + const gate = yield* validateQualityAuditSummary( + `{"status":"reported","results":[{"name":"jscpd","status":"reported","diagnostic":"","advisory":false,"files":${count},"findings":0,"coverage":{"tokenEligibleFiles":${count}}}]}`, + ).pipe(Effect.result); + assert.equal(Result.isFailure(gate), true); + if (Result.isFailure(gate)) { + assert.match(gate.failure.message, /Malformed audit summary/u); + } + } + }), + ), +); diff --git a/app/scripts/tests/quality-audit.test.mts b/app/scripts/tests/quality-audit.test.mts index 5824b5274..c689c7f7b 100644 --- a/app/scripts/tests/quality-audit.test.mts +++ b/app/scripts/tests/quality-audit.test.mts @@ -14,9 +14,12 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { NodeServices } from '@effect/platform-node'; -import { Effect, Schema } from 'effect'; +import { Effect, Layer, Schema } from 'effect'; import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; -import { runEffectTestPromise } from '../../packages/core-runtime/src/testing/effect-runtime.ts'; +import { + makeEffectTestCallback, + runEffectTestPromise, +} from '../../packages/core-runtime/src/testing/effect-runtime.ts'; import { auditSteps, runQualityAudit, validateReport } from '../quality-audit.mts'; const FALLOW_CLONES = 'fallow-clones'; @@ -24,6 +27,8 @@ const FALLOW_SIMILARITY = 'fallow-similarity'; const FALLOW_HEALTH = 'fallow-health'; const CONFIG_DIRECTORY = 'quality-audit'; const REPORT_DIRECTORY = 'reports'; +const SUMMARY_FILE = 'summary.json'; +const GITIGNORE_FILE = '.gitignore'; const KNIP_CONFIG = 'quality-audit/knip.json'; const CALLER_OWNED_FILE = 'caller-owned.txt'; const ProvenanceSchema = Schema.fromJsonString( @@ -212,7 +217,7 @@ const SummarySchema = Schema.Struct({ const summary = async (output: string) => await runEffectTestPromise( Schema.decodeUnknownEffect(Schema.fromJsonString(SummarySchema))( - readFileSync(path.join(output, 'summary.json'), 'utf-8'), + readFileSync(path.join(output, SUMMARY_FILE), 'utf-8'), ), ); @@ -430,7 +435,7 @@ await test('missing binaries and an empty source scope fail with preserved summa }, ]); assert.match( - readFileSync(path.join(output, 'summary.json'), 'utf-8'), + readFileSync(path.join(output, SUMMARY_FILE), 'utf-8'), /Source inventory: analysis contains no files/u, ); } finally { @@ -455,6 +460,10 @@ await test('the CLI handles escaped paths, foreign cwd and untracked source prov ); const executable = path.join(root, 'scripts/quality audit.mts'); copyFileSync(path.join(appRoot, 'scripts/quality-audit.mts'), executable); + copyFileSync( + path.join(appRoot, 'scripts/quality-cli-lifecycle.mts'), + path.join(root, 'scripts/quality-cli-lifecycle.mts'), + ); for (const file of ['knip-model.mts', 'knip-runtime-model.mts']) { copyFileSync( path.join(appRoot, CONFIG_DIRECTORY, file), @@ -487,6 +496,62 @@ await test('the CLI handles escaped paths, foreign cwd and untracked source prov } }); +void test( + 'external report directories preserve valid Fallow exclusions and source coverage', + makeEffectTestCallback( + Effect.scoped( + Layer.build( + Layer.effectDiscard( + Effect.gen(function* externalReportDirectory() { + const root = yield* Effect.acquireRelease(Effect.promise(createFixture), (directory) => + Effect.sync(() => rmSync(directory, { force: true, recursive: true })), + ); + const output = yield* Effect.acquireRelease( + Effect.sync(() => mkdtempSync(path.join(tmpdir(), 'ontos-external-report-'))), + (directory) => Effect.sync(() => rmSync(directory, { force: true, recursive: true })), + ); + const generatedTypes = path.join(root, 'apps/shell/@mf-types/remote'); + mkdirSync(generatedTypes, { recursive: true }); + writeFileSync(path.join(root, GITIGNORE_FILE), '**/@mf-types/\n'); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const initialized = yield* spawner.exitCode( + ChildProcess.make('git', ['init', '-q'], { cwd: root }), + ); + assert.equal(Number(initialized), 0); + writeFileSync( + path.join(generatedTypes, 'index.d.ts'), + 'export declare const remoteComponent: unknown;\n', + ); + yield* runQualityAudit(root, output, 'fallow'); + const result = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(SummarySchema))( + readFileSync(path.join(output, SUMMARY_FILE), 'utf-8'), + ); + assert.equal(result.status, 'reported'); + assert.equal(result.results.length, 4); + assert.ok(result.results.every((row) => row.status === 'reported' && row.files > 0)); + const coverage = yield* Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Struct({ + extra: Schema.Array(Schema.String), + intendedSources: Schema.Number, + missing: Schema.Array(Schema.String), + }), + ), + )(readFileSync(path.join(result.runDirectory, 'coverage.json'), 'utf-8')); + // Two authored fixture sources plus the copied Knip reporter, not remote declarations. + assert.deepEqual(coverage, { extra: [], intendedSources: 3, missing: [] }); + assert.ok(result.results.some((row) => row.name === FALLOW_HEALTH && row.findings > 0)); + assert.equal( + readFileSync(path.join(result.runDirectory, 'configs/fallow.json'), 'utf-8'), + readFileSync(path.join(root, 'quality-audit/fallow.json'), 'utf-8'), + ); + }), + ).pipe(Layer.provide(NodeServices.layer)), + ), + ), + ), +); + await test('output inside a source root fails before creating analyzer snapshots', async () => { const root = await createFixture(); const output = path.join(root, 'scripts/reports'); @@ -538,13 +603,13 @@ await test('custom output does not mark clean source provenance as modified', as const root = await createFixture(); const output = path.join(root, REPORT_DIRECTORY); try { - writeFileSync(path.join(root, '.gitignore'), 'node_modules\n.codex\n'); + writeFileSync(path.join(root, GITIGNORE_FILE), 'node_modules\n.codex\n'); await runEffectTestPromise( Effect.gen(function* commitFixture() { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const commands = [ ['init', '-q'], - ['add', '.gitignore', 'package.json', CONFIG_DIRECTORY, 'scripts'], + ['add', GITIGNORE_FILE, 'package.json', CONFIG_DIRECTORY, 'scripts'], [ '-c', `core.hooksPath=${path.join(root, '.git/no-hooks')}`, diff --git a/app/scripts/tests/quality-cli-lifecycle.test.mts b/app/scripts/tests/quality-cli-lifecycle.test.mts new file mode 100644 index 000000000..d4616d912 --- /dev/null +++ b/app/scripts/tests/quality-cli-lifecycle.test.mts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { NodeServices } from '@effect/platform-node'; +import { Effect, Layer, Schema, Stream } from 'effect'; +import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process'; +import { makeEffectTestCallback } from '../../packages/core-runtime/src/testing/effect-runtime.ts'; + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +const lifecycleUrl = new URL('../quality-cli-lifecycle.mts', import.meta.url).href; +const runChild = Effect.fn('runLifecycleChild')(function* runLifecycleChild(source: string) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn( + ChildProcess.make(process.execPath, ['--input-type=module', '-e', source]), + ); + const [stdout, stderr, code] = yield* Effect.all( + [ + child.stdout.pipe(Stream.decodeText(), Stream.mkString), + child.stderr.pipe(Stream.decodeText(), Stream.mkString), + child.exitCode, + ], + { concurrency: 'unbounded' }, + ); + return { code, stderr, stdout }; +}); + +const verifyImports = Effect.gen(function* verifyInertImports() { + const imports = ['quality-audit.mts', 'quality-audit-gate.mts', 'quality-cli-lifecycle.mts'] + .map((file) => { + const url = new URL(`../${file}`, import.meta.url).href; + return `import ${encodeJson(url)};`; + }) + .join('\n'); + assert.deepEqual(yield* runChild(imports), { code: 0, stderr: '', stdout: '' }); +}); + +void test( + 'CLI modules are inert when imported', + makeEffectTestCallback( + Effect.scoped( + Layer.build(Layer.effectDiscard(verifyImports).pipe(Layer.provide(NodeServices.layer))), + ), + ), +); + +const verifyFinalization = Effect.fn('verifyFinalization')(function* verifyCliFinalization( + fails: boolean, +) { + const result = yield* runChild(` + import { Console, Data, Effect } from 'effect'; + import { runQualityCli } from ${encodeJson(lifecycleUrl)}; + class CliFailure extends Data.TaggedError('CliFailure') {} + runQualityCli(Effect.gen(function* scopedCommand() { + yield* Effect.acquireRelease(Console.log('acquired'), () => Console.log('released')); + yield* ${fails ? "Effect.fail(new CliFailure({ message: 'expected failure' }))" : 'Effect.void'}; + })); + `); + assert.equal(result.code, fails ? 1 : 0); + assert.equal(result.stdout, 'acquired\nreleased\n'); + assert.equal(result.stderr, fails ? 'CliFailure: expected failure\n' : ''); +}); + +void test( + 'CLI success finalizes scope and exits zero', + makeEffectTestCallback( + Effect.scoped( + Layer.build( + Layer.effectDiscard(verifyFinalization(false)).pipe(Layer.provide(NodeServices.layer)), + ), + ), + ), +); +void test( + 'CLI failure finalizes scope, logs once and exits one', + makeEffectTestCallback( + Effect.scoped( + Layer.build( + Layer.effectDiscard(verifyFinalization(true)).pipe(Layer.provide(NodeServices.layer)), + ), + ), + ), +); diff --git a/app/scripts/validate-ultramodern-workspace.mts b/app/scripts/validate-ultramodern-workspace.mts index 8d047ef44..9472cde48 100644 --- a/app/scripts/validate-ultramodern-workspace.mts +++ b/app/scripts/validate-ultramodern-workspace.mts @@ -533,7 +533,7 @@ const workspaceValidationContractDefinition = { 'node --test scripts/scaffolding/tests/module-contract-generator.test.mts scripts/scaffolding/tests/resource-generator.test.mts scripts/scaffolding/tests/retire-contribution.test.mts scripts/scaffolding/tests/scaffold-generators.test.mts', 'test:integration': 'pnpm -r --if-present run test:integration', 'test:scripts': - 'node --test scripts/tests/boundary-source-structure.test.mts scripts/local-environment-values.test.mts scripts/tests/audit-database-trust-boundaries.test.mts scripts/tests/authorization-rollout-contract.test.mts scripts/tests/check-authorization-readiness.test.mts scripts/tests/database-access-boundaries.test.mts scripts/tests/initialize-local-development.test.mts scripts/tests/locki-feature.test.mts scripts/tests/migrate-contacts-authorization.test.mts scripts/tests/module-entrypoint-boundaries.test.mts scripts/tests/plan-deployment-impact.test.mts scripts/tests/protected-entrypoint-inventory.test.mts scripts/tests/provision-current-action-authorization.test.mts scripts/tests/report-fail-closed-authorization-impact.test.mts scripts/tests/api-only-tooling.test.mts scripts/tests/root-environment.test.mts scripts/tests/typecheck-project-references.test.mts scripts/tests/ultramodern-command.test.mts', + 'node --test scripts/tests/boundary-source-structure.test.mts scripts/local-environment-values.test.mts scripts/tests/audit-database-trust-boundaries.test.mts scripts/tests/authorization-rollout-contract.test.mts scripts/tests/check-authorization-readiness.test.mts scripts/tests/database-access-boundaries.test.mts scripts/tests/initialize-local-development.test.mts scripts/tests/locki-feature.test.mts scripts/tests/migrate-contacts-authorization.test.mts scripts/tests/module-entrypoint-boundaries.test.mts scripts/tests/plan-deployment-impact.test.mts scripts/tests/protected-entrypoint-inventory.test.mts scripts/tests/provision-current-action-authorization.test.mts scripts/tests/report-fail-closed-authorization-impact.test.mts scripts/tests/api-only-tooling.test.mts scripts/tests/generated-slot-entries.test.mts scripts/tests/root-environment.test.mts scripts/tests/typecheck-project-references.test.mts scripts/tests/ultramodern-command.test.mts scripts/tests/code-tools-i18n.test.mts scripts/tests/dependency-declarations.test.mts', 'test:unit': 'pnpm -r --if-present run test:unit && pnpm -r --if-present run test:component', }, cloudflareSecurity: createCloudflareSecurityContract(), @@ -1960,21 +1960,6 @@ interface CompactApp { readonly port?: number; readonly portEnv?: string; } -interface CompactShell { - readonly deliveryUnit?: DeliveryUnit; - readonly id: string; - readonly kind: string; - readonly mfName?: string; - readonly moduleFederation: Json; - readonly name: string; - readonly owner?: AdditionalShell['owner']; - readonly package: string; - readonly path: string; - readonly port: number; - readonly portEnv: string; - readonly tailwindPrefix: string; - readonly verticalRefs?: readonly string[]; -} interface BridgeConfig { readonly enabled: boolean; readonly gates?: readonly { @@ -1990,7 +1975,7 @@ interface CompactConfig extends Omit< > { readonly bridge?: BridgeConfig; readonly packageSource: CompactConfigDocument['packageSource'] & { readonly registry?: string }; - readonly shells?: readonly CompactShell[]; + readonly shells?: unknown; readonly topology: Omit & { readonly apps?: readonly CompactApp[]; }; @@ -2067,27 +2052,6 @@ interface DeliveryUnit { readonly unitId?: string; readonly version?: string; } -type AdditionalShell = IdentifierEntry & { - readonly degradedState?: { - readonly appId: string; - readonly required: boolean; - readonly status: string; - }; - readonly deliveryUnit?: DeliveryUnit; - readonly id: string; - readonly mfName: string; - readonly moduleFederation: Json; - readonly owner: { - readonly id: string; - readonly kind: string; - }; - readonly packageName: string; - readonly path: string; - readonly port: number; - readonly portEnv: string; - readonly tailwindPrefix: string; - readonly verticalRefs?: readonly string[]; -}; const StringValuesSchema = Schema.Record(Schema.String, Schema.String); const PackageJsonSchema = Schema.Struct({ dependencies: Schema.optionalKey(StringValuesSchema), @@ -2197,7 +2161,7 @@ type WorkspaceValidationContract = Omit< typeof workspaceValidationContractDefinition, 'cohort' | 'generatedSurfacePolicy' > & { - readonly additionalShells?: readonly AdditionalShell[]; + readonly additionalShells?: unknown; readonly cohort: Omit< typeof workspaceValidationContractDefinition.cohort, | 'additionalShellBuildMarkerIds' @@ -2268,8 +2232,6 @@ const { shellRouteMetaPaths } = workspaceValidationContract; const compactConfigPath = workspaceValidationContract.metadata.compactConfig.path; const { retiredMetadataPaths } = workspaceValidationContract.legacy; const modernPackageCohort = workspaceValidationContract.cohort.modernPackages; -const expectedAdditionalShellIds = workspaceValidationContract.cohort.additionalShellIds ?? []; -const expectedAdditionalShells = workspaceValidationContract.additionalShells ?? []; const expectedPrimaryShellVerticalIds = workspaceValidationContract.topology?.referenceTopology?.shell?.verticalRefs ?? workspaceValidationContract.cohort.verticalIds; @@ -2614,28 +2576,6 @@ const assertWorkspaceValidationContract = (contract: WorkspaceValidationContract 'workspace validation contract Modern package cohort', ); assertUniqueStrings(contract.cohort.appIds, 'workspace validation contract app cohort'); - assertUniqueStrings( - contract.cohort.additionalShellIds ?? [], - 'workspace validation contract additional-shell cohort', - ); - const additionalShellCohortChecks: readonly (readonly [AdditionalShellCohortField, string])[] = [ - [SHARED_VALIDATOR_STRING_043, 'workspace validation contract additional-shell owner cohort'], - [ - SHARED_VALIDATOR_STRING_042, - 'workspace validation contract additional-shell delivery-unit cohort', - ], - [ - SHARED_VALIDATOR_STRING_041, - 'workspace validation contract additional-shell degraded-state cohort', - ], - [ - SHARED_VALIDATOR_STRING_040, - 'workspace validation contract additional-shell build-marker cohort', - ], - ]; - for (const [field, label] of additionalShellCohortChecks) { - assertUniqueStrings(contract.cohort[field] ?? [], label); - } assertUniqueStrings( contract.cohort.backendAppIds, 'workspace validation contract backend app cohort', @@ -2795,6 +2735,10 @@ const findGeneratedSurfacePolicyMatch = ( : new RegExp(pattern.expression, pattern.flags).exec(source); }; const assertSingleShellDeclarations = (): void => { + assert( + workspaceValidationContract.cohort.additionalShellIds === undefined, + 'Single-shell workspace must not declare additionalShellIds', + ); assert( workspaceValidationContract.cohort?.additionalShellManifests === undefined, 'Single-shell workspace must not declare additional-shell manifests', @@ -2810,33 +2754,6 @@ const assertSingleShellDeclarations = (): void => { ); } }; -const assertGeneratedAdditionalShellDeclarations = (): void => { - if ((workspaceValidationContract.cohort?.additionalShellIds ?? []).length > 0) { - const additionalShellIds = workspaceValidationContract.cohort.additionalShellIds ?? []; - for (const field of additionalShellCohortFields) { - assertSameJson( - workspaceValidationContract.cohort?.[field], - additionalShellIds, - `workspace validation contract ${field}`, - 'restore every generated additional-shell cohort', - ); - } - assertSameIdCohort( - workspaceValidationContract.cohort?.additionalShellManifests, - additionalShellIds, - 'workspace validation contract additional-shell manifests', - 'restore every generated additional-shell package manifest', - ); - assertSameIdCohort( - workspaceValidationContract.additionalShells, - additionalShellIds, - 'workspace validation contract additional-shell records', - 'restore every generated additional-shell contract record', - ); - } else { - assertSingleShellDeclarations(); - } -}; const assertGeneratedSurfacePolicy = () => { for (const rule of workspaceValidationContract.generatedSurfacePolicy.rules) { const files = sortedCopy(rule.paths.flatMap(generatedSurfacePolicyFiles), (left, right) => @@ -2855,7 +2772,7 @@ const assertGeneratedSurfacePolicy = () => { } } } - assertGeneratedAdditionalShellDeclarations(); + assertSingleShellDeclarations(); }; const compactConfigPolicyView = (config: CompactConfig): Json => ({ agentSkills: config.agentSkills, @@ -4199,39 +4116,25 @@ const assertShellDependenciesForVertical = ( vertical: FullStackVertical, expectedShellVerticalIds: readonly string[], ): void => { - const shellPackages = [ - { - path: SHARED_VALIDATOR_STRING_047, - pkg: shellPackage, - uiRefs: expectedShellVerticalIds, - }, - ...expectedAdditionalShells.map((shell) => ({ - path: shell.path, - pkg: readJson(PackageJsonSchema, `${shell.path}/package.json`), - uiRefs: shell.verticalRefs ?? [], - })), - ]; - for (const shellEntry of shellPackages) { - const composed = shellEntry.uiRefs.includes(vertical.id) && vertical.exposes.length > 0; - if (vertical.emitsApi || composed) { - assertSameJson( - valueForKey(Object.entries(shellEntry.pkg.dependencies ?? {}), vertical.packageName), - SHARED_VALIDATOR_STRING_169, - `${shellEntry.path}/package.json dependencies.${vertical.packageName}`, - 'restore shell dependency for the MicroVertical consumer', - ); - } - if (composed) { - assertSameJson( - valueForKey( - Object.entries(shellEntry.pkg[SHARED_VALIDATOR_STRING_173] ?? {}), - vertical.zephyrAlias, - ), - `${vertical.packageName}@workspace:*`, - `${shellEntry.path}/package.json zephyr:dependencies.${vertical.zephyrAlias}`, - 'restore shell Zephyr dependency metadata for the MicroVertical', - ); - } + const composed = expectedShellVerticalIds.includes(vertical.id) && vertical.exposes.length > 0; + if (vertical.emitsApi || composed) { + assertSameJson( + valueForKey(Object.entries(shellPackage.dependencies ?? {}), vertical.packageName), + SHARED_VALIDATOR_STRING_169, + `${SHARED_VALIDATOR_STRING_047}/package.json dependencies.${vertical.packageName}`, + 'restore shell dependency for the MicroVertical consumer', + ); + } + if (composed) { + assertSameJson( + valueForKey( + Object.entries(shellPackage[SHARED_VALIDATOR_STRING_173] ?? {}), + vertical.zephyrAlias, + ), + `${vertical.packageName}@workspace:*`, + `${SHARED_VALIDATOR_STRING_047}/package.json zephyr:dependencies.${vertical.zephyrAlias}`, + 'restore shell Zephyr dependency metadata for the MicroVertical', + ); } }; const generatedVerticalFederationView = (contractEntry: ReturnType) => ({ @@ -4626,48 +4529,6 @@ const assertVerticalTsConfigReferenceGraph = (vertical: FullStackVertical): void ); }; -const assertAdditionalShellTsConfigReferences = ( - shell: (typeof expectedAdditionalShells)[number], -): void => { - const additionalShellTsConfig = readJson(TsConfigSchema, `${shell.path}/tsconfig.json`); - const additionalShellMfTypesTsConfig = readJson( - TsConfigSchema, - `${shell.path}/tsconfig.mf-types.json`, - ); - const expectedAdditionalShellReferences = [ - ...sharedPackagePaths, - ...(shell.verticalRefs ?? []) - .flatMap((verticalRef) => { - const referencedVertical = fullStackVerticals.find( - (candidate) => candidate.id === verticalRef, - ); - return referencedVertical === undefined ? [] : [referencedVertical]; - }) - .map((referencedVertical) => referencedVertical.path), - ].map((referencePath) => referenceFrom(shell.path, referencePath)); - assertSameJson( - additionalShellTsConfig.references ?? [], - expectedAdditionalShellReferences, - `${shell.path}/tsconfig.json references`, - 'restore the generated additional-shell project-reference graph', - ); - assertSameJson( - additionalShellTsConfig.include ?? [], - ['src', SHARED_VALIDATOR_STRING_075, SHARED_VALIDATOR_STRING_091, 'shared'], - `${shell.path}/tsconfig.json include`, - 'restore the generated additional-shell typecheck boundary', - ); - assertProjectReferenceEmitConfig(additionalShellTsConfig, shell.path); - assertSameJson( - additionalShellMfTypesTsConfig, - { - extends: SHARED_VALIDATOR_STRING_001, - include: [SHARED_VALIDATOR_STRING_137], - }, - `${shell.path}/tsconfig.mf-types.json`, - 'restore the generated additional-shell Module Federation DTS boundary', - ); -}; const primaryShellTsConfigReferences = () => { const expectedShellReferences = [ SHARED_VALIDATOR_STRING_092, @@ -4748,10 +4609,6 @@ const assertTsConfigReferenceGraph = () => { for (const vertical of fullStackVerticals) { assertVerticalTsConfigReferenceGraph(vertical); } - - for (const shell of expectedAdditionalShells) { - assertAdditionalShellTsConfigReferences(shell); - } }; const packageJsonFiles = (startDir: string): string[] => { const files: string[] = []; @@ -5366,34 +5223,6 @@ for (const vertical of fullStackVerticals) { assertForbiddenVerticalFile(vertical)(forbiddenPath); } } -for (const shell of expectedAdditionalShells) { - requiredPaths.push( - `${shell.path}/package.json`, - `${shell.path}/tsconfig.json`, - `${shell.path}/tsconfig.mf-types.json`, - `${shell.path}/modern.config.ts`, - `${shell.path}/module-federation.config.ts`, - `${shell.path}/src/modern-app-env.d.ts`, - `${shell.path}/src/modern.runtime.ts`, - `${shell.path}/src/api/vertical-clients.ts`, - `${shell.path}/locales/en/translation.json`, - `${shell.path}/locales/en/${shellNamespace}.json`, - `${shell.path}/locales/cs/translation.json`, - `${shell.path}/locales/cs/${shellNamespace}.json`, - `${shell.path}/src/routes/index.css`, - `${shell.path}/src/routes/layout.tsx`, - `${shell.path}/src/routes/shell-frame.tsx`, - `${shell.path}/src/routes/ultramodern-route-head.tsx`, - `${shell.path}/src/routes/ultramodern-route-metadata.ts`, - `${shell.path}/src/routes/[lang]/page.tsx`, - ...shellRouteMetaPaths.map((relativePath) => - relativePath.replace(/^apps\/shell-super-app/u, shell.path), - ), - ); - if (tailwindEnabled) { - requiredPaths.push(`${shell.path}/tailwind.config.ts`); - } -} for (const requiredPath of requiredPaths) { assertExists(requiredPath); } @@ -6150,217 +5979,7 @@ const assertStructuralShellPolicy = (): void => { } }; assertStructuralShellPolicy(); -const assertAdditionalShellSources = (shell: (typeof expectedAdditionalShells)[number]): void => { - const modernConfig = readText(`${shell.path}/modern.config.ts`); - const moduleFederationConfig = readText(`${shell.path}/module-federation.config.ts`); - const runtimeConfig = readText(`${shell.path}/src/modern.runtime.ts`); - const styles = readText(`${shell.path}/src/routes/index.css`); - const shellFrame = readText(`${shell.path}/src/routes/shell-frame.tsx`); - const routePage = readText(`${shell.path}/src/routes/[lang]/page.tsx`); - assert( - modernConfig.includes(`const appId = '${shell.id}';`), - `${shell.id} modern.config.ts appId is incorrect`, - ); - assert( - modernConfig.includes( - `const port = Number(getBuildConfigEnvironment('${shell.portEnv}') ?? ${shell.port});`, - ), - `${shell.id} modern.config.ts port is incorrect`, - ); - assert( - modernConfig.includes(`uniqueName('${shell.mfName}')`), - `${shell.id} modern.config.ts Rspack identity is incorrect`, - ); - assert( - moduleFederationConfig.includes(`name: '${shell.mfName}'`), - `${shell.id} Module Federation container name is incorrect`, - ); - assert( - new RegExp(`appId:\\s*['"]${shell.id}['"]`, 'u').test(runtimeConfig), - `${shell.id} runtime boundary metadata must identify its own shell`, - ); - assert( - routePage.includes('ShellFrame'), - `${shell.id} route page must use its own shell composition host`, - ); - if (tailwindEnabled) { - assert( - styles.includes(`prefix(${shell.tailwindPrefix})`), - `${shell.id} styles must use its shell-specific Tailwind prefix`, - ); - } - assert( - shellFrame.includes(`${shell.tailwindPrefix}:`), - `${shell.id} shell-frame must use its shell-specific Tailwind prefix`, - ); - if ((shell.verticalRefs ?? []).length > 0) { - assert( - shell.degradedState?.required ?? false, - `${shell.id} degraded-state contract must be required for remote consumption`, - ); - } -}; - -const assertAdditionalShellOwnerAndDeliveryUnit = ( - shell: (typeof expectedAdditionalShells)[number], - configShell: CompactShell, -): boolean => { - const { owner } = configShell; - assertObject( - owner, - `${compactConfigPath} shells.${shell.id}.owner`, - 'record exactly one owner for every configured Delivery Unit', - ); - if (owner === undefined) { - return false; - } - assert( - ['team', 'agent', 'agent-team'].includes(owner.kind) && - isString(owner.id) && - owner.id.length > 0, - `${compactConfigPath} shells.${shell.id}.owner must identify one accountable owner`, - ); - assertSameJson( - owner, - shell.owner, - `${compactConfigPath} shells.${shell.id}.owner`, - 'restore the generated additional-shell owner attribution', - ); - assertObject( - configShell.deliveryUnit, - `${compactConfigPath} shells.${shell.id}.deliveryUnit`, - 'restore the generated additional-shell Delivery Unit identity', - ); - if (configShell.deliveryUnit === undefined) { - return false; - } - assert( - isString(configShell.deliveryUnit.unitId) && - configShell.deliveryUnit.unitId.length > 0 && - isString(configShell.deliveryUnit.buildMarker) && - configShell.deliveryUnit.buildMarker.length > 0, - `${compactConfigPath} shells.${shell.id}.deliveryUnit must carry unitId and buildMarker`, - ); - return true; -}; -const assertAdditionalShellPackage = (shell: (typeof expectedAdditionalShells)[number]): void => { - const packagePath = `${shell.path}/package.json`; - const packageJson = readJson(PackageJsonSchema, packagePath); - assert(packageJson.name === shell.packageName, `${shell.id} package name is incorrect`); - assert( - packageJson.modernjs?.appId === shell.id, - `${shell.id} package modernjs.appId is incorrect`, - ); - assert(packageJson.modernjs?.role === 'shell', `${shell.id} package modernjs.role must be shell`); - assert( - packageJson.scripts?.[SHARED_VALIDATOR_STRING_060] === SHARED_VALIDATOR_STRING_144, - `${shell.id} must expose cloudflare:deploy`, - ); - assert( - packageJson.scripts?.[SHARED_VALIDATOR_STRING_061]?.includes(`--app ${shell.id}`) ?? false, - `${shell.id} must expose cloudflare:proof`, - ); -}; -const assertAdditionalShellContract = ( - shell: (typeof expectedAdditionalShells)[number], - configuredShellById: ReadonlyMap, -): void => { - const configShell = configuredShellById.get(shell.id); - assertObject( - configShell, - `${compactConfigPath} shells.${shell.id}`, - 'restore the generated additional-shell config record', - ); - if (configShell === undefined) { - return; - } - assertSameJson( - { - deliveryUnit: configShell.deliveryUnit, - id: configShell.id, - kind: configShell.kind, - mfName: configShell.mfName, - moduleFederation: configShell.moduleFederation, - name: configShell.name, - owner: configShell.owner, - package: configShell.package, - path: configShell.path, - port: configShell.port, - portEnv: configShell.portEnv, - verticalRefs: configShell.verticalRefs, - }, - { - deliveryUnit: shell.deliveryUnit, - id: shell.id, - kind: 'shell', - mfName: shell.mfName, - moduleFederation: shell.moduleFederation, - name: shell.id.replace(/^shell-/u, ''), - owner: shell.owner, - package: shell.packageName, - path: shell.path, - port: shell.port, - portEnv: shell.portEnv, - verticalRefs: shell.verticalRefs, - }, - `${compactConfigPath} shells.${shell.id}`, - 'restore the complete additional-shell config record', - ); - assert( - !Object.hasOwn(overlay.ports ?? {}, shell.id), - `${shell.id} port must stay in config.shells, not the development overlay`, - ); - if (!assertAdditionalShellOwnerAndDeliveryUnit(shell, configShell)) { - return; - } - - assertAdditionalShellPackage(shell); - const buildArtifact = readJson( - BuildArtifactSchema, - `${shell.path}/shared/ultramodern-build.json`, - ); - const buildSource = readText(`${shell.path}/shared/ultramodern-build.ts`); - assert( - buildArtifact.deliveryUnit?.appId === shell.id, - `${shell.id} build artifact appId is incorrect`, - ); - assert( - buildArtifact.deliveryUnit?.buildMarker === shell.deliveryUnit?.buildMarker, - `${shell.id} build marker is not participating in the build artifact`, - ); - assertSameJson( - deliveryUnitBlock(configShell.deliveryUnit), - deliveryUnitBlock(shell.deliveryUnit), - `${compactConfigPath} shells.${shell.id}.deliveryUnit`, - deliveryUnitIdentityFixArea, - ); - assertSameJson( - deliveryUnitBlock(buildArtifact.deliveryUnit), - deliveryUnitBlock(shell.deliveryUnit), - `${shell.path}/shared/ultramodern-build.json deliveryUnit`, - deliveryUnitIdentityFixArea, - ); - assert( - buildSource.includes( - 'export const ultramodernDeliveryUnit = ultramodernBuildArtifact.deliveryUnit;', - ), - `${shell.path}/shared/ultramodern-build.ts must expose the shell delivery-unit identity`, - ); - assertBuildFacadeExport( - buildSource, - SHARED_VALIDATOR_STRING_152, - 'ultramodernBuildArtifact.surfaces.ui', - `${shell.path}/shared/ultramodern-build.ts ultramodernUiMarker`, - ); - assert( - shell.degradedState?.appId === shell.id && shell.degradedState?.status === 'degraded', - `${shell.id} degraded-state contract must identify its own shell`, - ); - - assertAdditionalShellSources(shell); -}; - -const configuredShellPorts = () => { +const assertConfiguredDevelopmentPorts = (): void => { const primaryShellConfig = findById( ultramodernConfig.topology?.apps, SHARED_VALIDATOR_STRING_131, @@ -6371,10 +5990,6 @@ const configuredShellPorts = () => { ...(Object.hasOwn(overlayPorts, SHARED_VALIDATOR_STRING_131) ? [] : [{ id: SHARED_VALIDATOR_STRING_131, port: primaryShellConfig?.port }]), - ...expectedAdditionalShells.map((shell) => ({ - id: shell.id, - port: shell.port, - })), ]; const portsByValue = new Map(); for (const { id, port } of configuredPorts) { @@ -6390,110 +6005,12 @@ const configuredShellPorts = () => { ); portsByValue.set(port, id); } - - return { portsByValue, primaryShellConfig }; -}; -const assertAdditionalShellZeropsServices = (): void => { - // Zerops artifacts exist whenever the workspace has delivery units at all - // (ui-only and horizontal-remote units deploy too); a shell-only workspace - // must not carry one. - if (hasDeliveryUnits) { - assertExists(SHARED_VALIDATOR_STRING_174); - const zeropsYaml = readText(SHARED_VALIDATOR_STRING_174); - assert( - zeropsYaml.includes(`setup: ${quoteYamlString(SHARED_VALIDATOR_STRING_132)}`), - 'shell-super-app must have a Zerops service', - ); - for (const shell of expectedAdditionalShells) { - const runtimePath = `.zerops/runtime/${shell.id}`; - assert( - zeropsYaml.includes(`setup: ${quoteYamlString(shell.id)}`), - `${shell.id} must have a Zerops service`, - ); - assert( - zeropsYaml.includes(`start: cd ${quoteShellValue(runtimePath)} && npm run serve`), - `${shell.id} Zerops service start command is missing`, - ); - assert( - zeropsYaml.includes(` ${shell.portEnv}: ${quoteYamlString(String(shell.port))}`), - `${shell.id} Zerops service port environment is missing`, - ); - } - } else { - assertNotExists(SHARED_VALIDATOR_STRING_174); - } -}; -const assertAdditionalShellCohort = () => { - const { portsByValue, primaryShellConfig } = configuredShellPorts(); - - if (expectedAdditionalShellIds.length === 0) { - assert( - ultramodernConfig.shells === undefined, - 'Single-shell workspace must not declare config.shells', - ); - return; - } - - assertSameIdCohort( - ultramodernConfig.shells, - expectedAdditionalShellIds, - `${compactConfigPath} shells`, - 'restore every configured additional shell', - ); - const configuredShellRecords = ultramodernConfig.shells ?? []; - const configuredShellById = new Map(configuredShellRecords.map((shell) => [shell.id, shell])); - assertUniqueStrings( - [ - primaryShellConfig?.moduleFederation?.name, - ...expectedAdditionalShells.map((shell) => shell.mfName), - ].flatMap((name) => (name === undefined ? [] : [name])), - 'configured shell Module Federation identities', - ); - assertUniqueStrings( - expectedAdditionalShells.flatMap((shell) => { - const marker = shell.deliveryUnit?.buildMarker; - return marker === undefined ? [] : [marker]; - }), - 'configured shell build markers', - ); - - const configuredOrigins = sortedCopy([...portsByValue.keys()], (left, right) => left - right).map( - (port) => `http://localhost:${port}`, - ); - - for (const shell of expectedAdditionalShells) { - assertAdditionalShellContract(shell, configuredShellById); - } - - for (const appPath of [ - SHARED_VALIDATOR_STRING_047, - ...expectedAdditionalShells.map((shell) => shell.path), - ...fullStackVerticals.map((vertical) => vertical.path), - ]) { - const modernConfig = readText(`${appPath}/modern.config.ts`); - for (const origin of configuredOrigins) { - assert( - modernConfig.includes(`'${origin}'`), - `${appPath} MF dev CORS must allow configured origin ${origin}`, - ); - } - assert( - modernConfig.includes('credentials: false'), - `${appPath} MF asset CORS must disable credentials`, - ); - assert( - !modernConfig.includes('credentials: true'), - `${appPath} MF asset CORS must not enable credentials`, - ); - assert( - !/origin:\s*(?:true|\*|['"]\*['"])/u.test(modernConfig), - `${appPath} MF dev CORS must not reflect arbitrary origins`, - ); - } - - assertAdditionalShellZeropsServices(); }; -assertAdditionalShellCohort(); +assertConfiguredDevelopmentPorts(); +assert( + ultramodernConfig.shells === undefined, + 'Single-shell workspace must not declare config.shells', +); assert( rootPackage.devDependencies?.[SHARED_VALIDATOR_STRING_024] === expectedModernPackageSpecifier(SHARED_VALIDATOR_STRING_024), diff --git a/app/verticals/party-registry/shared/ultramodern-build.ts b/app/verticals/party-registry/shared/ultramodern-build.ts index 3771777a6..cbf1703e3 100644 --- a/app/verticals/party-registry/shared/ultramodern-build.ts +++ b/app/verticals/party-registry/shared/ultramodern-build.ts @@ -71,9 +71,6 @@ const ultramodernBuildArtifact = withUltramodernBuildIdentity( ultramodernSourceRevision, ); -export { ultramodernBuildArtifact }; - export const ultramodernDeliveryUnit = ultramodernBuildArtifact.deliveryUnit; -export const ultramodernVerticalIdentity = ultramodernDeliveryUnit; export const ultramodernUiMarker = ultramodernBuildArtifact.surfaces.ui; export const ultramodernApiMarker = ultramodernBuildArtifact.surfaces.api; From ec5492e77ad926bbff20d376d73b1fd02188f0e9 Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Tue, 8 Sep 2026 13:19:16 +0200 Subject: [PATCH 10/13] test: accept zero-diagnostic Oxlint summaries in CI Preserve exact clean-success and nonzero violation/crash checks across formatter environments; add positive and negative summary controls. Co-Authored-By: Claude Fable 5.1 --- app/scripts/tests/code-tools-i18n.test.mts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/app/scripts/tests/code-tools-i18n.test.mts b/app/scripts/tests/code-tools-i18n.test.mts index a0b0d0183..c3c92fd4e 100644 --- a/app/scripts/tests/code-tools-i18n.test.mts +++ b/app/scripts/tests/code-tools-i18n.test.mts @@ -13,6 +13,8 @@ const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const codeToolsPackage = '@modern-js/code-tools'; const malformedPluginCase = 'malformed plugin'; +const cleanOutput = + /^(?:Found 0 warnings and 0 errors\.\r?\nFinished in \d+(?:\.\d+)?(?:ms|s) on [1-9]\d* files? with \d+ rules using [1-9]\d* threads?\.\r?\n)?$/u; const packageRoot = fileURLToPath(new URL('../..', import.meta.resolve(codeToolsPackage))); const packageRequire = createRequire(import.meta.resolve(codeToolsPackage)); @@ -106,7 +108,7 @@ printOxlintOutput(result); process.exitCode = result.exitCode;`; const output = result.stdout + result.stderr; if (fixture.diagnostic === null) { assert.equal(result.status, 0, output); - assert.equal(output, ''); + assert.match(output, cleanOutput); } else { assert.equal(result.status, 1, output); assert.match(output, fixture.diagnostic); @@ -130,3 +132,19 @@ printOxlintOutput(result); process.exitCode = result.exitCode;`; ); } } + +void test('clean i18n output accepts only silence or a zero-diagnostic summary', () => { + const summary = + 'Found 0 warnings and 0 errors.\nFinished in 423ms on 2 files with 98 rules using 4 threads.\n'; + assert.match('', cleanOutput); + assert.match(summary, cleanOutput); + for (const output of [ + summary.replace('0 errors', '1 error'), + summary.replace('0 warnings', '1 warning'), + summary.replace('2 files', '0 files'), + `${summary}Error running JS plugin\n`, + `fixture.tsx:1:1: unexpected diagnostic\n${summary}`, + ]) { + assert.doesNotMatch(output, cleanOutput); + } +}); From b6e3db1b43380d093dfab9b56e4b876274568819 Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Tue, 8 Sep 2026 13:42:53 +0200 Subject: [PATCH 11/13] refactor: share typed scaffold error normalization Keep concrete owner failures while centralizing optional-cause projection and synchronous failure normalization. Preserve identity and strict fallback behavior with regression controls. Co-Authored-By: Claude Fable 5.1 --- .../scaffold.mts | 22 ++--- .../scaffolding/module-contract/scaffold.mts | 22 ++--- .../search-provider-access/scaffold.mts | 22 ++--- app/scripts/scaffolding/shared.mts | 18 ++++ .../tests/scaffold-generators.test.mts | 99 +++++++++++++++++++ 5 files changed, 138 insertions(+), 45 deletions(-) diff --git a/app/scripts/scaffolding/microvertical-action-boundary/scaffold.mts b/app/scripts/scaffolding/microvertical-action-boundary/scaffold.mts index c9fbefd2c..9f7be6a15 100644 --- a/app/scripts/scaffolding/microvertical-action-boundary/scaffold.mts +++ b/app/scripts/scaffolding/microvertical-action-boundary/scaffold.mts @@ -1,10 +1,11 @@ -import { Array as EffectArray, Effect, FileSystem, Option, Predicate, Schema } from 'effect'; +import { Array as EffectArray, Effect, FileSystem, Option, Schema } from 'effect'; import { createMutationEffect, discoverOntosModuleEffect, ensureUniqueMutationPaths, resolveContainedPath, withExactDependencies, + createScaffoldErrorTools, } from '../shared.mts'; import { createCodesmithGenerator } from '../generator-adapter.mts'; import type { @@ -28,20 +29,11 @@ class ActionBoundaryScaffoldError extends Schema.TaggedError - new ActionBoundaryScaffoldError(cause === undefined ? { message } : { cause, message }); - -const trySync = (operation: () => Value) => - Effect.try({ - catch: (cause) => - Schema.is(ActionBoundaryScaffoldError)(cause) - ? cause - : scaffoldError( - Predicate.isError(cause) ? cause.message : 'action boundary update failed', - cause, - ), - try: operation, - }); +const { scaffoldError, trySync } = createScaffoldErrorTools( + ActionBoundaryScaffoldError, + Schema.is(ActionBoundaryScaffoldError), + 'action boundary update failed', +); const createOrAcceptOwnedMutation = ( filePath: string, diff --git a/app/scripts/scaffolding/module-contract/scaffold.mts b/app/scripts/scaffolding/module-contract/scaffold.mts index 650133d17..d952dd830 100644 --- a/app/scripts/scaffolding/module-contract/scaffold.mts +++ b/app/scripts/scaffolding/module-contract/scaffold.mts @@ -1,5 +1,5 @@ import { topLevelSeparators } from '../../boundary-source-structure.mts'; -import { Array as EffectArray, Effect, FileSystem, Option, Predicate, Schema } from 'effect'; +import { Array as EffectArray, Effect, FileSystem, Option, Schema } from 'effect'; import { createCodesmithGenerator } from '../generator-adapter.mts'; import { MODULE_CONTRACT_GENERATOR_HEADER, @@ -76,6 +76,7 @@ import { toCamelCase, toTitle, updateMutation, + createScaffoldErrorTools, } from '../shared.mts'; import type { JsonValue, @@ -248,20 +249,11 @@ class ModuleContractScaffoldError extends Schema.TaggedError - new ModuleContractScaffoldError(cause === undefined ? { message } : { cause, message }); - -const trySync = (operation: () => Value) => - Effect.try({ - catch: (cause) => - Schema.is(ModuleContractScaffoldError)(cause) - ? cause - : scaffoldError( - Predicate.isError(cause) ? cause.message : 'module contract update failed', - cause, - ), - try: operation, - }); +const { scaffoldError, trySync } = createScaffoldErrorTools( + ModuleContractScaffoldError, + Schema.is(ModuleContractScaffoldError), + 'module contract update failed', +); const readModuleOwner = ( fileSystem: FileSystem.FileSystem, diff --git a/app/scripts/scaffolding/search-provider-access/scaffold.mts b/app/scripts/scaffolding/search-provider-access/scaffold.mts index e52d6de6c..891862c3b 100644 --- a/app/scripts/scaffolding/search-provider-access/scaffold.mts +++ b/app/scripts/scaffolding/search-provider-access/scaffold.mts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Predicate, Schema } from 'effect'; +import { Effect, FileSystem, Schema } from 'effect'; import { discoverOntosModuleEffect, ensureUniqueMutationPaths, @@ -9,6 +9,7 @@ import { toCamelCase, toPascalCase, updateMutation, + createScaffoldErrorTools, } from '../shared.mts'; import type { Mutation, @@ -29,20 +30,11 @@ class SearchProviderAccessScaffoldError extends Schema.TaggedError - new SearchProviderAccessScaffoldError(cause === undefined ? { message } : { cause, message }); - -const trySync = (operation: () => Value) => - Effect.try({ - catch: (cause) => - Schema.is(SearchProviderAccessScaffoldError)(cause) - ? cause - : scaffoldError( - Predicate.isError(cause) ? cause.message : 'search provider access update failed', - cause, - ), - try: operation, - }); +const { scaffoldError, trySync } = createScaffoldErrorTools( + SearchProviderAccessScaffoldError, + Schema.is(SearchProviderAccessScaffoldError), + 'search provider access update failed', +); const replaceOwnedLine = ( content: string, diff --git a/app/scripts/scaffolding/shared.mts b/app/scripts/scaffolding/shared.mts index 3ec854bad..68592d3b8 100644 --- a/app/scripts/scaffolding/shared.mts +++ b/app/scripts/scaffolding/shared.mts @@ -104,6 +104,24 @@ export const MODULE_REGISTRATION_SEARCH_SLOT_END = '// ( + ErrorClass: new (fields: { readonly cause?: unknown; readonly message: string }) => Failure, + isOwnError: Predicate.Refinement, + fallbackMessage: string, +) => { + const scaffoldError = (message: string, cause?: unknown): Failure => + new ErrorClass(cause === undefined ? { message } : { cause, message }); + const trySync = (operation: () => Value): Effect.Effect => + Effect.try({ + catch: (cause) => + isOwnError(cause) + ? cause + : scaffoldError(Predicate.isError(cause) ? cause.message : fallbackMessage, cause), + try: operation, + }); + return { scaffoldError, trySync }; +}; + interface VerticalActionScaffoldConfig { readonly action: string; readonly authorization: 'action_execution'; diff --git a/app/scripts/scaffolding/tests/scaffold-generators.test.mts b/app/scripts/scaffolding/tests/scaffold-generators.test.mts index b9df1e017..a44c8d9a2 100644 --- a/app/scripts/scaffolding/tests/scaffold-generators.test.mts +++ b/app/scripts/scaffolding/tests/scaffold-generators.test.mts @@ -52,6 +52,8 @@ import { GOVERNED_HTTP_HANDLER_SUPPORT_IMPORT_SLOT_END, GOVERNED_HTTP_HANDLER_SUPPORT_LAYER_SLOT_START, GOVERNED_HTTP_HANDLER_SUPPORT_LAYER_SLOT_END, + ScaffoldFailure, + createScaffoldErrorTools, insertSortedSlot, readGeneratedSlotEntries, } from '../shared.mts'; @@ -79,6 +81,103 @@ const test = (name: string, handler: () => void | Promise): void => { void nodeTest(name, handler); }; +class FirstScaffoldTestError extends Schema.TaggedError()( + 'FirstScaffoldTestError', + { cause: Schema.optionalKey(Schema.Unknown), message: Schema.String }, +) {} + +const firstScaffoldErrors = createScaffoldErrorTools( + FirstScaffoldTestError, + Schema.is(FirstScaffoldTestError), + 'first update failed', +); +const secondScaffoldErrors = createScaffoldErrorTools( + ScaffoldFailure, + Schema.is(ScaffoldFailure), + 'second update failed', +); + +test('scaffold error tools preserve success and own failure identity', async () => { + const value = { unchanged: true }; + assert.equal(await runEffectTestPromise(firstScaffoldErrors.trySync(() => value)), value); + const own = firstScaffoldErrors.scaffoldError('own failure'); + const failure = await runEffectTestPromise( + firstScaffoldErrors + .trySync(() => { + throw own; + }) + .pipe(Effect.flip), + ); + assert.equal(failure, own); +}); + +test('scaffold error tools omit undefined causes and retain defined causes', () => { + assert.equal(Object.hasOwn(firstScaffoldErrors.scaffoldError('absent'), 'cause'), false); + const absentCause = firstScaffoldErrors.scaffoldError('absent').cause; + assert.equal( + Object.hasOwn(firstScaffoldErrors.scaffoldError('undefined', absentCause), 'cause'), + false, + ); + for (const cause of [null, false, 0, '', { detail: 'retained' }]) { + const failure = firstScaffoldErrors.scaffoldError('defined', cause); + assert.equal(Object.hasOwn(failure, 'cause'), true); + assert.equal(failure.cause, cause); + } +}); + +test('scaffold error tools normalize foreign errors without accepting another owner', async () => { + const foreign = secondScaffoldErrors.scaffoldError('foreign owner'); + assert.equal(Schema.is(FirstScaffoldTestError)(foreign), false); + assert.equal(Schema.is(ScaffoldFailure)(foreign), true); + const emptyMessageError = new Error('initial'); + emptyMessageError.message = ''; + await runEffectTestPromise( + Effect.gen(function* foreignScaffoldErrors() { + for (const cause of [new Error('foreign error'), emptyMessageError, foreign]) { + const failure = yield* firstScaffoldErrors + .trySync(() => { + throw cause; + }) + .pipe(Effect.flip); + assert.notEqual(failure, cause); + assert.equal(Schema.is(FirstScaffoldTestError)(failure), true); + assert.equal(Schema.is(ScaffoldFailure)(failure), false); + assert.equal(failure.message, cause.message); + assert.equal(failure.cause, cause); + } + }), + ); +}); + +for (const [index, cause] of [ + undefined, + null, + 'thrown string', + { message: 'not an Error' }, +].entries()) { + test(`scaffold error tools use owner fallback for non-error ${index}`, async () => { + const operation = () => { + const iterator = (function* thrownValue() { + yield cause; + })(); + iterator.next(); + return iterator.throw(cause); + }; + const first = await runEffectTestPromise( + firstScaffoldErrors.trySync(operation).pipe(Effect.flip), + ); + const second = await runEffectTestPromise( + secondScaffoldErrors.trySync(operation).pipe(Effect.flip), + ); + assert.equal(first.message, 'first update failed'); + assert.equal(second.message, 'second update failed'); + for (const failure of [first, second]) { + assert.equal(failure.cause, cause); + assert.equal(Object.hasOwn(failure, 'cause'), cause !== undefined); + } + }); +} + const isGeneratedPrincipalError = (tag: GeneratedPrincipalErrorTag) => Schema.is(Schema.Struct({ _tag: Schema.Literal(tag) })); From 9d76c481fff2da073e42b7e1b6a3a097499e2598 Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Tue, 8 Sep 2026 13:45:36 +0200 Subject: [PATCH 12/13] refactor: remove redundant outbox error schema witnesses Use concrete tagged errors under the one-class-per-file rule. Preserve public contracts and fix persistence error construction's missing local runtime binding. Cover serialization, cross-schema rejection, yieldability, private causes and sanitization. Co-Authored-By: Claude Fable 5.1 --- .../core-runtime/src/outbox/errors.ts | 84 +--------- .../src/outbox/outbox-claim-lost-error.ts | 6 + .../outbox/outbox-handler-execution-error.ts | 6 + .../src/outbox/outbox-payload-decode-error.ts | 6 + .../src/outbox/outbox-poller-config-error.ts | 6 + .../outbox/outbox-worker-descriptor-error.ts | 6 + .../tests/unit/outbox-errors.test.ts | 151 ++++++++++++++++++ 7 files changed, 189 insertions(+), 76 deletions(-) create mode 100644 app/packages/core-runtime/src/outbox/outbox-claim-lost-error.ts create mode 100644 app/packages/core-runtime/src/outbox/outbox-handler-execution-error.ts create mode 100644 app/packages/core-runtime/src/outbox/outbox-payload-decode-error.ts create mode 100644 app/packages/core-runtime/src/outbox/outbox-poller-config-error.ts create mode 100644 app/packages/core-runtime/src/outbox/outbox-worker-descriptor-error.ts create mode 100644 app/packages/core-runtime/tests/unit/outbox-errors.test.ts diff --git a/app/packages/core-runtime/src/outbox/errors.ts b/app/packages/core-runtime/src/outbox/errors.ts index 85ee42172..e06e25da1 100644 --- a/app/packages/core-runtime/src/outbox/errors.ts +++ b/app/packages/core-runtime/src/outbox/errors.ts @@ -1,83 +1,15 @@ import { Schema } from 'effect'; -import type { Cause } from 'effect'; -const reason = { reason: Schema.String } as const; +export { OutboxClaimLostError } from './outbox-claim-lost-error.ts'; +export { OutboxHandlerExecutionError } from './outbox-handler-execution-error.ts'; +export { OutboxPayloadDecodeError } from './outbox-payload-decode-error.ts'; +export { OutboxPollerConfigError } from './outbox-poller-config-error.ts'; +export { OutboxWorkerDescriptorError } from './outbox-worker-descriptor-error.ts'; -const OutboxWorkerDescriptorErrorContract = Schema.TaggedStruct('OutboxWorkerDescriptorError', { - code: Schema.Literal('outbox_worker_descriptor_invalid'), - ...reason, -}); -type OutboxWorkerDescriptorErrorSelf = typeof OutboxWorkerDescriptorErrorContract.Type & - Cause.YieldableError; -const OutboxWorkerDescriptorErrorValue = Schema.TaggedError()( - 'OutboxWorkerDescriptorError', - { code: Schema.Literal('outbox_worker_descriptor_invalid'), ...reason }, -); -export type OutboxWorkerDescriptorError = InstanceType; -export { OutboxWorkerDescriptorErrorValue as OutboxWorkerDescriptorError }; - -const OutboxPayloadDecodeErrorContract = Schema.TaggedStruct('OutboxPayloadDecodeError', { - code: Schema.Literal('outbox_payload_invalid'), - ...reason, -}); -type OutboxPayloadDecodeErrorSelf = typeof OutboxPayloadDecodeErrorContract.Type & - Cause.YieldableError; -const OutboxPayloadDecodeErrorValue = Schema.TaggedError()( - 'OutboxPayloadDecodeError', - { code: Schema.Literal('outbox_payload_invalid'), ...reason }, -); -export type OutboxPayloadDecodeError = InstanceType; -export { OutboxPayloadDecodeErrorValue as OutboxPayloadDecodeError }; - -const OutboxPersistenceErrorContract = Schema.TaggedStruct('OutboxPersistenceError', { - code: Schema.Literal('outbox_persistence_failed'), - ...reason, -}); -type OutboxPersistenceErrorSelf = typeof OutboxPersistenceErrorContract.Type & Cause.YieldableError; -const OutboxPersistenceErrorValue = Schema.TaggedError()( +export class OutboxPersistenceError extends Schema.TaggedError()( 'OutboxPersistenceError', - { code: Schema.Literal('outbox_persistence_failed'), ...reason }, -); -export type OutboxPersistenceError = InstanceType; -export { OutboxPersistenceErrorValue as OutboxPersistenceError }; - -const OutboxClaimLostErrorContract = Schema.TaggedStruct('OutboxClaimLostError', { - code: Schema.Literal('outbox_claim_lost'), - ...reason, -}); -type OutboxClaimLostErrorSelf = typeof OutboxClaimLostErrorContract.Type & Cause.YieldableError; -const OutboxClaimLostErrorValue = Schema.TaggedError()( - 'OutboxClaimLostError', - { code: Schema.Literal('outbox_claim_lost'), ...reason }, -); -export type OutboxClaimLostError = InstanceType; -export { OutboxClaimLostErrorValue as OutboxClaimLostError }; - -const OutboxHandlerExecutionErrorContract = Schema.TaggedStruct('OutboxHandlerExecutionError', { - code: Schema.Literal('outbox_handler_execution_failed'), - ...reason, -}); -type OutboxHandlerExecutionErrorSelf = typeof OutboxHandlerExecutionErrorContract.Type & - Cause.YieldableError; -const OutboxHandlerExecutionErrorValue = Schema.TaggedError()( - 'OutboxHandlerExecutionError', - { code: Schema.Literal('outbox_handler_execution_failed'), ...reason }, -); -export type OutboxHandlerExecutionError = InstanceType; -export { OutboxHandlerExecutionErrorValue as OutboxHandlerExecutionError }; - -const OutboxPollerConfigErrorContract = Schema.TaggedStruct('OutboxPollerConfigError', { - code: Schema.Literal('outbox_poller_config_invalid'), - ...reason, -}); -type OutboxPollerConfigErrorSelf = typeof OutboxPollerConfigErrorContract.Type & - Cause.YieldableError; -const OutboxPollerConfigErrorValue = Schema.TaggedError()( - 'OutboxPollerConfigError', - { code: Schema.Literal('outbox_poller_config_invalid'), ...reason }, -); -export type OutboxPollerConfigError = InstanceType; -export { OutboxPollerConfigErrorValue as OutboxPollerConfigError }; + { code: Schema.Literal('outbox_persistence_failed'), reason: Schema.String }, +) {} const PERSISTENCE_CAUSE_PROPERTY = 'ontosOutboxPersistenceCause'; diff --git a/app/packages/core-runtime/src/outbox/outbox-claim-lost-error.ts b/app/packages/core-runtime/src/outbox/outbox-claim-lost-error.ts new file mode 100644 index 000000000..76cabca35 --- /dev/null +++ b/app/packages/core-runtime/src/outbox/outbox-claim-lost-error.ts @@ -0,0 +1,6 @@ +import { Schema } from 'effect'; + +export class OutboxClaimLostError extends Schema.TaggedError()( + 'OutboxClaimLostError', + { code: Schema.Literal('outbox_claim_lost'), reason: Schema.String }, +) {} diff --git a/app/packages/core-runtime/src/outbox/outbox-handler-execution-error.ts b/app/packages/core-runtime/src/outbox/outbox-handler-execution-error.ts new file mode 100644 index 000000000..4fa5e7c56 --- /dev/null +++ b/app/packages/core-runtime/src/outbox/outbox-handler-execution-error.ts @@ -0,0 +1,6 @@ +import { Schema } from 'effect'; + +export class OutboxHandlerExecutionError extends Schema.TaggedError()( + 'OutboxHandlerExecutionError', + { code: Schema.Literal('outbox_handler_execution_failed'), reason: Schema.String }, +) {} diff --git a/app/packages/core-runtime/src/outbox/outbox-payload-decode-error.ts b/app/packages/core-runtime/src/outbox/outbox-payload-decode-error.ts new file mode 100644 index 000000000..2b4b3cc41 --- /dev/null +++ b/app/packages/core-runtime/src/outbox/outbox-payload-decode-error.ts @@ -0,0 +1,6 @@ +import { Schema } from 'effect'; + +export class OutboxPayloadDecodeError extends Schema.TaggedError()( + 'OutboxPayloadDecodeError', + { code: Schema.Literal('outbox_payload_invalid'), reason: Schema.String }, +) {} diff --git a/app/packages/core-runtime/src/outbox/outbox-poller-config-error.ts b/app/packages/core-runtime/src/outbox/outbox-poller-config-error.ts new file mode 100644 index 000000000..612689660 --- /dev/null +++ b/app/packages/core-runtime/src/outbox/outbox-poller-config-error.ts @@ -0,0 +1,6 @@ +import { Schema } from 'effect'; + +export class OutboxPollerConfigError extends Schema.TaggedError()( + 'OutboxPollerConfigError', + { code: Schema.Literal('outbox_poller_config_invalid'), reason: Schema.String }, +) {} diff --git a/app/packages/core-runtime/src/outbox/outbox-worker-descriptor-error.ts b/app/packages/core-runtime/src/outbox/outbox-worker-descriptor-error.ts new file mode 100644 index 000000000..44b5d6f57 --- /dev/null +++ b/app/packages/core-runtime/src/outbox/outbox-worker-descriptor-error.ts @@ -0,0 +1,6 @@ +import { Schema } from 'effect'; + +export class OutboxWorkerDescriptorError extends Schema.TaggedError()( + 'OutboxWorkerDescriptorError', + { code: Schema.Literal('outbox_worker_descriptor_invalid'), reason: Schema.String }, +) {} diff --git a/app/packages/core-runtime/tests/unit/outbox-errors.test.ts b/app/packages/core-runtime/tests/unit/outbox-errors.test.ts new file mode 100644 index 000000000..d38f6d482 --- /dev/null +++ b/app/packages/core-runtime/tests/unit/outbox-errors.test.ts @@ -0,0 +1,151 @@ +import { makeEffectTestCallback } from '@app/core-runtime/testing/effect-runtime'; +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Effect, Schema } from 'effect'; +import type { Cause } from 'effect'; +import { + OutboxClaimLostError, + OutboxHandlerExecutionError, + OutboxPayloadDecodeError, + OutboxPersistenceError, + OutboxPollerConfigError, + OutboxWorkerDescriptorError, + outboxPersistenceError, + sanitizeOutboxErrorMessage, +} from '../../src/outbox/errors.ts'; + +const errorSchemas = [ + OutboxClaimLostError, + OutboxHandlerExecutionError, + OutboxPayloadDecodeError, + OutboxPersistenceError, + OutboxPollerConfigError, + OutboxWorkerDescriptorError, +]; + +const checkErrorContract = ( + schema: Schema.Codec< + Failure, + { readonly _tag: string; readonly code: string; readonly reason: string } + >, + failure: Failure, + encoded: { readonly _tag: string; readonly code: string; readonly reason: string }, +): void => { + void test( + `${encoded._tag} preserves its schema and yieldable failure contract`, + makeEffectTestCallback( + Effect.gen(function* errorContract() { + assert.ok(Schema.is(schema)(failure)); + assert.deepEqual(yield* Schema.encodeEffect(schema)(failure), encoded); + const decoded = yield* Schema.decodeUnknownEffect(schema)(encoded); + assert.ok(Schema.is(schema)(decoded)); + assert.deepEqual(yield* Schema.encodeEffect(schema)(decoded), encoded); + for (const otherSchema of errorSchemas) { + assert.equal(Schema.is(otherSchema)(failure), Object.is(otherSchema, schema)); + assert.equal(Schema.is(otherSchema)(decoded), Object.is(otherSchema, schema)); + } + assert.throws(() => Schema.decodeUnknownSync(schema)({ ...encoded, _tag: 'WrongError' })); + assert.throws(() => Schema.decodeUnknownSync(schema)({ ...encoded, code: 'wrong_code' })); + assert.throws(() => Schema.decodeUnknownSync(schema)({ ...encoded, reason: 42 })); + assert.throws(() => + Schema.decodeUnknownSync(schema)({ _tag: encoded._tag, code: encoded.code }), + ); + const yielded = yield* Effect.flip( + Effect.gen(function* yieldFailure() { + assert.ok(Schema.is(schema)(failure)); + return yield* failure; + }), + ); + assert.equal(yielded, failure); + const decodedFailure = yield* Effect.flip( + Effect.gen(function* yieldDecodedFailure() { + assert.ok(Schema.is(schema)(decoded)); + return yield* decoded; + }), + ); + assert.equal(decodedFailure, decoded); + }), + ), + ); +}; + +checkErrorContract( + OutboxWorkerDescriptorError, + new OutboxWorkerDescriptorError({ code: 'outbox_worker_descriptor_invalid', reason: 'detail' }), + { + _tag: 'OutboxWorkerDescriptorError', + code: 'outbox_worker_descriptor_invalid', + reason: 'detail', + }, +); +checkErrorContract( + OutboxPayloadDecodeError, + new OutboxPayloadDecodeError({ code: 'outbox_payload_invalid', reason: 'detail' }), + { _tag: 'OutboxPayloadDecodeError', code: 'outbox_payload_invalid', reason: 'detail' }, +); +checkErrorContract( + OutboxPersistenceError, + new OutboxPersistenceError({ code: 'outbox_persistence_failed', reason: 'detail' }), + { _tag: 'OutboxPersistenceError', code: 'outbox_persistence_failed', reason: 'detail' }, +); +checkErrorContract( + OutboxClaimLostError, + new OutboxClaimLostError({ code: 'outbox_claim_lost', reason: 'detail' }), + { _tag: 'OutboxClaimLostError', code: 'outbox_claim_lost', reason: 'detail' }, +); +checkErrorContract( + OutboxHandlerExecutionError, + new OutboxHandlerExecutionError({ code: 'outbox_handler_execution_failed', reason: 'detail' }), + { + _tag: 'OutboxHandlerExecutionError', + code: 'outbox_handler_execution_failed', + reason: 'detail', + }, +); +checkErrorContract( + OutboxPollerConfigError, + new OutboxPollerConfigError({ code: 'outbox_poller_config_invalid', reason: 'detail' }), + { _tag: 'OutboxPollerConfigError', code: 'outbox_poller_config_invalid', reason: 'detail' }, +); + +void test('persistence errors keep the original cause private and immutable', () => { + const cause = { secret: 'database credential' }; + const failure = outboxPersistenceError(cause); + assert.ok(Schema.is(OutboxPersistenceError)(failure)); + assert.deepEqual(Object.getOwnPropertyDescriptor(failure, 'ontosOutboxPersistenceCause'), { + configurable: false, + enumerable: false, + value: cause, + writable: false, + }); + assert.equal( + Object.getOwnPropertyDescriptor(failure, 'ontosOutboxPersistenceCause')?.value, + cause, + ); + assert.equal(Object.keys(failure).includes('ontosOutboxPersistenceCause'), false); + assert.equal(JSON.stringify(failure).includes('database credential'), false); + const encoded = Schema.encodeSync(OutboxPersistenceError)(failure); + assert.deepEqual(encoded, { + _tag: 'OutboxPersistenceError', + code: 'outbox_persistence_failed', + reason: 'The Outbox Worker persistence operation failed', + }); + assert.equal( + Object.hasOwn( + Schema.decodeUnknownSync(OutboxPersistenceError)(encoded), + 'ontosOutboxPersistenceCause', + ), + false, + ); +}); + +void test('sanitizer normalizes control whitespace, trims, truncates and falls back', () => { + assert.equal( + sanitizeOutboxErrorMessage(' \r\nfirst\r\n\tsecond\t third \n'), + 'first second third', + ); + assert.equal(sanitizeOutboxErrorMessage(' plain detail '), 'plain detail'); + assert.equal(sanitizeOutboxErrorMessage(` ${'x'.repeat(501)} `), 'x'.repeat(500)); + assert.equal(sanitizeOutboxErrorMessage(' \r\n\t '), 'Outbox Worker processing failed'); + assert.equal(sanitizeOutboxErrorMessage(''), 'Outbox Worker processing failed'); +}); From 1bd5d89517878c7ec6b42a734053f00eae8df30c Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Tue, 8 Sep 2026 13:53:56 +0200 Subject: [PATCH 13/13] fix: align generated API checks with Cloudflare build output Preserve authored API enforcement while matching the live checker's generated dist-cloudflare exclusion. Prove both behaviors across all three published scaffold formats after real production builds. Co-Authored-By: Claude Fable 5.1 --- ...odern-js-create@3.8.2-ultramodern.12.patch | 18 +++++--- app/pnpm-lock.yaml | 6 +-- app/scripts/tests/api-only-tooling.test.mts | 41 +++++++++++++++++++ 3 files changed, 57 insertions(+), 8 deletions(-) diff --git a/app/patches/@bleedingdev__modern-js-create@3.8.2-ultramodern.12.patch b/app/patches/@bleedingdev__modern-js-create@3.8.2-ultramodern.12.patch index 3387ce2c5..631214bcb 100644 --- a/app/patches/@bleedingdev__modern-js-create@3.8.2-ultramodern.12.patch +++ b/app/patches/@bleedingdev__modern-js-create@3.8.2-ultramodern.12.patch @@ -3200,7 +3200,7 @@ index 04820fd4264e7df2d2fa5c803df181e3e1401a34..3eb4b2387a43ba4a163f93b3dc309df3 - }, + }), diff --git a/templates/workspace-scripts/check-ultramodern-api-boundaries.mts b/templates/workspace-scripts/check-ultramodern-api-boundaries.mts -index d606a2265cb438d32a612cfdd6ebb9561529d4f5..b31f281e7ab5bbea289fc2aae117fe39202023cf 100644 +index d606a2265cb438d32a612cfdd6ebb9561529d4f5..624bf77fa44f720c9ee394f138273b2461e906ba 100644 --- a/templates/workspace-scripts/check-ultramodern-api-boundaries.mts +++ b/templates/workspace-scripts/check-ultramodern-api-boundaries.mts @@ -1,6 +1,10 @@ @@ -3214,7 +3214,15 @@ index d606a2265cb438d32a612cfdd6ebb9561529d4f5..b31f281e7ab5bbea289fc2aae117fe39 const workspaceRoot = process.env.ULTRAMODERN_WORKSPACE_ROOT ?? process.cwd(); const failures = []; -@@ -182,11 +186,1184 @@ for (const file of textFiles) { +@@ -11,6 +15,7 @@ const ignoredDirectories = new Set([ + '.output', + 'coverage', + 'dist', ++ 'dist-cloudflare', + 'node_modules', + 'repos', + ]); +@@ -182,11 +187,1184 @@ for (const file of textFiles) { } const verticalDirectories = listDirectories('verticals'); @@ -4399,7 +4407,7 @@ index d606a2265cb438d32a612cfdd6ebb9561529d4f5..b31f281e7ab5bbea289fc2aae117fe39 function assertApiSurface(appPath) { const apiEntry = `${appPath}/api/index.ts`; const backendEffectExpose = `${appPath}/api/effect-api.ts`; -@@ -211,30 +1388,28 @@ function assertApiSurface(appPath) { +@@ -211,30 +1389,28 @@ function assertApiSurface(appPath) { if (exists(apiEntry)) { const entry = readText(apiEntry); @@ -4444,7 +4452,7 @@ index d606a2265cb438d32a612cfdd6ebb9561529d4f5..b31f281e7ab5bbea289fc2aae117fe39 } if (exists(backendEffectExpose)) { const backendExpose = readText(backendEffectExpose); -@@ -300,6 +1475,62 @@ function assertApiSurface(appPath) { +@@ -300,6 +1476,62 @@ function assertApiSurface(appPath) { /\bSchema\./u, 'must use Schema for request, response and error shapes.', ); @@ -4507,7 +4515,7 @@ index d606a2265cb438d32a612cfdd6ebb9561529d4f5..b31f281e7ab5bbea289fc2aae117fe39 } if (exists(modernConfig)) { -@@ -373,7 +1604,6 @@ if (exists('package.json')) { +@@ -373,7 +1605,6 @@ if (exists('package.json')) { } if (exists('topology/reference-topology.json')) { diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index f6ae91b2d..8942877cc 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -18,7 +18,7 @@ patchedDependencies: '@bleedingdev/modern-js-app-tools@3.8.2-ultramodern.12': 92106508bb75f01d2d27454563d5030569bcf89a49155b2cc91a2b9111360d4c '@bleedingdev/modern-js-builder@3.8.2-ultramodern.12': c5bdbbf89a17e39cb43c17f66a1904ed29a1462b09fd19aaf05c7301cbe4601c '@bleedingdev/modern-js-code-tools@3.8.2-ultramodern.12': 227ad960c0ce793da1dee90eeaba8edc310b14a58334be185c7cce71ae59a110 - '@bleedingdev/modern-js-create@3.8.2-ultramodern.12': c0b541c048a1d25b651f0d6bac2df86969944fc47d3e39f3af6ee7e83250faba + '@bleedingdev/modern-js-create@3.8.2-ultramodern.12': 2e3af68a4da1baca903853057cd804d197a52bfe2c35e864cf12bad79878670b '@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12': e96021e5be6d0ee85e6656b9110807d08e4b8dda295d17933606ff0472b61ed0 '@bleedingdev/modern-js-server-utils@3.8.2-ultramodern.12': 254be68a353f0c3a57aed91447a2de7b86afe26d1badeb9786945fd466fe760d '@module-federation/bridge-react@2.8.0': 54bfc79e097473222f83cbfa6d717792e3026bf16b5097c2ed91715b7da126be @@ -62,7 +62,7 @@ importers: version: 2.6.9(supports-color@10.2.2) '@modern-js/create': specifier: npm:@bleedingdev/modern-js-create@3.8.2-ultramodern.12 - version: '@bleedingdev/modern-js-create@3.8.2-ultramodern.12(patch_hash=c0b541c048a1d25b651f0d6bac2df86969944fc47d3e39f3af6ee7e83250faba)(oxlint@1.79.0(oxlint-tsgolint@7.0.2001))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)' + version: '@bleedingdev/modern-js-create@3.8.2-ultramodern.12(patch_hash=2e3af68a4da1baca903853057cd804d197a52bfe2c35e864cf12bad79878670b)(oxlint@1.79.0(oxlint-tsgolint@7.0.2001))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)' '@modern-js/plugin-bff': specifier: npm:@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12 version: '@bleedingdev/modern-js-plugin-bff@3.8.2-ultramodern.12(patch_hash=e96021e5be6d0ee85e6656b9110807d08e4b8dda295d17933606ff0472b61ed0)(3a233a8c5baa0ff66c13c170d030459d)' @@ -9476,7 +9476,7 @@ snapshots: - react - react-dom - '@bleedingdev/modern-js-create@3.8.2-ultramodern.12(patch_hash=c0b541c048a1d25b651f0d6bac2df86969944fc47d3e39f3af6ee7e83250faba)(oxlint@1.79.0(oxlint-tsgolint@7.0.2001))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)': + '@bleedingdev/modern-js-create@3.8.2-ultramodern.12(patch_hash=2e3af68a4da1baca903853057cd804d197a52bfe2c35e864cf12bad79878670b)(oxlint@1.79.0(oxlint-tsgolint@7.0.2001))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)': dependencies: '@modern-js/codesmith': 2.6.9(supports-color@10.2.2) '@modern-js/i18n-utils': '@bleedingdev/modern-js-i18n-utils@3.8.2-ultramodern.12(react-dom@19.2.8(react@19.2.8))(react@19.2.8)' diff --git a/app/scripts/tests/api-only-tooling.test.mts b/app/scripts/tests/api-only-tooling.test.mts index 73ef1eba1..b0afde6e0 100644 --- a/app/scripts/tests/api-only-tooling.test.mts +++ b/app/scripts/tests/api-only-tooling.test.mts @@ -2563,6 +2563,25 @@ void test('all published scaffold formats emit the executable AST baseline valid }, ], }); + await writeText( + checkoutWorkspace, + 'apps/shell-super-app/src/api/vertical-clients.ts', + 'export const verticalClients = {};\n', + ); + const invalidApiSource = `import { Schema } from 'effect'; +export const response = new Response('generated'); +export const responseSchema = Schema.Unknown; +`; + await writeText( + checkoutWorkspace, + 'verticals/shopping/dist-cloudflare/api/index.js', + invalidApiSource, + ); + await writeText( + checkoutWorkspace, + 'apps/shell-super-app/dist-cloudflare/api/index.js', + invalidApiSource, + ); assert.match( runNode([path.join(formatRoot, checker.relativePath)], { env: { ULTRAMODERN_WORKSPACE_ROOT: checkoutWorkspace }, @@ -2570,6 +2589,28 @@ void test('all published scaffold formats emit the executable AST baseline valid /UltraModern API boundary check passed/u, `${moduleFormat} checkout workspace`, ); + const authoredApiPath = 'verticals/shopping/api/invalid.ts'; + await writeText(checkoutWorkspace, authoredApiPath, invalidApiSource); + const authoredResult = spawnSync( + process.execPath, + [path.join(formatRoot, checker.relativePath)], + { + encoding: 'utf-8', + env: { ULTRAMODERN_WORKSPACE_ROOT: checkoutWorkspace }, + }, + ); + assert.equal(authoredResult.status, 1, moduleFormat); + assert.match( + authoredResult.stderr, + /verticals\/shopping\/api\/invalid\.ts: API modules must not hand-build Response objects/u, + moduleFormat, + ); + assert.match( + authoredResult.stderr, + /verticals\/shopping\/api\/invalid\.ts: API modules must use concrete request, response and error schemas/u, + moduleFormat, + ); + assert.doesNotMatch(authoredResult.stderr, /dist-cloudflare/u, moduleFormat); }), ); });