From 975d2b8c8870abca3c0b006d77a0392ee65a0585 Mon Sep 17 00:00:00 2001 From: Shivaji Byrapaneni Date: Tue, 1 Sep 2026 11:08:09 -0400 Subject: [PATCH 01/13] feat(vscode): add CalmHub connection, CURIE resolution, and Hub palette UX --- .github/ISSUE_TEMPLATE/Meeting.md | 2 +- .github/ISSUE_TEMPLATE/Office_Hours.md | 2 +- .../release-calm-models-maven-publish.yml | 15 +- .github/workflows/semgrep-ci.yml | 2 +- CONTRIBUTING.md | 4 + MAINTAINERS_GUIDELINES.md | 128 +++++ README.md | 1 + .../admin/panels/EntitlementsPanel.test.tsx | 4 +- .../diagram-section/DiagramSection.tsx | 22 +- .../src/hub/hooks/useDefaultLayout.test.tsx | 94 +++- calm-hub-ui/src/hub/hooks/useDefaultLayout.ts | 88 ++- calm-hub-ui/src/model/layout.test.ts | 107 ++++ calm-hub-ui/src/model/layout.ts | 54 +- .../reactflow/ArchitectureGraph.test.tsx | 13 +- .../reactflow/ArchitectureGraph.tsx | 8 +- .../reactflow/PatternGraph.test.tsx | 13 +- .../components/reactflow/PatternGraph.tsx | 5 +- .../services/node-position-service.test.tsx | 56 ++ .../services/node-position-service.tsx | 56 +- calm-hub/PERMISSIONS.md | 7 +- .../0001-versioned-artefact-storage.md | 7 + .../0003-shared-version-store-helper.md | 6 +- calm-hub/nitrite/init-nitrite.sh | 48 ++ .../MongoArchitectureIntegration.java | 31 + .../integration/MongoControlIntegration.java | 102 +++- .../NitriteArchitectureIntegration.java | 30 + .../NitriteControlIntegration.java | 102 +++- .../integration/NitriteEndToEndResource.java | 1 + .../calm/config/StandaloneDemoSeeder.java | 193 +++++++ .../ControlHasConfigurationsException.java | 31 + .../steps/MongoLayoutFormatMigrationStep.java | 108 ++++ .../NitriteLayoutFormatMigrationStep.java | 111 ++++ .../org/finos/calm/resources/AdrResource.java | 27 + .../calm/resources/ArchitectureResource.java | 31 +- .../finos/calm/resources/ControlResource.java | 63 +++ .../calm/resources/DecoratorResource.java | 31 + .../finos/calm/resources/FlowResource.java | 30 +- .../calm/resources/InterfaceResource.java | 26 +- .../finos/calm/resources/MappingCleanup.java | 43 ++ .../finos/calm/resources/PatternResource.java | 30 +- .../calm/resources/StandardResource.java | 26 +- .../calm/resources/TimelineResource.java | 24 + .../calm/security/AuditRequestFilter.java | 4 + .../java/org/finos/calm/store/AdrStore.java | 5 + .../finos/calm/store/ArchitectureStore.java | 5 + .../org/finos/calm/store/ControlStore.java | 12 + .../org/finos/calm/store/DecoratorStore.java | 10 + .../java/org/finos/calm/store/FlowStore.java | 5 + .../org/finos/calm/store/InterfaceStore.java | 5 + .../org/finos/calm/store/PatternStore.java | 5 + .../calm/store/ResourceMappingStore.java | 10 + .../org/finos/calm/store/StandardStore.java | 5 + .../org/finos/calm/store/TimelineStore.java | 5 + .../finos/calm/store/mongo/MongoAdrStore.java | 8 + .../store/mongo/MongoArchitectureStore.java | 8 + .../calm/store/mongo/MongoControlStore.java | 37 ++ .../calm/store/mongo/MongoDecoratorStore.java | 16 + .../calm/store/mongo/MongoFlowStore.java | 8 + .../calm/store/mongo/MongoInterfaceStore.java | 8 + .../calm/store/mongo/MongoPatternStore.java | 8 + .../mongo/MongoResourceMappingStore.java | 13 + .../calm/store/mongo/MongoStandardStore.java | 8 + .../calm/store/mongo/MongoTimelineStore.java | 8 + .../calm/store/nitrite/NitriteAdrStore.java | 9 + .../nitrite/NitriteArchitectureStore.java | 9 + .../store/nitrite/NitriteControlStore.java | 36 ++ .../store/nitrite/NitriteDecoratorStore.java | 32 ++ .../calm/store/nitrite/NitriteFlowStore.java | 9 + .../store/nitrite/NitriteInterfaceStore.java | 9 + .../store/nitrite/NitritePatternStore.java | 9 + .../nitrite/NitriteResourceMappingStore.java | 18 + .../store/nitrite/NitriteStandardStore.java | 9 + .../store/nitrite/NitriteTimelineStore.java | 9 + .../store/util/MongoVersionDocumentStore.java | 31 +- .../util/NitriteVersionDocumentStore.java | 32 ++ .../src/main/resources/application.properties | 3 + ...tMongoLayoutFormatMigrationStepShould.java | 165 ++++++ ...itriteLayoutFormatMigrationStepShould.java | 137 +++++ .../calm/resources/TestAdrResourceShould.java | 23 + .../TestArchitectureResourceShould.java | 59 ++ .../resources/TestControlResourceShould.java | 70 +++ .../TestDecoratorResourceShould.java | 58 ++ .../resources/TestFlowResourceShould.java | 60 ++ .../TestInterfaceResourceShould.java | 58 ++ .../resources/TestMappingCleanupShould.java | 57 ++ .../resources/TestPatternResourceShould.java | 58 ++ .../resources/TestStandardResourceShould.java | 59 ++ .../resources/TestTimelineResourceShould.java | 34 ++ .../TestAuditRequestFilterShould.java | 39 ++ .../store/mongo/TestMongoAdrStoreShould.java | 27 + .../TestMongoArchitectureStoreShould.java | 27 + .../mongo/TestMongoControlStoreShould.java | 77 +++ .../mongo/TestMongoDecoratorStoreShould.java | 54 ++ .../store/mongo/TestMongoFlowStoreShould.java | 27 + .../mongo/TestMongoInterfaceStoreShould.java | 27 + .../mongo/TestMongoPatternStoreShould.java | 27 + .../TestMongoResourceMappingStoreShould.java | 38 ++ .../mongo/TestMongoStandardStoreShould.java | 27 + .../mongo/TestMongoTimelineStoreShould.java | 27 + .../nitrite/TestNitriteAdrStoreShould.java | 29 + .../TestNitriteArchitectureStoreShould.java | 29 + .../TestNitriteControlStoreShould.java | 78 +++ .../TestNitriteDecoratorStoreShould.java | 70 +++ .../nitrite/TestNitriteFlowStoreShould.java | 29 + .../TestNitriteInterfaceStoreShould.java | 29 + .../TestNitritePatternStoreShould.java | 29 + ...TestNitriteResourceMappingStoreShould.java | 44 ++ .../TestNitriteStandardStoreShould.java | 29 + .../TestNitriteTimelineStoreShould.java | 29 + .../TestMongoVersionDocumentStoreShould.java | 34 ++ ...TestNitriteVersionDocumentStoreShould.java | 25 + calm-models/src/model/moment.ts | 2 +- calm-models/src/model/node.spec.ts | 65 ++- calm-models/src/model/node.ts | 6 +- calm-models/src/types/core-types.ts | 1 + calm-plugins/vscode/package.json | 27 + .../vscode/src/extension/extension.ts | 222 ++++++++ .../services/hub-asset-service.test.ts | 218 ++++++++ .../extension/services/hub-asset-service.ts | 137 +++++ .../services/hub-auth-service.test.ts | 170 ++++++ .../extension/services/hub-auth-service.ts | 155 +++++ .../src/extension/services/hub-client.test.ts | 169 ++++++ .../src/extension/services/hub-client.ts | 132 +++++ .../extension/services/hub-status-bar.test.ts | 107 ++++ .../src/extension/services/hub-status-bar.ts | 107 ++++ .../services/sha-cache-service.test.ts | 118 ++++ .../extension/services/sha-cache-service.ts | 78 +++ .../services/workspace-asset-service.ts | 4 + .../vscode/src/extension/types/messages.ts | 21 +- .../extension/webview/canvas-panel.test.ts | 56 ++ .../src/extension/webview/canvas-panel.ts | 339 ++++++++++- .../vscode/src/test/__mocks__/vscode.ts | 68 ++- calm-plugins/vscode/src/webview/App.tsx | 98 +++- .../src/webview/panels/ControlsList.tsx | 2 +- .../vscode/src/webview/panels/NodePalette.tsx | 95 +++- .../vscode/src/webview/stores/canvas-store.ts | 10 + .../vscode/src/webview/stores/sync-bridge.ts | 38 ++ .../calm-editor-transformer.test.ts | 169 ++++++ .../transforms/calm-editor-transformer.ts | 36 +- .../src/webview/transforms/calm-parser.ts | 1 + .../vscode/src/webview/utils/controls.ts | 3 + .../vscode/src/webview/utils/validation.ts | 14 +- cli/CHANGELOG.md | 30 + cli/package.json | 2 +- cli/src/browser-manifest.spec.ts | 30 + cli/src/cli-config.ts | 25 + cli/src/cli.e2e.spec.ts | 2 +- cli/src/cli.spec.ts | 6 +- cli/src/cli.ts | 2 + cli/src/command-helpers/ai-tools.ts | 2 +- cli/src/command-helpers/hub-commands.spec.ts | 15 +- cli/src/command-helpers/validate.spec.ts | 38 +- cli/src/command-helpers/validate.ts | 33 +- .../command-helpers/workspace/bump.spec.ts | 5 +- cli/src/command-helpers/workspace/bump.ts | 13 +- .../workspace/commands.spec.ts | 15 +- cli/src/command-helpers/workspace/commands.ts | 4 +- cli/src/command-helpers/workspace/config.ts | 2 +- .../workspace/document-id-prompt.ts | 5 +- .../command-helpers/workspace/push.spec.ts | 5 +- cli/src/command-helpers/workspace/push.ts | 4 +- .../workspace/ref-rewrite.spec.ts | 3 +- .../command-helpers/workspace/ref-rewrite.ts | 2 +- cli/src/command-helpers/workspace/rm.ts | 2 +- cli/tsconfig.json | 4 +- package-lock.json | 528 +----------------- shared/AGENTS.md | 24 +- shared/README.md | 16 + shared/package.json | 16 +- shared/scripts/browser-probe.ts | 70 +++ shared/scripts/check-browser-entry.mjs | 127 +++++ shared/src/browser-capabilities.spec.ts | 33 ++ shared/src/browser-capabilities.ts | 37 ++ shared/src/browser-surface.spec.ts | 58 ++ shared/src/browser.ts | 74 +++ shared/src/commands/diff/diff-core.ts | 244 ++++++++ shared/src/commands/diff/diff.spec.ts | 35 ++ shared/src/commands/diff/diff.ts | 274 +-------- .../commands/generate/generate-core.spec.ts | 27 + shared/src/commands/generate/generate-core.ts | 22 + shared/src/commands/generate/generate.ts | 25 +- .../commands/validate/format-output.spec.ts | 30 + shared/src/commands/validate/format-output.ts | 44 ++ .../validate/output-formats/junit-output.ts | 14 + .../validate/output-formats/pretty-output.ts | 14 +- shared/src/commands/validate/validate-core.ts | 102 ++++ shared/src/commands/validate/validate.ts | 153 +---- shared/src/controls/merge-controls.spec.ts | 46 ++ shared/src/controls/merge-controls.ts | 12 + .../browser-document-loader.spec.ts | 46 ++ .../browser-document-loader.ts | 33 ++ .../calmhub-document-loader.spec.ts | 36 +- .../calmhub-document-loader.ts | 2 + .../direct-url-document-loader.spec.ts | 36 +- .../direct-url-document-loader.ts | 8 +- .../document-loader/document-loader.spec.ts | 201 +------ shared/src/document-loader/document-loader.ts | 52 +- .../in-memory-document-loader.spec.ts | 42 ++ .../in-memory-document-loader.ts | 49 ++ .../node-document-loader.spec.ts | 201 +++++++ .../document-loader/node-document-loader.ts | 49 ++ .../document-loader/response-origin.spec.ts | 49 ++ shared/src/document-loader/response-origin.ts | 37 ++ shared/src/hub/calm-hub-client.ts | 10 +- shared/src/hub/curie.spec.ts | 116 ++++ shared/src/hub/curie.ts | 40 ++ shared/src/hub/document-id-utils.ts | 2 +- shared/src/hub/resource-types.ts | 6 + shared/src/index.ts | 44 +- shared/src/logger.node.ts | 35 ++ shared/src/logger.spec.ts | 54 +- shared/src/logger.ts | 72 +-- .../src/resolver/caching-tracking-resolver.ts | 2 +- .../resolver/chain-reference-resolver.spec.ts | 66 +++ .../src/resolver/chain-reference-resolver.ts | 32 ++ shared/src/resolver/curie-resolver.spec.ts | 72 +++ shared/src/resolver/curie-resolver.ts | 20 + .../src/resolver/local-curie-resolver.spec.ts | 56 ++ shared/src/resolver/local-curie-resolver.ts | 46 ++ .../schema-directory-reference-resolver.ts | 2 +- .../src/resolver/sha-cache-resolver.spec.ts | 52 ++ shared/src/resolver/sha-cache-resolver.ts | 52 ++ shared/src/schema-directory.spec.ts | 3 +- shared/src/util/ip-literal.spec.ts | 17 + shared/src/util/ip-literal.ts | 61 ++ shared/vitest.config.ts | 2 +- 226 files changed, 9079 insertions(+), 1445 deletions(-) create mode 100644 MAINTAINERS_GUIDELINES.md create mode 100644 calm-hub-ui/src/model/layout.test.ts create mode 100644 calm-hub/src/main/java/org/finos/calm/config/StandaloneDemoSeeder.java create mode 100644 calm-hub/src/main/java/org/finos/calm/domain/exception/ControlHasConfigurationsException.java create mode 100644 calm-hub/src/main/java/org/finos/calm/migration/steps/MongoLayoutFormatMigrationStep.java create mode 100644 calm-hub/src/main/java/org/finos/calm/migration/steps/NitriteLayoutFormatMigrationStep.java create mode 100644 calm-hub/src/main/java/org/finos/calm/resources/MappingCleanup.java create mode 100644 calm-hub/src/test/java/org/finos/calm/migration/steps/TestMongoLayoutFormatMigrationStepShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/migration/steps/TestNitriteLayoutFormatMigrationStepShould.java create mode 100644 calm-hub/src/test/java/org/finos/calm/resources/TestMappingCleanupShould.java create mode 100644 calm-plugins/vscode/src/extension/services/hub-asset-service.test.ts create mode 100644 calm-plugins/vscode/src/extension/services/hub-asset-service.ts create mode 100644 calm-plugins/vscode/src/extension/services/hub-auth-service.test.ts create mode 100644 calm-plugins/vscode/src/extension/services/hub-auth-service.ts create mode 100644 calm-plugins/vscode/src/extension/services/hub-client.test.ts create mode 100644 calm-plugins/vscode/src/extension/services/hub-client.ts create mode 100644 calm-plugins/vscode/src/extension/services/hub-status-bar.test.ts create mode 100644 calm-plugins/vscode/src/extension/services/hub-status-bar.ts create mode 100644 calm-plugins/vscode/src/extension/services/sha-cache-service.test.ts create mode 100644 calm-plugins/vscode/src/extension/services/sha-cache-service.ts create mode 100644 calm-plugins/vscode/src/extension/webview/canvas-panel.test.ts create mode 100644 calm-plugins/vscode/src/webview/transforms/calm-editor-transformer.test.ts create mode 100644 cli/src/browser-manifest.spec.ts create mode 100644 shared/scripts/browser-probe.ts create mode 100644 shared/scripts/check-browser-entry.mjs create mode 100644 shared/src/browser-capabilities.spec.ts create mode 100644 shared/src/browser-capabilities.ts create mode 100644 shared/src/browser-surface.spec.ts create mode 100644 shared/src/browser.ts create mode 100644 shared/src/commands/diff/diff-core.ts create mode 100644 shared/src/commands/generate/generate-core.spec.ts create mode 100644 shared/src/commands/generate/generate-core.ts create mode 100644 shared/src/commands/validate/format-output.spec.ts create mode 100644 shared/src/commands/validate/format-output.ts create mode 100644 shared/src/commands/validate/validate-core.ts create mode 100644 shared/src/controls/merge-controls.spec.ts create mode 100644 shared/src/controls/merge-controls.ts create mode 100644 shared/src/document-loader/browser-document-loader.spec.ts create mode 100644 shared/src/document-loader/browser-document-loader.ts create mode 100644 shared/src/document-loader/in-memory-document-loader.spec.ts create mode 100644 shared/src/document-loader/in-memory-document-loader.ts create mode 100644 shared/src/document-loader/node-document-loader.spec.ts create mode 100644 shared/src/document-loader/node-document-loader.ts create mode 100644 shared/src/document-loader/response-origin.spec.ts create mode 100644 shared/src/document-loader/response-origin.ts create mode 100644 shared/src/hub/curie.spec.ts create mode 100644 shared/src/hub/curie.ts create mode 100644 shared/src/hub/resource-types.ts create mode 100644 shared/src/logger.node.ts create mode 100644 shared/src/resolver/chain-reference-resolver.spec.ts create mode 100644 shared/src/resolver/chain-reference-resolver.ts create mode 100644 shared/src/resolver/curie-resolver.spec.ts create mode 100644 shared/src/resolver/curie-resolver.ts create mode 100644 shared/src/resolver/local-curie-resolver.spec.ts create mode 100644 shared/src/resolver/local-curie-resolver.ts create mode 100644 shared/src/resolver/sha-cache-resolver.spec.ts create mode 100644 shared/src/resolver/sha-cache-resolver.ts create mode 100644 shared/src/util/ip-literal.spec.ts create mode 100644 shared/src/util/ip-literal.ts diff --git a/.github/ISSUE_TEMPLATE/Meeting.md b/.github/ISSUE_TEMPLATE/Meeting.md index b1b976884..ef2786dcc 100644 --- a/.github/ISSUE_TEMPLATE/Meeting.md +++ b/.github/ISSUE_TEMPLATE/Meeting.md @@ -25,7 +25,7 @@ https://zoom-lfx.platform.linuxfoundation.org/meeting/95612628024?password=bd0f2 - FINOS meetings involve participation by industry competitors, and it is the intention of FINOS and the Linux Foundation to conduct all of its activities in accordance with applicable antitrust and competition laws. It is therefore extremely important that attendees adhere to meeting agendas, and be aware of, and not participate in, any activities that are prohibited under applicable US state, federal or foreign antitrust and competition laws. Please contact legal@finos.org with any questions. -- FINOS project meetings may be recorded for use solely by the FINOS team for administration purposes. In very limited instances, and with explicit approval, recordings may be made more widely available. +- FINOS project meetings are recorded for use solely by the FINOS team for administration purposes. In very limited instances, and with explicit approval, recordings may be made more widely available. ## Agenda diff --git a/.github/ISSUE_TEMPLATE/Office_Hours.md b/.github/ISSUE_TEMPLATE/Office_Hours.md index 80c618432..8e11a1a7a 100644 --- a/.github/ISSUE_TEMPLATE/Office_Hours.md +++ b/.github/ISSUE_TEMPLATE/Office_Hours.md @@ -21,7 +21,7 @@ https://zoom-lfx.platform.linuxfoundation.org/meeting/98579493833?password=2af32 - FINOS meetings involve participation by industry competitors, and it is the intention of FINOS and the Linux Foundation to conduct all of its activities in accordance with applicable antitrust and competition laws. It is therefore extremely important that attendees adhere to meeting agendas, and be aware of, and not participate in, any activities that are prohibited under applicable US state, federal or foreign antitrust and competition laws. Please contact legal@finos.org with any questions. -- FINOS project meetings may be recorded for use solely by the FINOS team for administration purposes. In very limited instances, and with explicit approval, recordings may be made more widely available. +- FINOS project meetings are recorded for use solely by the FINOS team for administration purposes. In very limited instances, and with explicit approval, recordings may be made more widely available. ## Agenda diff --git a/.github/workflows/release-calm-models-maven-publish.yml b/.github/workflows/release-calm-models-maven-publish.yml index 93c795c85..99d9954fc 100644 --- a/.github/workflows/release-calm-models-maven-publish.yml +++ b/.github/workflows/release-calm-models-maven-publish.yml @@ -17,9 +17,15 @@ jobs: steps: - name: Extract release version id: version + env: + REF: ${{ github.event.pull_request.head.ref }} run: | - REF="${{ github.event.pull_request.head.ref }}" - echo "release_version=${REF#release-prep/calm-models-v}" >> "$GITHUB_OUTPUT" + RELEASE_VERSION="${REF#release-prep/calm-models-v}" + if ! [[ "$RELEASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Branch name did not yield a valid semver release version: $REF" + exit 1 + fi + echo "release_version=$RELEASE_VERSION" >> "$GITHUB_OUTPUT" - name: Checkout main at merge commit uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 @@ -47,8 +53,9 @@ jobs: git config --global url."https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/".insteadOf "https://github.com/" - name: Tag release + env: + RELEASE_VERSION: ${{ steps.version.outputs.release_version }} run: | - RELEASE_VERSION="${{ steps.version.outputs.release_version }}" git tag -a "calm-models-v${RELEASE_VERSION}" -m "calm-models v${RELEASE_VERSION}" git push origin "calm-models-v${RELEASE_VERSION}" @@ -66,8 +73,8 @@ jobs: - name: Prepare next development iteration PR env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_VERSION: ${{ steps.version.outputs.release_version }} run: | - RELEASE_VERSION="${{ steps.version.outputs.release_version }}" IFS='.' read -r MAJOR MINOR PATCH <<< "$RELEASE_VERSION" NEXT_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))-SNAPSHOT" BRANCH="release-prep/calm-models-next-dev-v${NEXT_VERSION}" diff --git a/.github/workflows/semgrep-ci.yml b/.github/workflows/semgrep-ci.yml index 3ee673066..815651c3c 100644 --- a/.github/workflows/semgrep-ci.yml +++ b/.github/workflows/semgrep-ci.yml @@ -21,7 +21,7 @@ jobs: env: SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} container: - image: semgrep/semgrep@sha256:67319956da3dcb58baf5b322899c15458e3963e7018a86aeeb5cd224e69cb77a + image: semgrep/semgrep@sha256:f1f7b71861c7b28b6e0f661225a2c4f58a484f5d0f182465c6d6b3b22f972ade if: (github.actor != 'dependabot[bot]') steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c40b48aa8..745c6cc74 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,10 @@ Thank you for your interest in contributing to the Architecture as Code project! > apply to every repository in the project and are maintained there. This document covers what is > specific to *this* repository: our commit conventions and release process. +Maintainers working in this repository should also read +[MAINTAINERS_GUIDELINES.md](./MAINTAINERS_GUIDELINES.md) for repository-specific review, triage, +merge, release, and onboarding guidance. + ## 🚀 Why We Use Semantic Release We use [Semantic Release](https://semantic-release.gitbook.io/) to automate our release process for the **CLI module**, with plans to expand to other modules in the future. This ensures: diff --git a/MAINTAINERS_GUIDELINES.md b/MAINTAINERS_GUIDELINES.md new file mode 100644 index 000000000..e7be6feed --- /dev/null +++ b/MAINTAINERS_GUIDELINES.md @@ -0,0 +1,128 @@ +# Maintainer Guidelines for architecture-as-code + +This document is for maintainers working in the `finos/architecture-as-code` monorepo. It complements the contributor-facing guidance in [CONTRIBUTING.md](./CONTRIBUTING.md) and the project-wide governance documents in [finos/calm-governance](https://github.com/finos/calm-governance). + +The existing [MAINTAINERS.md](./MAINTAINERS.md) in this repository remains a pointer to the project-wide maintainer roster. Use this document for repository-specific maintainer workflow and expectations. + +## Source Documents + +Use these documents as the source of truth for the corresponding topics: + +| Document | Purpose | +|---|---| +| [finos/calm-governance/GOVERNANCE.md](https://github.com/finos/calm-governance/blob/main/GOVERNANCE.md) | Maintainer roles, voting, elections, removal, and escalation rules | +| [finos/calm-governance/MAINTAINERS.md](https://github.com/finos/calm-governance/blob/main/MAINTAINERS.md) | Project-wide maintainer roster and Lead Maintainer | +| [README.md](./README.md#projects) | Maintainer mapping for subprojects in this monorepo | +| [CONTRIBUTING.md](./CONTRIBUTING.md) | Contributor workflow, commit conventions, and release expectations | +| [SECURITY.md](./SECURITY.md) | Private vulnerability reporting and disclosure process | +| [.github/CODEOWNERS](./.github/CODEOWNERS) | Default ownership and review routing | +| [.github/pull_request_template.md](./.github/pull_request_template.md) | Required PR metadata and maintainer review checklist | +| [.github/ISSUE_TEMPLATE/Maintainer_update.md](./.github/ISSUE_TEMPLATE/Maintainer_update.md) | Process checklist for adding or removing maintainers | + +## Core Responsibilities + +Maintainers are responsible for keeping this repository healthy, reviewable, and welcoming. In practice, that includes: + +- Reviewing pull requests and giving clear, actionable feedback +- Triaging issues and routing work to the right subproject maintainers +- Protecting code quality, documentation quality, and CI health +- Coordinating releases and versioning changes for affected components +- Handling dependency, licensing, and security maintenance responsibly +- Supporting contributors and onboarding new maintainers + +The maintainers listed for each subproject in the [Projects table](./README.md#projects) are expected to steward that area, but repository-wide changes often need coordination across multiple subprojects. + +## Reviewing Pull Requests + +When reviewing a pull request: + +- Use the [PR template](./.github/pull_request_template.md) to verify the change type, affected components, testing notes, and follow-up documentation. +- Confirm that project-wide contribution requirements are met, including the expectations documented in [finos/calm-governance/CONTRIBUTING.md](https://github.com/finos/calm-governance/blob/main/CONTRIBUTING.md). +- Make sure the right reviewers are involved based on the affected paths in [.github/CODEOWNERS](./.github/CODEOWNERS), which is the repository's primary PR review-routing map for changed files, and the subproject ownership listed in [README.md](./README.md#projects). +- Ask for tests, migration notes, or documentation updates when behavior, interfaces, workflows, or public examples change. +- Ask specifically for regression coverage when a change fixes a bug, alters migration behavior, or introduces stateful UI or persistence logic where a precise failure mode should stay pinned by tests. +- Prefer shared helpers over copied logic for keys, namespace rules, error responses, and similar repeated behavior; if duplication is kept deliberately, ask for the rationale to be made explicit in the code or PR discussion. +- Pull in additional maintainers early for cross-cutting changes, schema changes, release automation changes, or changes that affect multiple packages or services. + +## Approval and Merge Expectations + +Before merging a pull request: + +- Ensure required GitHub checks have passed. +- Ensure required approvals and code-owner reviews are complete. +- Resolve open review threads or capture an explicit follow-up issue when something is intentionally deferred. +- Confirm the PR description still accurately reflects the final diff. +- Ask authors to split unrelated changes into separate pull requests when a single PR has become too broad to review safely. + +Treat automated release pull requests and release-preparation branches carefully. For example, PRs or branches generated by workflows such as `ci(cli): release version ...`, `ci(calm-server): release version ...`, or `release-prep/calm-models-v...` should stay focused on release artifacts and version changes rather than accumulating unrelated manual edits. + +## Issue and Pull Request Triage + +Maintainers help keep work discoverable and moving: + +- Acknowledge new issues and PRs, especially when follow-up will take time. +- Apply existing labels, close duplicates, and redirect out-of-scope requests to the right place. +- Route governance matters, including maintainer roster changes, to [finos/calm-governance](https://github.com/finos/calm-governance) and use the [Maintainer Update template](./.github/ISSUE_TEMPLATE/Maintainer_update.md) when this repository needs a maintainer change processed. +- Keep the [Projects table](./README.md#projects) and [.github/CODEOWNERS](./.github/CODEOWNERS) aligned when component ownership changes. +- Remember that [.github/CODEOWNERS](./.github/CODEOWNERS) also establishes repo-wide ownership for dependency and build-manifest files such as `package.json`, `package-lock.json`, and `pom.xml`, so changes to those files should usually be treated as cross-cutting review rather than package-local review. +- If a path does not have a more specific owner entry, GitHub falls back to the default owners in [.github/CODEOWNERS](./.github/CODEOWNERS); when adding a new top-level area or changing review routing expectations, update `CODEOWNERS` deliberately rather than relying on the fallback by accident. + +## Quality, CI, and Security Responsibilities + +Maintainers are expected to protect repository quality, not just merge code: + +- Keep required workflows green before merge and investigate recurring failures instead of normalizing them. +- Pay close attention to repository-wide automation such as CodeQL, Semgrep, CVE scanning, license scanning, lockfile validation, and component build workflows. +- Make sure documentation and examples evolve with behavior changes so contributor and user guidance stays trustworthy. +- Make sure technical rationale is documented clearly and locally when the design is non-obvious; avoid circular comments or explanations that force readers to bounce between files to understand a decision. +- Treat dependency updates, workflow changes, and shared-package changes as potentially cross-cutting work that may affect multiple downstream components. +- For dependency and security remediation PRs, verify that the selected version, override, or workflow change actually fixes the reported issue rather than only appearing newer. +- Handle security vulnerabilities privately according to [SECURITY.md](./SECURITY.md); do not use public issues for undisclosed vulnerabilities. + +## GitHub Actions + +When maintaining workflows, use the existing repository guidance as the source of truth rather than restating it here: + +- Follow the naming and documentation expectations in [README.md](./README.md#github-actions), especially when adding new workflows or less-familiar third-party actions. +- Keep workflow runtime assumptions aligned with the CI standards in [AGENTS.md](./AGENTS.md), especially the [Node 26 requirement](./AGENTS.md#node-version-requirements) and [lockfile regeneration rules](./AGENTS.md#lockfile-regeneration), because those expectations are enforced by workflows such as [validate-lockfile.yml](./.github/workflows/validate-lockfile.yml). +- For release-oriented workflow changes or manual runs, review the release guidance in [CONTRIBUTING.md](./CONTRIBUTING.md#-why-we-use-semantic-release) and the workflow-specific comments and inputs in [automated-release.yml](./.github/workflows/automated-release.yml), [automated-release-calm-server.yml](./.github/workflows/automated-release-calm-server.yml), [release-calm-models-maven.yml](./.github/workflows/release-calm-models-maven.yml), and [build-vscode-extension.yml](./.github/workflows/build-vscode-extension.yml) before approving or triggering release automation. +- Use the PR template's CI/CD and testing sections when reviewing workflow changes, and remember the note in [pull_request_template.md](./.github/pull_request_template.md) that only `(cli)`-scoped commits trigger the CLI automated release flow. + +## Release and Version Management + +This repository contains multiple independently released components, so maintainers should be deliberate about release-related changes: + +- Enforce the conventional-commit expectations in [CONTRIBUTING.md](./CONTRIBUTING.md), because release automation depends on them. +- Review release workflow changes carefully, especially when they affect generated changelogs, package publication, tags, or release branches. +- Verify release-preparation PRs contain the expected generated changes and no unrelated edits. +- Document any manual intervention clearly in the PR when normal release automation is not sufficient. + +## Significant or Breaking Changes + +For large or high-risk changes: + +- Ask for an issue or design discussion before merge when the change is breaking, architectural, security-sensitive, or spans multiple subprojects. +- Involve all affected maintainers early rather than relying on a single late-stage review. +- For significant new enhancements, require two maintainer approvals from two different organizations before merge. +- Require clear rollout or migration notes when the change impacts consumers, contributors, or release workflows. +- Treat schema and data-migration changes as sequence-sensitive, high-risk work: review them for malformed legacy data handling, atomicity, ordering, rollback or retry safety, and avoid changing the behavior of already-merged migration steps unless there is a compelling reason and the impact is fully understood. +- Use the governance process in [GOVERNANCE.md](https://github.com/finos/calm-governance/blob/main/GOVERNANCE.md) when escalation or formal maintainer decision-making is needed. + +## Supporting Contributors + +Maintainers set the tone for the project: + +- Be respectful, specific, and timely in review feedback. +- Explain requested changes and point contributors to the relevant documentation or examples. +- Close the loop after merge or closure so contributors understand the outcome. +- Keep maintainer behavior aligned with the [Code of Conduct](https://github.com/finos/calm-governance/blob/main/CODE_OF_CONDUCT.md) and FINOS community expectations. + +## Onboarding New Maintainers + +When onboarding a new maintainer: + +- Follow the role and voting rules in [GOVERNANCE.md](https://github.com/finos/calm-governance/blob/main/GOVERNANCE.md). +- Use the checklist in [.github/ISSUE_TEMPLATE/Maintainer_update.md](./.github/ISSUE_TEMPLATE/Maintainer_update.md). +- Update the project-wide roster in [finos/calm-governance/MAINTAINERS.md](https://github.com/finos/calm-governance/blob/main/MAINTAINERS.md), the [Projects table](./README.md#projects), and [.github/CODEOWNERS](./.github/CODEOWNERS) as needed. +- Make sure the maintainer is added to the appropriate GitHub team and that FINOS is notified where required by the maintainer update checklist. +- Point the new maintainer to [README.md](./README.md), [CONTRIBUTING.md](./CONTRIBUTING.md), [SECURITY.md](./SECURITY.md), and this guide. diff --git a/README.md b/README.md index 61561156e..196e7c1c3 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ in the project. |---|---| | [GOVERNANCE.md](https://github.com/finos/calm-governance/blob/main/GOVERNANCE.md) | Roles, contribution rules, Maintainer voting, and how Maintainers are added and removed | | [MAINTAINERS.md](https://github.com/finos/calm-governance/blob/main/MAINTAINERS.md) | The project-wide Maintainer roster and the Lead Maintainer | +| [MAINTAINERS_GUIDELINES.md](./MAINTAINERS_GUIDELINES.md) | Repository-specific maintainer guidance for review, triage, quality, releases, and onboarding in this monorepo | | [CONTRIBUTING.md](https://github.com/finos/calm-governance/blob/main/CONTRIBUTING.md) | Project-wide contribution guidelines | | [CODE_OF_CONDUCT.md](https://github.com/finos/calm-governance/blob/main/CODE_OF_CONDUCT.md) | The Code of Conduct all participants are subject to | diff --git a/calm-hub-ui/src/admin/panels/EntitlementsPanel.test.tsx b/calm-hub-ui/src/admin/panels/EntitlementsPanel.test.tsx index cf361038e..ee2cae5c6 100644 --- a/calm-hub-ui/src/admin/panels/EntitlementsPanel.test.tsx +++ b/calm-hub-ui/src/admin/panels/EntitlementsPanel.test.tsx @@ -192,7 +192,9 @@ describe('EntitlementsPanel', () => { await waitFor(() => expect(screen.getByRole('region', { name: /global admin access/i })).toBeInTheDocument() ); - expect(userAccessSvc.getNamespaceUserAccess).toHaveBeenCalledWith('GLOBAL'); + await waitFor(() => + expect(userAccessSvc.getNamespaceUserAccess).toHaveBeenCalledWith('GLOBAL') + ); }); }); diff --git a/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx b/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx index 0abcc03cf..355da682f 100644 --- a/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx +++ b/calm-hub-ui/src/hub/components/diagram-section/DiagramSection.tsx @@ -87,7 +87,7 @@ export function DiagramSection({ data, onItemSelect, hasDetailsPanel, breadcrumb [nodeSearchTerm, nodeTypeFilter, nodeTypes] ); const calmService = useMemo(() => new CalmService(), []); - const defaultLayoutState = useDefaultLayout(data.name, data.id, data.calmType); + const defaultLayoutState = useDefaultLayout(data.name, data.id, data.calmType, data.data as Record | undefined); // Destructured locals so handleSaveLayout/handleResetLayout below can depend // on exactly the (already useCallback-stable) functions they call, rather // than the whole result object — which still changes identity whenever @@ -428,6 +428,26 @@ export function DiagramSection({ data, onItemSelect, hasDetailsPanel, breadcrumb const showLayoutActions = !comparing && activeTab === 'diagram'; const layoutActions = showLayoutActions && (
+ {defaultLayoutState.hasBothSources && ( +
+ + +
+ )} {canSaveLayout && (
)} diff --git a/calm-plugins/vscode/src/webview/panels/NodePalette.tsx b/calm-plugins/vscode/src/webview/panels/NodePalette.tsx index ef9bd10aa..106f22855 100644 --- a/calm-plugins/vscode/src/webview/panels/NodePalette.tsx +++ b/calm-plugins/vscode/src/webview/panels/NodePalette.tsx @@ -10,6 +10,8 @@ interface BuildingBlock { nodeType: string; category?: string; description?: string; + namespace?: string; + sha?: string; } interface NodePaletteProps { @@ -18,7 +20,7 @@ interface NodePaletteProps { export function NodePalette({ buildingBlocks }: NodePaletteProps) { const [searchQuery, setSearchQuery] = useState(''); - const [collapsed, setCollapsed] = useState>({ containers: true, infra: true, standards: true, guidelines: true }); + const [collapsed, setCollapsed] = useState>({ containers: true, infra: true, standards: true, guidelines: true, hub: false }); const packs = useMemo(() => { const allPacks = getAllPacks().filter((p: PackDefinition) => p.id !== 'internal'); @@ -40,9 +42,20 @@ export function NodePalette({ buildingBlocks }: NodePaletteProps) { const lowerQuery = searchQuery.toLowerCase().trim(); const isSearching = lowerQuery.length > 0; - const infraNodes = useMemo(() => buildingBlocks.filter((n) => n.behaviour === 'create-node'), [buildingBlocks]); - const standardNodes = useMemo(() => buildingBlocks.filter((n) => n.behaviour === 'apply-controls-on-drop' && n.id.startsWith('standards:')), [buildingBlocks]); - const guidelineNodes = useMemo(() => buildingBlocks.filter((n) => n.behaviour === 'apply-controls-on-drop' && n.id.startsWith('guidelines:')), [buildingBlocks]); + const infraNodes = useMemo(() => buildingBlocks.filter((n) => n.behaviour === 'create-node' && !n.namespace), [buildingBlocks]); + const standardNodes = useMemo(() => buildingBlocks.filter((n) => n.behaviour === 'apply-controls-on-drop' && n.id?.startsWith('standards:') && !n.namespace), [buildingBlocks]); + const guidelineNodes = useMemo(() => buildingBlocks.filter((n) => n.behaviour === 'apply-controls-on-drop' && n.id?.startsWith('guidelines:') && !n.namespace), [buildingBlocks]); + const hubNodes = useMemo(() => buildingBlocks.filter((n) => !!n.namespace), [buildingBlocks]); + + const hubByNamespace = useMemo(() => { + const map = new Map(); + for (const node of hubNodes) { + const ns = node.namespace!; + if (!map.has(ns)) map.set(ns, []); + map.get(ns)!.push(node); + } + return [...map.entries()].map(([name, items]) => ({ name, items })).sort((a, b) => a.name.localeCompare(b.name)); + }, [hubNodes]); function matchesSearch(name: string, description?: string): boolean { if (!isSearching) return true; @@ -82,6 +95,11 @@ export function NodePalette({ buildingBlocks }: NodePaletteProps) { [packs, lowerQuery] // eslint-disable-line ); + const filteredHub = useMemo(() => + hubByNamespace.map((g) => ({ ...g, items: g.items.filter((n) => matchesSearch(n.name, n.description)) })).filter((g) => g.items.length > 0), + [hubByNamespace, lowerQuery] // eslint-disable-line + ); + const toggleSection = (key: string) => setCollapsed((c) => ({ ...c, [key]: !c[key] })); const onDragStart = (e: React.DragEvent, node: BuildingBlock) => { @@ -176,6 +194,47 @@ export function NodePalette({ buildingBlocks }: NodePaletteProps) { )} + {/* Hub Namespaces */} + {filteredHub.length > 0 && ( +
toggleSection('hub')} badge="Hub"> + {filteredHub.map((group) => { + const blocks = group.items.filter((n) => n.behaviour === 'create-node'); + const standards = group.items.filter((n) => n.behaviour === 'apply-controls-on-drop'); + return ( + + {blocks.length > 0 && ( + + {blocks.map((node) => ( + onDragStart(e, node)} title={`[${node.namespace}] ${node.name}`} /> + ))} + + )} + {(() => { + const stdOnly = standards.filter((n) => !n.name.toLowerCase().includes('guideline')); + const guideOnly = standards.filter((n) => n.name.toLowerCase().includes('guideline')); + return (<> + {stdOnly.length > 0 && ( + + {stdOnly.map((node) => ( + onDragStart(e, node)} title={`[${node.namespace}] ${node.name}`} /> + ))} + + )} + {guideOnly.length > 0 && ( + + {guideOnly.map((node) => ( + onDragStart(e, node)} title={`[${node.namespace}] ${node.name}`} /> + ))} + + )} + ); + })()} + + ); + })} +
+ )} + {/* Extension Packs */} {filteredPacks.map((pack) => (
void; badge?: string; borderColor?: string; children: React.ReactNode }) { + const isHub = badge === 'Hub'; const sectionStyle: React.CSSProperties = { marginBottom: '4px', - ...(badge ? { borderLeft: '2px solid #2e7d32', marginLeft: '4px', background: 'rgba(46,125,50,0.04)' } : {}), + ...(badge && !isHub ? { borderLeft: '2px solid #2e7d32', marginLeft: '4px', background: 'rgba(46,125,50,0.04)' } : {}), + ...(isHub ? { borderLeft: '2px solid #2e7d32', marginLeft: '4px', background: 'rgba(46,125,50,0.04)' } : {}), ...(borderColor && !badge ? { borderLeft: `2px solid ${borderColor}`, marginLeft: '4px' } : {}), }; return (
@@ -242,11 +303,33 @@ function PaletteItem({ icon, iconHtml, label, onDragStart, title }: { icon?: str ); } +const hubIconSvg = ''; +const hubBlockIcon = ''; +const hubStandardIcon = ''; + +function humanizeNs(slug: string): string { + return slug.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +} + +function CollapsibleSub({ label, color, children }: { label: string; color: string; children: React.ReactNode }) { + const [open, setOpen] = React.useState(true); + return ( +
+ + {open && children} +
+ ); +} + const paletteStyle: React.CSSProperties = { width: '180px', minWidth: '180px', height: '100%', overflowY: 'auto', borderRight: '1px solid var(--calm-border)', background: 'var(--calm-bg)', padding: '8px 0' }; const headerStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 12px 6px', fontSize: '11px', fontWeight: 700, letterSpacing: '0.5px', color: 'var(--calm-fg)' }; const searchInputStyle: React.CSSProperties = { width: '100%', height: '28px', padding: '0 24px 0 8px', fontSize: '11px', color: 'var(--calm-fg)', background: 'var(--calm-bg-input)', border: '1px solid var(--calm-border-input)', borderRadius: '4px', outline: 'none' }; const clearBtnStyle: React.CSSProperties = { position: 'absolute', right: '14px', top: '4px', width: '20px', height: '20px', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--calm-fg-muted)', fontSize: '14px' }; const groupNameStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: '6px', padding: '4px 12px', fontSize: '10px', fontWeight: 700, letterSpacing: '0.3px', color: 'var(--calm-fg-muted)', background: 'none', border: 'none', width: '100%', cursor: 'pointer', textAlign: 'left' }; const wsBadgeStyle: React.CSSProperties = { fontSize: '8px', fontWeight: 800, padding: '1px 4px', borderRadius: '3px', background: '#2e7d32', color: '#fff', letterSpacing: '0.5px' }; +const hubBadgeStyle: React.CSSProperties = { fontSize: '8px', fontWeight: 800, padding: '1px 4px', borderRadius: '3px', background: '#2e7d32', color: '#fff', letterSpacing: '0.5px' }; const subgroupStyle: React.CSSProperties = { padding: '3px 12px 3px 20px', fontSize: '9px', fontWeight: 600, color: 'var(--calm-fg-muted)', textTransform: 'capitalize' }; const itemStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: '8px', padding: '6px 12px', cursor: 'grab', borderRadius: '4px', margin: '1px 6px' }; diff --git a/calm-plugins/vscode/src/webview/stores/canvas-store.ts b/calm-plugins/vscode/src/webview/stores/canvas-store.ts index 2c0b8d55f..dedb42ebf 100644 --- a/calm-plugins/vscode/src/webview/stores/canvas-store.ts +++ b/calm-plugins/vscode/src/webview/stores/canvas-store.ts @@ -15,6 +15,12 @@ export interface DrillEntry { readonly?: boolean; } +export interface UpdateInfo { + nodeId: string; + currentSha: string; + latestSha: string; +} + export interface CanvasState { nodes: Node[]; edges: Edge[]; @@ -32,6 +38,7 @@ export interface CanvasState { loadedPatterns: unknown[]; loadedTemplates: unknown[]; loadedStandards: unknown[]; + availableUpdates: UpdateInfo[]; // Actions setNodes: (nodes: Node[]) => void; @@ -47,6 +54,7 @@ export interface CanvasState { resetDrill: () => void; setPanelWidth: (width: number) => void; setExpandControlKey: (key: string | null) => void; + setAvailableUpdates: (updates: UpdateInfo[]) => void; } export const useCanvasStore = create((set) => ({ @@ -66,6 +74,7 @@ export const useCanvasStore = create((set) => ({ loadedPatterns: [], loadedTemplates: [], loadedStandards: [], + availableUpdates: [], setNodes: (nodes) => set({ nodes }), setEdges: (edges) => set({ edges }), @@ -82,4 +91,5 @@ export const useCanvasStore = create((set) => ({ resetDrill: () => set({ drillStack: [], readonlyMode: false }), setPanelWidth: (width) => set({ panelWidth: width }), setExpandControlKey: (key) => set({ expandControlKey: key }), + setAvailableUpdates: (updates) => set({ availableUpdates: updates }), })); diff --git a/calm-plugins/vscode/src/webview/stores/sync-bridge.ts b/calm-plugins/vscode/src/webview/stores/sync-bridge.ts index 72f79252c..636d89d8f 100644 --- a/calm-plugins/vscode/src/webview/stores/sync-bridge.ts +++ b/calm-plugins/vscode/src/webview/stores/sync-bridge.ts @@ -17,6 +17,17 @@ type DrillResultCallback = ( readonly?: boolean, solution?: unknown ) => void; +type DefinitionResolvedCallback = ( + nodeId: string, + controls: Record +) => void; +type DefinitionResolutionFailedCallback = ( + nodeId: string, + error: string +) => void; +type UpdatesAvailableCallback = ( + updates: Array<{ nodeId: string; currentSha: string; latestSha: string }> +) => void; let modelUpdateCallback: ModelUpdateCallback | undefined; let patternsLoadedCallback: PatternsLoadedCallback | undefined; @@ -25,6 +36,9 @@ let buildingBlocksLoadedCallback: BuildingBlocksLoadedCallback | undefined; let standardsLoadedCallback: StandardsLoadedCallback | undefined; let standardProseCallback: StandardProseCallback | undefined; let drillResultCallback: DrillResultCallback | undefined; +let definitionResolvedCallback: DefinitionResolvedCallback | undefined; +let definitionResolutionFailedCallback: DefinitionResolutionFailedCallback | undefined; +let updatesAvailableCallback: UpdatesAvailableCallback | undefined; export function setModelUpdateCallback(cb: ModelUpdateCallback): void { modelUpdateCallback = cb; @@ -49,6 +63,21 @@ export function setStandardProseCallback(cb: StandardProseCallback): void { export function setDrillResultCallback(cb: DrillResultCallback): void { drillResultCallback = cb; } +export function setDefinitionResolvedCallback( + cb: DefinitionResolvedCallback +): void { + definitionResolvedCallback = cb; +} +export function setDefinitionResolutionFailedCallback( + cb: DefinitionResolutionFailedCallback +): void { + definitionResolutionFailedCallback = cb; +} +export function setUpdatesAvailableCallback( + cb: UpdatesAvailableCallback +): void { + updatesAvailableCallback = cb; +} export function initBridge(): void { window.addEventListener( @@ -83,6 +112,15 @@ export function initBridge(): void { msg.solution ); break; + case 'definitionResolved': + definitionResolvedCallback?.(msg.nodeId, msg.controls); + break; + case 'definitionResolutionFailed': + definitionResolutionFailedCallback?.(msg.nodeId, msg.error); + break; + case 'updatesAvailable': + updatesAvailableCallback?.(msg.updates); + break; } } ); diff --git a/calm-plugins/vscode/src/webview/transforms/calm-editor-transformer.test.ts b/calm-plugins/vscode/src/webview/transforms/calm-editor-transformer.test.ts new file mode 100644 index 000000000..69ae46f19 --- /dev/null +++ b/calm-plugins/vscode/src/webview/transforms/calm-editor-transformer.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import type { Node, Edge } from 'reactflow'; +import { flowToCalm, setLastParsedArch } from './calm-editor-transformer'; + +describe('flowToCalm - definition-id handling', () => { + beforeEach(() => { + setLastParsedArch(null); + }); + + it('writes definition-id and omits controls/metadata for Hub-sourced nodes', () => { + const nodes: Node[] = [ + { + id: 'svc-1', + type: 'service', + position: { x: 0, y: 0 }, + data: { + calmId: 'svc-1', + calmType: 'service', + label: 'Payment Service', + description: 'Handles payments', + interfaces: [{ id: 'iface-1', protocol: 'https' }], + 'definition-id': 'finos:building-blocks:microservice@abc123', + }, + }, + ]; + const edges: Edge[] = []; + + const arch = flowToCalm(nodes, edges); + + expect(arch.nodes).toHaveLength(1); + const calmNode = arch.nodes[0]; + expect(calmNode['definition-id']).toBe( + 'finos:building-blocks:microservice@abc123' + ); + expect(calmNode['unique-id']).toBe('svc-1'); + expect(calmNode.name).toBe('Payment Service'); + expect(calmNode.interfaces).toEqual([ + { id: 'iface-1', protocol: 'https' }, + ]); + // Controls and metadata should NOT be present + expect(calmNode.controls).toBeUndefined(); + expect(calmNode.metadata).toBeUndefined(); + }); + + it('writes controls and metadata for local nodes (no definition-id)', () => { + const nodes: Node[] = [ + { + id: 'svc-2', + type: 'service', + position: { x: 100, y: 100 }, + data: { + calmId: 'svc-2', + calmType: 'service', + label: 'Local Service', + description: 'A locally defined service', + interfaces: [], + controls: { 'app-id': { description: 'App ID' } }, + metadata: { 'source-building-block': 'microservice' }, + }, + }, + ]; + const edges: Edge[] = []; + + const arch = flowToCalm(nodes, edges); + + expect(arch.nodes).toHaveLength(1); + const calmNode = arch.nodes[0]; + expect(calmNode['definition-id']).toBeUndefined(); + expect(calmNode.controls).toEqual({ + 'app-id': { description: 'App ID' }, + }); + expect(calmNode.metadata).toEqual({ + 'source-building-block': 'microservice', + }); + }); + + it('handles a mix of Hub-sourced and local nodes', () => { + const nodes: Node[] = [ + { + id: 'hub-node', + type: 'service', + position: { x: 0, y: 0 }, + data: { + calmId: 'hub-node', + calmType: 'service', + label: 'Hub Node', + description: '', + 'definition-id': 'acme:building-blocks:api-gateway@sha256', + }, + }, + { + id: 'local-node', + type: 'database', + position: { x: 200, y: 0 }, + data: { + calmId: 'local-node', + calmType: 'database', + label: 'Local DB', + description: '', + controls: { encryption: { description: 'AES256' } }, + metadata: { 'source-building-block': 'rds' }, + }, + }, + ]; + const edges: Edge[] = []; + + const arch = flowToCalm(nodes, edges); + + expect(arch.nodes).toHaveLength(2); + + const hubCalm = arch.nodes.find( + (n) => n['unique-id'] === 'hub-node' + )!; + expect(hubCalm['definition-id']).toBe( + 'acme:building-blocks:api-gateway@sha256' + ); + expect(hubCalm.controls).toBeUndefined(); + expect(hubCalm.metadata).toBeUndefined(); + + const localCalm = arch.nodes.find( + (n) => n['unique-id'] === 'local-node' + )!; + expect(localCalm['definition-id']).toBeUndefined(); + expect(localCalm.controls).toEqual({ + encryption: { description: 'AES256' }, + }); + expect(localCalm.metadata).toEqual({ + 'source-building-block': 'rds', + }); + }); + + it('preserves details on Hub-sourced nodes', () => { + const nodes: Node[] = [ + { + id: 'n1', + type: 'system', + position: { x: 0, y: 0 }, + data: { + calmId: 'n1', + calmType: 'system', + label: 'System', + description: '', + 'definition-id': 'org:building-blocks:platform@v1', + details: { 'detailed-architecture': 'sub-arch.calm.json' }, + }, + }, + ]; + + const arch = flowToCalm(nodes, []); + const calmNode = arch.nodes[0]; + expect(calmNode['definition-id']).toBe( + 'org:building-blocks:platform@v1' + ); + expect(calmNode.details).toEqual({ + 'detailed-architecture': 'sub-arch.calm.json', + }); + }); +}); + +describe('parseCurie (via canvas-panel)', () => { + // Test the CURIE format that the drop handler generates + it('generates correct CURIE format from namespace:type:slug@sha', () => { + const namespace = 'finos'; + const id = 'microservice'; + const sha = 'abc123def'; + const curie = `${namespace}:building-blocks:${id}@${sha}`; + expect(curie).toBe('finos:building-blocks:microservice@abc123def'); + }); +}); diff --git a/calm-plugins/vscode/src/webview/transforms/calm-editor-transformer.ts b/calm-plugins/vscode/src/webview/transforms/calm-editor-transformer.ts index c4925cbf2..ec860fb97 100644 --- a/calm-plugins/vscode/src/webview/transforms/calm-editor-transformer.ts +++ b/calm-plugins/vscode/src/webview/transforms/calm-editor-transformer.ts @@ -72,10 +72,20 @@ export function flowToCalm( name: data.label ?? '', description: data.description ?? '', }; + if (data['definition-id']) { + calmNode['definition-id'] = data['definition-id']; + } if (data.interfaces) calmNode.interfaces = data.interfaces; - if (data.controls) calmNode.controls = data.controls; + if (data.controls && Object.keys(data.controls as object).length > 0) { + if (data['definition-id']) { + // For ref-based nodes: only save requirement-url + config (strip resolved descriptions) + calmNode.controls = stripResolvedNoise(data.controls as Record); + } else { + calmNode.controls = data.controls; + } + } if (data.details) calmNode.details = data.details; - if (data.metadata) calmNode.metadata = data.metadata; + if (data.metadata && !data['definition-id']) calmNode.metadata = data.metadata; calmNodes.push(calmNode); } @@ -216,3 +226,25 @@ function buildRelationshipType(variant: string, source: string, target: string): default: return { [variant]: { source: { node: source }, destination: { node: target } } }; } } + +function stripResolvedNoise(controls: Record): Record { + const stripped: Record = {}; + for (const [key, value] of Object.entries(controls)) { + const ctrl = value as Record | undefined; + if (!ctrl) continue; + const clean: Record = {}; + // Keep only requirements (with requirement-url + config), drop description and other resolved fields + if (ctrl.requirements && Array.isArray(ctrl.requirements)) { + clean.requirements = (ctrl.requirements as Array>).map((req) => { + const r: Record = {}; + if (req['requirement-url']) r['requirement-url'] = req['requirement-url']; + if (req.config) r.config = req.config; + return r; + }); + } + if (Object.keys(clean).length > 0) { + stripped[key] = clean; + } + } + return stripped; +} diff --git a/calm-plugins/vscode/src/webview/transforms/calm-parser.ts b/calm-plugins/vscode/src/webview/transforms/calm-parser.ts index 5eabdf798..dfdbe04d6 100644 --- a/calm-plugins/vscode/src/webview/transforms/calm-parser.ts +++ b/calm-plugins/vscode/src/webview/transforms/calm-parser.ts @@ -102,6 +102,7 @@ export function parseCALMData( controls: n.controls, details: n.details, metadata: n.metadata, + 'definition-id': n['definition-id'], containmentType: containerVariant.get(id), }, ...(parent ? { parentId: parent, extent: 'parent' as const, zIndex: computeDepth(parent, parentMap) } : {}), diff --git a/calm-plugins/vscode/src/webview/utils/controls.ts b/calm-plugins/vscode/src/webview/utils/controls.ts index eac975562..1872b5164 100644 --- a/calm-plugins/vscode/src/webview/utils/controls.ts +++ b/calm-plugins/vscode/src/webview/utils/controls.ts @@ -1,3 +1,6 @@ +// TODO: Replace this local implementation with a re-export from @finos/calm-shared +// once the vscode plugin adds @finos/calm-shared as a dependency. +// The canonical implementation lives in shared/src/controls/merge-controls.ts. export function mergeControls( existing: Record, incoming: Record, diff --git a/calm-plugins/vscode/src/webview/utils/validation.ts b/calm-plugins/vscode/src/webview/utils/validation.ts index 688f7c242..7a647620e 100644 --- a/calm-plugins/vscode/src/webview/utils/validation.ts +++ b/calm-plugins/vscode/src/webview/utils/validation.ts @@ -30,9 +30,19 @@ export function validateControlConfig( nodeId?: string ): ValidationIssue[] { const validation = control?.metadata?.validation; - if (!validation) return []; - + const requirementUrl = control?.requirements?.[0]?.['requirement-url'] as string | undefined; const value = control?.requirements?.[0]?.config?.value ?? ''; + + // If control has a requirement-url (CURIE) but no config value, flag as unconfigured + if (!validation && requirementUrl && !value) { + return [{ + severity: 'error', + message: `Control "${controlId}" on ${scopeLabel} is not configured`, + nodeId, + controlId, + }]; + } + if (!validation) return []; const allowed = validation['allowed-values']; const pattern = validation.pattern; const issues: ValidationIssue[] = []; diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index cd8555656..61f1e94c4 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -5,6 +5,36 @@ All notable changes to the CALM CLI will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.57.0] - 2026-08-24 + +## [1.57.0](https://github.com/finos/architecture-as-code/compare/cli-v1.56.0...cli-v1.57.0) (2026-08-24) + +### chore + +* **deps:** update semgrep/semgrep docker digest to 6731995 ([](https://github.com/finos/architecture-as-code/commit/92ed20c6b07b93c23bc790af41738a5f193cba9d)) +* **deps:** update semgrep/semgrep docker digest to 6731995 ([](https://github.com/finos/architecture-as-code/commit/006b71634b38ff805850804a75159583b40d1885)) + +### docs + +* add guidance on replying to review feedback ([](https://github.com/finos/architecture-as-code/commit/34044c3b5604f7e03f11f9198a5ecae10823c0dc)) +* correct monorepo structure in root AGENTS.md ([](https://github.com/finos/architecture-as-code/commit/fb57bcaacf32ab9650ca4ec9b76b8e0f06d6afb3)) +* correct release rules, Maven modules and CalmStudio package list ([](https://github.com/finos/architecture-as-code/commit/0da54a85c0aa8236cb5505686708c7213406c52f)) +* reword prose notes ([](https://github.com/finos/architecture-as-code/commit/0cc79cc34c83158a57b16c499e4b285b2da55a3c)) +* trim root AGENTS.md and move package guidance to its own guides ([](https://github.com/finos/architecture-as-code/commit/79cbda492b5844680639b1eee4567800fed2c3c8)) +* trim root AGENTS.md, correct structure notes, and add docs writing guidelines ([](https://github.com/finos/architecture-as-code/commit/67dab7d5907e89e796427444b462756c51d3ea1e)) + +### feat + +* **cli:** add missing hub push/pull/list interface subcommands ([](https://github.com/finos/architecture-as-code/commit/88f264e8ca019ff3cdeb673f4ee276c0a63ef0f0)) +* **cli:** add missing hub push/pull/list interface subcommands ([](https://github.com/finos/architecture-as-code/commit/48f01b05261f7769b41bb3bdef30818a4d51ba28)) +* **vscode-plugin:** bug fix requirment url to be shown for free text controls (#3000) ([](https://github.com/finos/architecture-as-code/commit/86113ec509c5c16f6600afcc7ff874ba4382489a)), closes [#3000](https://github.com/finos/architecture-as-code/issues/3000) + +### fix + +* **cli:** emit POSIX-style paths in timeline references and bundle manifest ([](https://github.com/finos/architecture-as-code/commit/08c4b803851bf5283e26d241f36148c9dcdc7685)) +* **shared:** emit POSIX-style paths in generated front matter ([](https://github.com/finos/architecture-as-code/commit/d44f3a4288d357420432d097243f020e8672300b)) +* **shared:** emit POSIX-style paths in generated front matter ([](https://github.com/finos/architecture-as-code/commit/5896ec02c94cebec6b710ff332542fc9bef09ce6)), closes [#3008](https://github.com/finos/architecture-as-code/issues/3008) + ## [1.56.0] - 2026-08-17 ## [1.56.0](https://github.com/finos/architecture-as-code/compare/cli-v1.55.0...cli-v1.56.0) (2026-08-17) diff --git a/cli/package.json b/cli/package.json index 540082c8b..331ee4675 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@finos/calm-cli", - "version": "1.56.0", + "version": "1.57.0", "description": "A set of tools for interacting with the Common Architecture Language Model (CALM)", "homepage": "https://calm.finos.org", "repository": { diff --git a/cli/src/browser-manifest.spec.ts b/cli/src/browser-manifest.spec.ts new file mode 100644 index 000000000..542d78b26 --- /dev/null +++ b/cli/src/browser-manifest.spec.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { Command } from 'commander'; +import { setupCLI } from './cli'; +import { BROWSER_COMMAND_SUPPORT } from '@finos/calm-shared/browser'; + +function registeredCommandKeys(): string[] { + const program = new Command(); + setupCLI(program); + const keys: string[] = []; + for (const command of program.commands) { + if (command.name() === 'hub') { + for (const sub of command.commands) { + keys.push(`hub ${sub.name()}`); + } + } else { + keys.push(command.name()); + } + } + return keys.sort(); +} + +describe('browser capability manifest matches the CLI', () => { + // Granularity: top-level commands plus the `hub` subgroups (`hub pull`, `hub list`, ...); + // `workspace` subcommands are intentionally not enumerated, they're covered by the single + // `workspace` entry. + it('lists every registered command exactly once', () => { + const manifest = BROWSER_COMMAND_SUPPORT.map((entry) => entry.command).sort(); + expect(manifest).toEqual(registeredCommandKeys()); + }); +}); diff --git a/cli/src/cli-config.ts b/cli/src/cli-config.ts index 27bad9cf0..bcf71c5e8 100644 --- a/cli/src/cli-config.ts +++ b/cli/src/cli-config.ts @@ -5,6 +5,31 @@ import { homedir } from 'os'; import { join } from 'path'; import { pathToFileURL } from 'url'; +// TODO: Add built-in CalmHub authentication for the CLI. +// Currently auth uses a pluggable AuthPlugin (.js file) — works but requires custom code. +// +// Two modes needed: +// +// A) Developer workstation (interactive, one-time): +// `calm login` — OIDC Auth Code + PKCE via localhost callback (like VS Code plugin), +// chains into GitHub OAuth via Hub's PluginAuthResource, stores tokens in +// ~/.calm/credentials (encrypted or OS keychain). Subsequent commands auto-inject headers. +// +// B) CI / non-interactive (the harder problem): +// No browser, no human. Options to explore: +// - OAuth2 Client Credentials grant (machine-to-machine app registration in IdP) +// - Pre-obtained token via env var (CALM_HUB_TOKEN) — simplest, user provisions externally +// - GitHub Actions OIDC: runner gets a JWT from GitHub's OIDC provider, Hub trusts it +// as a federated identity (Azure Workload Identity Federation / similar) +// - GitHub App installation token: Hub accepts a GitHub App token directly for API access +// - Service principal / managed identity: Azure MI / AWS IAM Role → token exchange +// +// The Hub needs to support at least one non-interactive grant type. Client Credentials +// is the most universal (works in any CI). GitHub Actions OIDC is zero-secret but +// GitHub-specific. Both may be needed. +// +// Hub-side: PluginAuthResource (or a new /api/calm/auth/token endpoint) must accept +// client_credentials or token-exchange grants alongside the existing browser-based flow. export interface CLIConfig { calmHubUrl?: string allowedRemoteHosts?: string[] diff --git a/cli/src/cli.e2e.spec.ts b/cli/src/cli.e2e.spec.ts index 8f064779b..1637ec483 100644 --- a/cli/src/cli.e2e.spec.ts +++ b/cli/src/cli.e2e.spec.ts @@ -2,7 +2,7 @@ import { execSync } from 'child_process'; import path from 'path'; import * as fs from 'fs'; import { parseStringPromise } from 'xml2js'; -import { expectDirectoryMatch, expectFilesMatch } from '@finos/calm-shared'; +import { expectDirectoryMatch, expectFilesMatch } from '../../shared/src/test/file-comparison'; import { installPackedCli, type CliInstall } from './test_helpers/cli-runner'; import { patchJson } from './test_helpers/json-file'; import { STATIC_GETTING_STARTED_MAPPING_PATH } from './test_helpers/getting-started-url-mapping'; diff --git a/cli/src/cli.spec.ts b/cli/src/cli.spec.ts index 012e024c4..f2ab385c6 100644 --- a/cli/src/cli.spec.ts +++ b/cli/src/cli.spec.ts @@ -14,8 +14,7 @@ let templateModule: typeof import('./command-helpers/template'); let optionsModule: typeof import('./command-helpers/generate-options'); let diffModule: typeof import('./command-helpers/diff'); let hubCommandsModule: typeof import('./command-helpers/hub-commands'); -let _fileSystemDocLoaderModule: typeof import('@finos/calm-shared/dist/document-loader/file-system-document-loader'); -let documentLoaderModule: typeof import('../../shared/src/document-loader/document-loader'); +let documentLoaderModule: typeof import('../../shared/src/document-loader/node-document-loader'); let setupCLI: typeof import('./cli').setupCLI; let cliConfigModule: typeof import('./cli-config'); @@ -32,8 +31,7 @@ describe('CLI Commands', () => { templateModule = await import('./command-helpers/template'); optionsModule = await import('./command-helpers/generate-options'); diffModule = await import('./command-helpers/diff'); - _fileSystemDocLoaderModule = await import('@finos/calm-shared/dist/document-loader/file-system-document-loader'); - documentLoaderModule = await import('../../shared/src/document-loader/document-loader'); + documentLoaderModule = await import('../../shared/src/document-loader/node-document-loader'); vi.spyOn(calmShared, 'runGenerate').mockResolvedValue(undefined); vi.spyOn(calmShared.TemplateProcessor.prototype, 'processTemplate').mockResolvedValue(undefined); diff --git a/cli/src/cli.ts b/cli/src/cli.ts index bddbfa291..48d5743a7 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -128,6 +128,7 @@ Validation requires: .option(TIMELINE_OPTION, 'Path to the timeline file to validate. May be a file path or a URL.') .option(SCHEMAS_OPTION, 'Path to the directory containing the meta schemas to use.', CALM_META_SCHEMA_DIRECTORY) .option(CALMHUB_URL_OPTION, 'URL to CALMHub instance') + .option('--assets-path ', 'Local path to CALM assets directory (resolves CURIEs without a Hub)') .option(URL_MAPPING_OPTION, 'Path to mapping file which maps URLs to local paths') .option(STRICT_OPTION, 'When run in strict mode, the CLI will fail if any warnings are reported.', false) .addOption( @@ -146,6 +147,7 @@ Validation requires: timelinePath: options.timeline, metaSchemaPath: options.schemaDirectory, calmHubUrl: options.calmHubUrl, + assetsPath: options.assetsPath, urlToLocalFileMapping: options.urlToLocalFileMapping, verbose: !!options.verbose, strict: options.strict, diff --git a/cli/src/command-helpers/ai-tools.ts b/cli/src/command-helpers/ai-tools.ts index acb5ac5dc..f4f3b1b3b 100644 --- a/cli/src/command-helpers/ai-tools.ts +++ b/cli/src/command-helpers/ai-tools.ts @@ -1,5 +1,5 @@ import { initLogger } from '@finos/calm-shared'; -import { Logger } from '@finos/calm-shared/src/logger.js'; +import { Logger } from '@finos/calm-shared'; import { mkdir, writeFile, readFile, stat } from 'fs/promises'; import { dirname, join, resolve } from 'path'; diff --git a/cli/src/command-helpers/hub-commands.spec.ts b/cli/src/command-helpers/hub-commands.spec.ts index fe7fc3f6e..c862cfbbc 100644 --- a/cli/src/command-helpers/hub-commands.spec.ts +++ b/cli/src/command-helpers/hub-commands.spec.ts @@ -14,11 +14,7 @@ import { runCreateNamespace, runListArchitectures, runListNamespaces, // We stub the @finos/calm-shared HTTP client so no real HTTP is made, but keep the // real (pure) document-id-utils helpers that orchestratePush relies on. vi.mock('@finos/calm-shared', async () => { - const documentIdUtils = await vi.importActual>('@finos/calm-shared/dist/hub/document-id-utils'); - // Real (pure) semver helpers used by pushDocument's version-bump path. - const semver = await vi.importActual('@finos/calm-shared/dist/hub/semver'); - // Real (pure) canonical-equality helper used by pushDocument's fail-if-modified path. - const canonical = await vi.importActual('@finos/calm-shared/dist/hub/canonical'); + const actual = await vi.importActual('@finos/calm-shared'); const mockClient = { createNamespace: vi.fn(), listNamespaces: vi.fn(), @@ -41,10 +37,11 @@ vi.mock('@finos/calm-shared', async () => { createControlConfigurationVersion: vi.fn() }; return { - ...documentIdUtils, - extractDocumentMetadata: vi.fn(documentIdUtils['extractDocumentMetadata'] as (...args: unknown[]) => unknown), - ...semver, - ...canonical, + // Keep all the real (pure) helpers — document-id-utils, semver and canonical — that + // orchestratePush relies on, then override just the HTTP-touching pieces below. The + // vi.fn(...) overrides must come after this spread, or the spread would clobber them. + ...actual, + extractDocumentMetadata: vi.fn(actual.extractDocumentMetadata), CalmHubClient: vi.fn(function () { return mockClient; }), HubClientError: class HubClientError extends Error { constructor(public status: number, public error: string, public request: string) { diff --git a/cli/src/command-helpers/validate.spec.ts b/cli/src/command-helpers/validate.spec.ts index b13fe0107..64c22e480 100644 --- a/cli/src/command-helpers/validate.spec.ts +++ b/cli/src/command-helpers/validate.spec.ts @@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => ({ processExit: vi.fn(), mkdirpSync: vi.fn(), writeFileSync: vi.fn(), + readFileSync: vi.fn(), parseDocumentLoaderConfig: vi.fn(), buildDocumentLoader: vi.fn(function () { return { loadMissingDocument: mocks.loadMissingDocument @@ -49,6 +50,7 @@ vi.mock('mkdirp', () => ({ vi.mock('fs', () => ({ ...vi.importActual('fs'), writeFileSync: mocks.writeFileSync, + readFileSync: mocks.readFileSync, })); vi.mock('../cli', async () => ({ @@ -69,6 +71,7 @@ describe('runValidate', () => { process.exit = mocks.processExit as any; mocks.parseDocumentLoaderConfig.mockResolvedValue({}); + mocks.readFileSync.mockImplementation(function () { throw new Error('file not found'); }); // Inline mock for loadMissingDocument mocks.loadMissingDocument.mockImplementation(function (filePath: string, _: string) { if (filePath === 'arch.json') return Promise.resolve(dummyArch); @@ -107,7 +110,7 @@ describe('runValidate', () => { expect(mocks.loadSchemas).toHaveBeenCalled(); expect(mocks.loadMissingDocument).toHaveBeenCalledWith('arch.json', 'architecture'); expect(mocks.loadMissingDocument).toHaveBeenCalledWith('pattern.json', 'pattern'); - expect(validate).toHaveBeenCalledWith(dummyArch, dummyPattern, undefined, expect.anything(), true); + expect(validate).toHaveBeenCalledWith(dummyArch, dummyPattern, undefined, expect.anything(), true, expect.anything()); expect(getFormattedOutput).toHaveBeenCalledWith(fakeOutcome, 'json', expect.anything()); expect(exitBasedOffOfValidationOutcome).toHaveBeenCalledWith(fakeOutcome, false); @@ -131,7 +134,7 @@ describe('runValidate', () => { expect(mocks.loadSchemas).toHaveBeenCalled(); expect(mocks.loadMissingDocument).toHaveBeenCalledWith('arch.json', 'architecture'); - expect(validate).toHaveBeenCalledWith(dummyArch, undefined, undefined, expect.anything(), true); + expect(validate).toHaveBeenCalledWith(dummyArch, undefined, undefined, expect.anything(), true, expect.anything()); expect(getFormattedOutput).toHaveBeenCalledWith(fakeOutcome, 'json', expect.anything()); expect(exitBasedOffOfValidationOutcome).toHaveBeenCalledWith(fakeOutcome, false); @@ -159,7 +162,7 @@ describe('runValidate', () => { expect(mocks.getSchema).toHaveBeenCalledWith(resolvedPatternPath); expect(mocks.loadMissingDocument).toHaveBeenCalledWith('arch-of-pattern.json', 'architecture'); expect(mocks.loadMissingDocument).toHaveBeenCalledWith(resolvedPatternPath, 'pattern'); - expect(validate).toHaveBeenCalledWith(dummyArchOfAPattern, dummyPattern, undefined, expect.anything(), true); + expect(validate).toHaveBeenCalledWith(dummyArchOfAPattern, dummyPattern, undefined, expect.anything(), true, expect.anything()); expect(getFormattedOutput).toHaveBeenCalledWith(fakeOutcome, 'json', expect.anything()); expect(exitBasedOffOfValidationOutcome).toHaveBeenCalledWith(fakeOutcome, false); @@ -187,7 +190,7 @@ describe('runValidate', () => { expect(mocks.getSchema).toHaveBeenCalledWith(resolvedSchemaPath); expect(mocks.loadMissingDocument).toHaveBeenCalledWith('arch-of-calm.json', 'architecture'); expect(mocks.loadMissingDocument).toHaveBeenCalledOnce(); - expect(validate).toHaveBeenCalledWith(dummyArchOfCalmSchema, dummyCalmSchema, undefined, expect.anything(), true); + expect(validate).toHaveBeenCalledWith(dummyArchOfCalmSchema, dummyCalmSchema, undefined, expect.anything(), true, expect.anything()); expect(getFormattedOutput).toHaveBeenCalledWith(fakeOutcome, 'json', expect.anything()); expect(exitBasedOffOfValidationOutcome).toHaveBeenCalledWith(fakeOutcome, false); @@ -211,7 +214,7 @@ describe('runValidate', () => { expect(mocks.loadSchemas).toHaveBeenCalled(); expect(mocks.loadMissingDocument).toHaveBeenCalledWith('pattern.json', 'pattern'); - expect(validate).toHaveBeenCalledWith(undefined, dummyPattern, undefined, expect.anything(), true); + expect(validate).toHaveBeenCalledWith(undefined, dummyPattern, undefined, expect.anything(), true, expect.anything()); expect(getFormattedOutput).toHaveBeenCalledWith(fakeOutcome, 'json', expect.anything()); expect(exitBasedOffOfValidationOutcome).toHaveBeenCalledWith(fakeOutcome, false); @@ -239,7 +242,7 @@ describe('runValidate', () => { const resolvedSchemaPath = path.resolve(process.cwd(), 'calm-timeline-schema.json'); expect(mocks.getSchema).toHaveBeenCalledWith(resolvedSchemaPath); expect(mocks.loadMissingDocument).toHaveBeenCalledWith('timeline.json', 'timeline'); - expect(validate).toHaveBeenCalledWith(undefined, dummyCalmTimelineSchema, dummyTimeline, expect.anything(), true); + expect(validate).toHaveBeenCalledWith(undefined, dummyCalmTimelineSchema, dummyTimeline, expect.anything(), true, expect.anything()); expect(getFormattedOutput).toHaveBeenCalledWith(fakeOutcome, 'json', expect.anything()); expect(exitBasedOffOfValidationOutcome).toHaveBeenCalledWith(fakeOutcome, false); @@ -247,6 +250,29 @@ describe('runValidate', () => { expect(writeFileSync).toHaveBeenCalledWith('out.json', 'formatted output'); }); + it('should pass CURIE resolver chain when calmHubUrl and assetsPath are provided', async () => { + const validJson = JSON.stringify({ nodes: [] }); + mocks.readFileSync.mockReturnValue(validJson); + + const options: ValidateOptions = { + architecturePath: 'arch.json', + patternPath: 'pattern.json', + metaSchemaPath: 'schemas', + calmHubUrl: 'https://hub.example.com', + assetsPath: '/tmp/assets', + verbose: false, + outputFormat: 'json', + outputPath: 'out.json', + strict: false, + }; + + await runValidate(options); + + expect(validate).toHaveBeenCalledWith( + dummyArch, dummyPattern, undefined, expect.anything(), false, expect.anything() + ); + }); + it('should exit 1 when neither architecture, pattern, nor timeline is resolved', async () => { await runValidate({ architecturePath: undefined, diff --git a/cli/src/command-helpers/validate.ts b/cli/src/command-helpers/validate.ts index 9063ff841..e01d54151 100644 --- a/cli/src/command-helpers/validate.ts +++ b/cli/src/command-helpers/validate.ts @@ -1,4 +1,4 @@ -import { getFormattedOutput, validate, exitBasedOffOfValidationOutcome, ValidationFormattingOptions, loadArchitectureAndPattern, loadTimeline, enrichWithDocumentPositions, ParsedDocumentContext, initLogger, ValidateOutputFormat, buildDocumentLoader, DocumentLoader, Logger } from '@finos/calm-shared'; +import { getFormattedOutput, validate, exitBasedOffOfValidationOutcome, ValidationFormattingOptions, loadArchitectureAndPattern, loadTimeline, enrichWithDocumentPositions, ParsedDocumentContext, initLogger, ValidateOutputFormat, buildDocumentLoader, DocumentLoader, Logger, CalmReferenceResolver, ChainReferenceResolver, LocalCurieReferenceResolver, ShaCacheReferenceResolver, CurieReferenceResolver, HttpReferenceResolver } from '@finos/calm-shared'; import path from 'path'; import { mkdirp } from 'mkdirp'; import { readFileSync, writeFileSync } from 'fs'; @@ -12,6 +12,7 @@ export interface ValidateOptions { timelinePath?: string; metaSchemaPath: string; calmHubUrl?: string; + assetsPath?: string; urlToLocalFileMapping?: string; verbose: boolean; strict: boolean; @@ -59,7 +60,8 @@ export async function runValidate(options: ValidateOptions) { if (!architecture && !pattern && !timeline) { throw new Error('You must provide an architecture, a pattern, or a timeline'); } - const outcome = await validate(architecture, pattern, timeline, schemaDirectory, options.verbose); + const curieResolver = buildCurieResolverChain(options); + const outcome = await validate(architecture, pattern, timeline, schemaDirectory, options.verbose, curieResolver); enrichWithDocumentPositions(outcome, documentContexts); const content = getFormattedOutput(outcome, options.outputFormat, toFormattingOptions(documentContexts)); writeOutputFile(options.outputPath, content); @@ -77,6 +79,33 @@ export async function runValidate(options: ValidateOptions) { +/** + * Build the CURIE resolution chain based on CLI options. + * Order: SHA cache → local path → Hub → HTTP (absolute URLs) + * Returns undefined if no CURIE resolution is configured. + */ +function buildCurieResolverChain(options: ValidateOptions): CalmReferenceResolver | undefined { + const resolvers: CalmReferenceResolver[] = []; + + // SHA cache is always available (offline-first) + resolvers.push(new ShaCacheReferenceResolver()); + + // Local assets path (resolves CURIEs without a Hub) + if (options.assetsPath) { + resolvers.push(new LocalCurieReferenceResolver(path.resolve(options.assetsPath))); + } + + // CalmHub (expand CURIE to Hub URL and fetch) + if (options.calmHubUrl) { + resolvers.push(new CurieReferenceResolver(options.calmHubUrl, new HttpReferenceResolver())); + } + + // HTTP resolver for absolute URLs in requirement-url + resolvers.push(new HttpReferenceResolver()); + + return resolvers.length > 0 ? new ChainReferenceResolver(resolvers) : undefined; +} + export function writeOutputFile(output: string, validationsOutput: string) { if (output) { const dirname = path.dirname(output); diff --git a/cli/src/command-helpers/workspace/bump.spec.ts b/cli/src/command-helpers/workspace/bump.spec.ts index c0490e1a1..7f69aa571 100644 --- a/cli/src/command-helpers/workspace/bump.spec.ts +++ b/cli/src/command-helpers/workspace/bump.spec.ts @@ -1,11 +1,12 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import { detectChangedResources, bumpWorkspace, canonicalEqual, maxIncrement } from './bump'; import { saveManifest } from './bundle'; -import { CalmHubClient, ResourceChangeType } from '@finos/calm-shared/src/hub/calm-hub-client'; +import { CalmHubClient, ResourceChangeType } from '@finos/calm-shared'; import { mkdir, writeFile, rm, readFile } from 'fs/promises'; import path from 'path'; -vi.mock('@finos/calm-shared/src/logger', () => ({ +vi.mock('@finos/calm-shared', async (importOriginal) => ({ + ...(await importOriginal()), initLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), })); diff --git a/cli/src/command-helpers/workspace/bump.ts b/cli/src/command-helpers/workspace/bump.ts index 473dc5cd5..67bf71dbd 100644 --- a/cli/src/command-helpers/workspace/bump.ts +++ b/cli/src/command-helpers/workspace/bump.ts @@ -2,15 +2,18 @@ import { readFile, writeFile } from 'fs/promises'; import { existsSync } from 'fs'; import { loadManifest, resolveFilePath } from './bundle'; import { buildRefRulesFromDiskIds, syncReferences, RefUpdateResult } from './ref-rewrite'; -import { CalmHubClient, ResourceChangeType } from '@finos/calm-shared/src/hub/calm-hub-client'; import { + CalmHubClient, + ResourceChangeType, DocumentMetadata, extractDocumentMetadata, constructDocumentId, -} from '@finos/calm-shared/src/hub/document-id-utils'; -import { computeSemVerBump, sortSemVer } from '@finos/calm-shared/src/hub/semver'; -import { canonicalEqual } from '@finos/calm-shared/src/hub/canonical'; -import { initLogger, Logger } from '@finos/calm-shared/src/logger'; + computeSemVerBump, + sortSemVer, + canonicalEqual, + initLogger, + Logger, +} from '@finos/calm-shared'; // Re-exported for existing consumers (push.ts, tests) that import it from here. export { canonicalEqual }; diff --git a/cli/src/command-helpers/workspace/commands.spec.ts b/cli/src/command-helpers/workspace/commands.spec.ts index a9d75bf57..eb8962769 100644 --- a/cli/src/command-helpers/workspace/commands.spec.ts +++ b/cli/src/command-helpers/workspace/commands.spec.ts @@ -93,19 +93,10 @@ vi.mock('../../cli-config', () => ({ loadAuthPlugin: mocks.loadAuthPlugin, })); -vi.mock('@finos/calm-shared/src/hub/calm-hub-client', () => ({ - CalmHubClient: mocks.CalmHubClient, -})); - vi.mock('./document-id-prompt', () => ({ promptForDocumentId: mocks.promptForDocumentId, })); -vi.mock('@finos/calm-shared/src/hub/document-id-utils', () => ({ - isConformantDocumentId: mocks.isConformantDocumentId, - namespaceFromDocumentId: mocks.namespaceFromDocumentId, -})); - vi.mock('fs/promises', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, readFile: mocks.readFile, writeFile: mocks.writeFile }; @@ -116,7 +107,11 @@ vi.mock('@inquirer/prompts', () => ({ input: mocks.input, })); -vi.mock('@finos/calm-shared/src/logger', () => ({ +vi.mock('@finos/calm-shared', async (importOriginal) => ({ + ...(await importOriginal()), + CalmHubClient: mocks.CalmHubClient, + isConformantDocumentId: mocks.isConformantDocumentId, + namespaceFromDocumentId: mocks.namespaceFromDocumentId, initLogger: () => ({ info: vi.fn(), warn: vi.fn(), diff --git a/cli/src/command-helpers/workspace/commands.ts b/cli/src/command-helpers/workspace/commands.ts index 62144d0a4..9dbc36dca 100644 --- a/cli/src/command-helpers/workspace/commands.ts +++ b/cli/src/command-helpers/workspace/commands.ts @@ -11,11 +11,9 @@ import { detectChangedResources, bumpWorkspace } from './bump'; import { runPostBumpValidation } from './post-bump-validate'; import { loadWorkspaceConfig } from './config'; import { findWorkspaceManifestPath, findGitRoot } from '../../workspace-resolver'; -import { initLogger, Logger } from '@finos/calm-shared/src/logger'; +import { initLogger, Logger, CalmHubClient, ResourceChangeType, isConformantDocumentId, namespaceFromDocumentId } from '@finos/calm-shared'; import { select, input } from '@inquirer/prompts'; import { CALM_DOCUMENT_TYPES_LIST, isValidCalmDocumentType } from '@finos/calm-models/types'; -import { CalmHubClient, ResourceChangeType } from '@finos/calm-shared/src/hub/calm-hub-client'; -import { isConformantDocumentId, namespaceFromDocumentId } from '@finos/calm-shared/src/hub/document-id-utils'; import { loadCliConfig } from '../../cli-config'; import { resolveCalmHubOptions } from '../hub-commands'; diff --git a/cli/src/command-helpers/workspace/config.ts b/cli/src/command-helpers/workspace/config.ts index e39113257..57bb95c43 100644 --- a/cli/src/command-helpers/workspace/config.ts +++ b/cli/src/command-helpers/workspace/config.ts @@ -1,7 +1,7 @@ import path from 'path'; import { readFile } from 'fs/promises'; import { existsSync } from 'fs'; -import type { ResourceChangeType } from '@finos/calm-shared/src/hub/calm-hub-client'; +import type { ResourceChangeType } from '@finos/calm-shared'; /** * Central, repo-level workspace configuration. Committed at diff --git a/cli/src/command-helpers/workspace/document-id-prompt.ts b/cli/src/command-helpers/workspace/document-id-prompt.ts index 304c40d37..31d5943d6 100644 --- a/cli/src/command-helpers/workspace/document-id-prompt.ts +++ b/cli/src/command-helpers/workspace/document-id-prompt.ts @@ -5,8 +5,9 @@ import { isConformantDocumentId, DocumentMetadata, ControlDocumentMetadata, -} from '@finos/calm-shared/src/hub/document-id-utils'; -import { RESOURCE_TYPES, ResourceType } from '@finos/calm-shared/src/hub/calm-hub-client'; + RESOURCE_TYPES, + ResourceType, +} from '@finos/calm-shared'; const DEFAULT_VERSION = '1.0.0'; diff --git a/cli/src/command-helpers/workspace/push.spec.ts b/cli/src/command-helpers/workspace/push.spec.ts index b00c6e85b..6b22e0f0c 100644 --- a/cli/src/command-helpers/workspace/push.spec.ts +++ b/cli/src/command-helpers/workspace/push.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; import { pushWorkspaceToHub } from './push'; import { loadManifest, saveManifest } from './bundle'; -import { CalmHubClient, HubClientError } from '@finos/calm-shared/src/hub/calm-hub-client'; +import { CalmHubClient, HubClientError } from '@finos/calm-shared'; import { mkdir, writeFile, rm } from 'fs/promises'; import path from 'path'; import { existsSync } from 'fs'; @@ -15,7 +15,8 @@ const makeClient = ( ...overrides, }) as unknown as CalmHubClient; -vi.mock('@finos/calm-shared/src/logger', () => ({ +vi.mock('@finos/calm-shared', async (importOriginal) => ({ + ...(await importOriginal()), initLogger: () => ({ info: vi.fn(), warn: vi.fn(), diff --git a/cli/src/command-helpers/workspace/push.ts b/cli/src/command-helpers/workspace/push.ts index 81ee2bf4e..a18a045b8 100644 --- a/cli/src/command-helpers/workspace/push.ts +++ b/cli/src/command-helpers/workspace/push.ts @@ -1,9 +1,7 @@ import { readFile } from 'fs/promises'; import { existsSync } from 'fs'; import { loadManifest, saveManifest, resolveFilePath } from './bundle'; -import { CalmHubClient } from '@finos/calm-shared/src/hub/calm-hub-client'; -import { DocumentMetadata, extractDocumentMetadata } from '@finos/calm-shared/src/hub/document-id-utils'; -import { initLogger, Logger } from '@finos/calm-shared/src/logger'; +import { CalmHubClient, DocumentMetadata, extractDocumentMetadata, initLogger, Logger } from '@finos/calm-shared'; import { canonicalEqual } from './bump'; const logger: Logger = initLogger(false, 'workspace'); diff --git a/cli/src/command-helpers/workspace/ref-rewrite.spec.ts b/cli/src/command-helpers/workspace/ref-rewrite.spec.ts index ac63d5975..da1b6b4ce 100644 --- a/cli/src/command-helpers/workspace/ref-rewrite.spec.ts +++ b/cli/src/command-helpers/workspace/ref-rewrite.spec.ts @@ -12,7 +12,8 @@ import path from 'path'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const loadJson = async (p: string): Promise => JSON.parse(await readFile(p, 'utf8')); -vi.mock('@finos/calm-shared/src/logger', () => ({ +vi.mock('@finos/calm-shared', async (importOriginal) => ({ + ...(await importOriginal()), initLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), })); diff --git a/cli/src/command-helpers/workspace/ref-rewrite.ts b/cli/src/command-helpers/workspace/ref-rewrite.ts index ed062db69..eef4122b3 100644 --- a/cli/src/command-helpers/workspace/ref-rewrite.ts +++ b/cli/src/command-helpers/workspace/ref-rewrite.ts @@ -1,7 +1,7 @@ import { readFile, writeFile } from 'fs/promises'; import { existsSync } from 'fs'; import { REFERENCE_PROPERTIES, WorkspaceManifest, resolveFilePath } from './bundle'; -import { initLogger, Logger } from '@finos/calm-shared/src/logger'; +import { initLogger, Logger } from '@finos/calm-shared'; const logger: Logger = initLogger(false, 'workspace'); diff --git a/cli/src/command-helpers/workspace/rm.ts b/cli/src/command-helpers/workspace/rm.ts index 4167f49a8..b75be4361 100644 --- a/cli/src/command-helpers/workspace/rm.ts +++ b/cli/src/command-helpers/workspace/rm.ts @@ -1,4 +1,4 @@ -import { initLogger } from '@finos/calm-shared/src/logger'; +import { initLogger } from '@finos/calm-shared'; import { loadManifest, saveManifest } from './bundle'; const logger = initLogger(false, 'workspace-rm'); diff --git a/cli/tsconfig.json b/cli/tsconfig.json index 1dc3e3b04..84f724692 100644 --- a/cli/tsconfig.json +++ b/cli/tsconfig.json @@ -1,8 +1,8 @@ { "extends": "../tsconfig.base.json", - "module": "Preserve", - "moduleResolution": "bundler", "compilerOptions": { + "module": "Preserve", + "moduleResolution": "bundler", "strict": true, "outDir": "dist", }, diff --git a/package-lock.json b/package-lock.json index 1cf2adeb1..7d0767ee4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1189,7 +1189,7 @@ }, "cli": { "name": "@finos/calm-cli", - "version": "1.55.0", + "version": "1.56.0", "license": "Apache-2.0", "dependencies": { "@apidevtools/json-schema-ref-parser": "^14.0.0", @@ -2458,15 +2458,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@asyncapi/specs": { - "version": "6.11.1", - "resolved": "https://registry.npmjs.org/@asyncapi/specs/-/specs-6.11.1.tgz", - "integrity": "sha512-A3WBLqAKGoJ2+6FWFtpjBlCQ1oFCcs4GxF7zsIGvNqp/klGUHjlA3aAcZ9XMMpLGE8zPeYDz2x9FmO6DSuKraQ==", - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.11" - } - }, "node_modules/@azu/format-text": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@azu/format-text/-/format-text-1.0.2.tgz", @@ -14715,81 +14706,6 @@ "node": ">=8" } }, - "node_modules/@stoplight/spectral-cli": { - "version": "6.16.2", - "resolved": "https://registry.npmjs.org/@stoplight/spectral-cli/-/spectral-cli-6.16.2.tgz", - "integrity": "sha512-1BwNCglpvCA2J+MpdXSPH56h69rARthf0QG/t2eeFbuqg12rcQEbGfAXs70mCRUAewiCzf1QqzeHF1tnabfS3A==", - "license": "Apache-2.0", - "dependencies": { - "@scarf/scarf": "^1.4.0", - "@stoplight/json": "~3.21.0", - "@stoplight/path": "1.3.2", - "@stoplight/spectral-core": "^1.19.5", - "@stoplight/spectral-formatters": "^1.4.1", - "@stoplight/spectral-parsers": "^1.0.4", - "@stoplight/spectral-ref-resolver": "^1.0.4", - "@stoplight/spectral-ruleset-bundler": "^1.6.0", - "@stoplight/spectral-ruleset-migrator": "^1.11.0", - "@stoplight/spectral-rulesets": ">=1", - "@stoplight/spectral-runtime": "^1.1.2", - "@stoplight/types": "^13.6.0", - "chalk": "4.1.2", - "fast-glob": "~3.2.12", - "hpagent": "~1.2.0", - "lodash": "^4.18.1", - "pony-cause": "^1.1.1", - "stacktracey": "^2.1.8", - "tslib": "^2.8.1", - "yargs": "~17.7.2" - }, - "bin": { - "spectral": "dist/index.js" - }, - "engines": { - "node": "^16.20 || ^18.18 || >= 20.17" - } - }, - "node_modules/@stoplight/spectral-cli/node_modules/@stoplight/types": { - "version": "13.20.0", - "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-13.20.0.tgz", - "integrity": "sha512-2FNTv05If7ib79VPDA/r9eUet76jewXFH2y2K5vuge6SXbRHtWBhcaRmu+6QpF4/WRNoJj5XYRSwLGXDxysBGA==", - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.4", - "utility-types": "^3.10.0" - }, - "engines": { - "node": "^12.20 || >=14.13" - } - }, - "node_modules/@stoplight/spectral-cli/node_modules/fast-glob": { - "version": "3.2.12", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", - "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/@stoplight/spectral-cli/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/@stoplight/spectral-core": { "version": "1.23.1", "resolved": "https://registry.npmjs.org/@stoplight/spectral-core/-/spectral-core-1.23.1.tgz", @@ -14897,70 +14813,6 @@ "node": "^16.20 || ^18.18 || >= 20.17" } }, - "node_modules/@stoplight/spectral-formatters": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/@stoplight/spectral-formatters/-/spectral-formatters-1.5.1.tgz", - "integrity": "sha512-mGXaiIrPglPokSnbFqbkWN3DoozIbwrZAA6OgqSIl+djeD5+e6PMELg0g6r3ot3ZzntO+6/GXaDnxEQ/p9M/EQ==", - "license": "Apache-2.0", - "dependencies": { - "@stoplight/path": "^1.3.2", - "@stoplight/spectral-core": "^1.19.4", - "@stoplight/spectral-runtime": "^1.1.2", - "@stoplight/types": "^13.15.0", - "@types/markdown-escape": "^1.1.3", - "chalk": "4.1.2", - "cliui": "7.0.4", - "lodash": "^4.18.1", - "markdown-escape": "^2.0.0", - "node-sarif-builder": "^2.0.3", - "strip-ansi": "6.0", - "text-table": "^0.2.0", - "tslib": "^2.8.1" - }, - "engines": { - "node": "^16.20 || ^18.18 || >= 20.17" - } - }, - "node_modules/@stoplight/spectral-formatters/node_modules/@stoplight/types": { - "version": "13.20.0", - "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-13.20.0.tgz", - "integrity": "sha512-2FNTv05If7ib79VPDA/r9eUet76jewXFH2y2K5vuge6SXbRHtWBhcaRmu+6QpF4/WRNoJj5XYRSwLGXDxysBGA==", - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.4", - "utility-types": "^3.10.0" - }, - "engines": { - "node": "^12.20 || >=14.13" - } - }, - "node_modules/@stoplight/spectral-formatters/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@stoplight/spectral-formatters/node_modules/node-sarif-builder": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-2.0.3.tgz", - "integrity": "sha512-Pzr3rol8fvhG/oJjIq2NTVB0vmdNNlz22FENhhPojYRZ4/ee08CfK4YuKmuL54V9MLhI1kpzxfOJ/63LzmZzDg==", - "license": "MIT", - "dependencies": { - "@types/sarif": "^2.1.4", - "fs-extra": "^10.0.0" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/@stoplight/spectral-functions": { "version": "1.10.5", "resolved": "https://registry.npmjs.org/@stoplight/spectral-functions/-/spectral-functions-1.10.5.tgz", @@ -15032,251 +14884,6 @@ "node": "^16.20 || ^18.18 || >= 20.17" } }, - "node_modules/@stoplight/spectral-ruleset-bundler": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@stoplight/spectral-ruleset-bundler/-/spectral-ruleset-bundler-1.7.0.tgz", - "integrity": "sha512-PpIdj5Wje0T7ktxY8EUzBWLU0+mGGQHznT8nlQxTMnRhWLNYsm6HvSZDXLtMi+86yqvTuf7loJy6JvLBDzHGAA==", - "license": "Apache-2.0", - "dependencies": { - "@rollup/plugin-commonjs": "~22.0.2", - "@stoplight/path": "1.3.2", - "@stoplight/spectral-core": ">=1", - "@stoplight/spectral-formats": "^1.8.1", - "@stoplight/spectral-functions": ">=1", - "@stoplight/spectral-parsers": ">=1", - "@stoplight/spectral-ref-resolver": "^1.0.4", - "@stoplight/spectral-ruleset-migrator": "^1.9.6", - "@stoplight/spectral-rulesets": ">=1", - "@stoplight/spectral-runtime": "^1.1.2", - "@stoplight/types": "^13.6.0", - "@types/node": "*", - "pony-cause": "1.1.1", - "rollup": "~2.80.0", - "tslib": "^2.8.1", - "validate-npm-package-name": "3.0.0" - }, - "engines": { - "node": "^16.20 || ^18.18 || >= 20.17" - } - }, - "node_modules/@stoplight/spectral-ruleset-bundler/node_modules/@rollup/plugin-commonjs": { - "version": "22.0.2", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-22.0.2.tgz", - "integrity": "sha512-//NdP6iIwPbMTcazYsiBMbJW7gfmpHom33u1beiIoHDEM0Q9clvtQB1T0efvMqHeKsGohiHo97BCPCkBXdscwg==", - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^3.1.0", - "commondir": "^1.0.1", - "estree-walker": "^2.0.1", - "glob": "^7.1.6", - "is-reference": "^1.2.1", - "magic-string": "^0.25.7", - "resolve": "^1.17.0" - }, - "engines": { - "node": ">= 12.0.0" - }, - "peerDependencies": { - "rollup": "^2.68.0" - } - }, - "node_modules/@stoplight/spectral-ruleset-bundler/node_modules/@rollup/pluginutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", - "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", - "license": "MIT", - "dependencies": { - "@types/estree": "0.0.39", - "estree-walker": "^1.0.1", - "picomatch": "^2.2.2" - }, - "engines": { - "node": ">= 8.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0" - } - }, - "node_modules/@stoplight/spectral-ruleset-bundler/node_modules/@rollup/pluginutils/node_modules/estree-walker": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", - "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", - "license": "MIT" - }, - "node_modules/@stoplight/spectral-ruleset-bundler/node_modules/@stoplight/types": { - "version": "13.20.0", - "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-13.20.0.tgz", - "integrity": "sha512-2FNTv05If7ib79VPDA/r9eUet76jewXFH2y2K5vuge6SXbRHtWBhcaRmu+6QpF4/WRNoJj5XYRSwLGXDxysBGA==", - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.4", - "utility-types": "^3.10.0" - }, - "engines": { - "node": "^12.20 || >=14.13" - } - }, - "node_modules/@stoplight/spectral-ruleset-bundler/node_modules/@types/estree": { - "version": "0.0.39", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", - "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", - "license": "MIT" - }, - "node_modules/@stoplight/spectral-ruleset-bundler/node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "license": "MIT" - }, - "node_modules/@stoplight/spectral-ruleset-bundler/node_modules/is-reference": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", - "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", - "license": "MIT", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/@stoplight/spectral-ruleset-bundler/node_modules/magic-string": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", - "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", - "license": "MIT", - "dependencies": { - "sourcemap-codec": "^1.4.8" - } - }, - "node_modules/@stoplight/spectral-ruleset-bundler/node_modules/rollup": { - "version": "2.80.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", - "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", - "license": "MIT", - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=10.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/@stoplight/spectral-ruleset-migrator": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@stoplight/spectral-ruleset-migrator/-/spectral-ruleset-migrator-1.12.1.tgz", - "integrity": "sha512-IUEbDmmTro0oF6VoAtrUySRV/b6bvYmV7wV6lB99f0Ym5lF9M2DXcgPLo7VMbKTPjCOQcaBzWRnIMXAyLjIRMA==", - "license": "Apache-2.0", - "dependencies": { - "@stoplight/json": "~3.21.0", - "@stoplight/ordered-object-literal": "~1.0.4", - "@stoplight/path": "1.3.2", - "@stoplight/spectral-functions": "^1.9.1", - "@stoplight/spectral-runtime": "^1.1.2", - "@stoplight/types": "^13.6.0", - "@stoplight/yaml": "~4.2.3", - "@types/node": "*", - "ajv": "^8.18.0", - "ast-types": "0.14.2", - "astring": "^1.9.0", - "reserved": "0.1.2", - "tslib": "^2.8.1", - "validate-npm-package-name": "3.0.0" - }, - "engines": { - "node": "^16.20 || ^18.18 || >= 20.17" - } - }, - "node_modules/@stoplight/spectral-ruleset-migrator/node_modules/@stoplight/types": { - "version": "13.20.0", - "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-13.20.0.tgz", - "integrity": "sha512-2FNTv05If7ib79VPDA/r9eUet76jewXFH2y2K5vuge6SXbRHtWBhcaRmu+6QpF4/WRNoJj5XYRSwLGXDxysBGA==", - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.4", - "utility-types": "^3.10.0" - }, - "engines": { - "node": "^12.20 || >=14.13" - } - }, - "node_modules/@stoplight/spectral-ruleset-migrator/node_modules/@stoplight/yaml": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@stoplight/yaml/-/yaml-4.2.3.tgz", - "integrity": "sha512-Mx01wjRAR9C7yLMUyYFTfbUf5DimEpHMkRDQ1PKLe9dfNILbgdxyrncsOXM3vCpsQ1Hfj4bPiGl+u4u6e9Akqw==", - "license": "Apache-2.0", - "dependencies": { - "@stoplight/ordered-object-literal": "^1.0.1", - "@stoplight/types": "^13.0.0", - "@stoplight/yaml-ast-parser": "0.0.48", - "tslib": "^2.2.0" - }, - "engines": { - "node": ">=10.8" - } - }, - "node_modules/@stoplight/spectral-ruleset-migrator/node_modules/@stoplight/yaml-ast-parser": { - "version": "0.0.48", - "resolved": "https://registry.npmjs.org/@stoplight/yaml-ast-parser/-/yaml-ast-parser-0.0.48.tgz", - "integrity": "sha512-sV+51I7WYnLJnKPn2EMWgS4EUfoP4iWEbrWwbXsj0MZCB/xOK8j6+C9fntIdOM50kpx45ZLC3s6kwKivWuqvyg==", - "license": "Apache-2.0" - }, - "node_modules/@stoplight/spectral-rulesets": { - "version": "1.22.6", - "resolved": "https://registry.npmjs.org/@stoplight/spectral-rulesets/-/spectral-rulesets-1.22.6.tgz", - "integrity": "sha512-xBwrb2zjx+7AzGS3aX7aOtddChRRw8aoQMu8ZT5AmTfEr0VAj4ydGC6Pl7lIvAIomcO7hw+P4I2oylTOOCkUVw==", - "license": "Apache-2.0", - "dependencies": { - "@asyncapi/specs": "^6.8.0", - "@scarf/scarf": "^1.4.0", - "@stoplight/better-ajv-errors": "1.0.3", - "@stoplight/json": "^3.17.0", - "@stoplight/spectral-core": "^1.23.0", - "@stoplight/spectral-formats": "^1.8.1", - "@stoplight/spectral-functions": "^1.9.1", - "@stoplight/spectral-runtime": "^1.1.2", - "@stoplight/types": "^13.6.0", - "@types/json-schema": "^7.0.7", - "ajv": "^8.18.0", - "ajv-formats": "~2.1.1", - "json-schema-traverse": "^1.0.0", - "leven": "3.1.0", - "lodash": "^4.18.1", - "tslib": "^2.8.1" - }, - "engines": { - "node": "^16.20 || ^18.18 || >= 20.17" - } - }, - "node_modules/@stoplight/spectral-rulesets/node_modules/@stoplight/types": { - "version": "13.20.0", - "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-13.20.0.tgz", - "integrity": "sha512-2FNTv05If7ib79VPDA/r9eUet76jewXFH2y2K5vuge6SXbRHtWBhcaRmu+6QpF4/WRNoJj5XYRSwLGXDxysBGA==", - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.4", - "utility-types": "^3.10.0" - }, - "engines": { - "node": "^12.20 || >=14.13" - } - }, - "node_modules/@stoplight/spectral-rulesets/node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/@stoplight/spectral-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@stoplight/spectral-runtime/-/spectral-runtime-1.1.6.tgz", @@ -18054,12 +17661,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/markdown-escape": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@types/markdown-escape/-/markdown-escape-1.1.3.tgz", - "integrity": "sha512-JIc1+s3y5ujKnt/+N+wq6s/QdL2qZ11fP79MijrVXsAAnzSxCbT2j/3prHRouJdZ2yFLN3vkP0HytfnoCczjOw==", - "license": "MIT" - }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -18189,6 +17790,7 @@ "version": "2.1.7", "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", "integrity": "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==", + "dev": true, "license": "MIT" }, "node_modules/@types/sax": { @@ -20414,15 +20016,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/as-table": { - "version": "1.0.55", - "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", - "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", - "license": "MIT", - "dependencies": { - "printable-characters": "^1.0.42" - } - }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -20474,18 +20067,6 @@ "node": ">=12" } }, - "node_modules/ast-types": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.14.2.tgz", - "integrity": "sha512-O0yuUDnZeQDL+ncNGlJ78BiO4jnYI3bvMsD5prT0/nsgijG/LpNBIr63gTjVTNsiGkgQhiyCShTgxt8oXOrklA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -21227,12 +20808,6 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, - "node_modules/builtins": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/builtins/-/builtins-1.0.3.tgz", - "integrity": "sha512-uYBjakWipfaO/bXI7E8rq6kpwHRZK5cNYrUv2OzZSI/FvmdMyXJ2tG9dKcjEC5YHmHpUAwsargWIZNWdxb/bnQ==", - "license": "MIT" - }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -22364,12 +21939,6 @@ "node": ">=4.0.0" } }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", - "license": "MIT" - }, "node_modules/compare-func": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", @@ -24406,12 +23975,6 @@ "node": ">=0.10" } }, - "node_modules/data-uri-to-buffer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", - "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", - "license": "MIT" - }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -27741,25 +27304,6 @@ "node": ">= 0.4" } }, - "node_modules/get-source": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", - "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", - "license": "Unlicense", - "dependencies": { - "data-uri-to-buffer": "^2.0.0", - "source-map": "^0.6.1" - } - }, - "node_modules/get-source/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/get-stream": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", @@ -28832,15 +28376,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/hpagent": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", - "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==", - "license": "MIT", - "engines": { - "node": ">=14" - } - }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -32550,12 +32085,6 @@ "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", "license": "ISC" }, - "node_modules/markdown-escape": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/markdown-escape/-/markdown-escape-2.0.0.tgz", - "integrity": "sha512-Trz4v0+XWlwy68LJIyw3bLbsJiC8XAbRCKF9DbEtZjyndKOGVx6n+wNB0VfoRmY2LKboQLeniap3xrb6LGSJ8A==", - "license": "MIT" - }, "node_modules/markdown-extensions": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", @@ -41072,12 +40601,6 @@ "node": ">=4" } }, - "node_modules/printable-characters": { - "version": "1.0.42", - "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", - "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", - "license": "Unlicense" - }, "node_modules/prism-react-renderer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz", @@ -42667,14 +42190,6 @@ "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", "license": "MIT" }, - "node_modules/reserved": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/reserved/-/reserved-0.1.2.tgz", - "integrity": "sha512-/qO54MWj5L8WCBP9/UNe2iefJc+L9yETbH32xO/ft/EYPOTCR5k+azvDUgdCOKwZH8hXwPd0b8XBL78Nn2U69g==", - "engines": { - "node": ">=0.8" - } - }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -44746,13 +44261,6 @@ "node": ">=0.10.0" } }, - "node_modules/sourcemap-codec": { - "version": "1.4.8", - "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", - "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", - "deprecated": "Please use @jridgewell/sourcemap-codec instead", - "license": "MIT" - }, "node_modules/space-separated-tokens": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", @@ -44967,16 +44475,6 @@ "dev": true, "license": "MIT" }, - "node_modules/stacktracey": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.2.0.tgz", - "integrity": "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==", - "license": "Unlicense", - "dependencies": { - "as-table": "^1.0.36", - "get-source": "^2.0.12" - } - }, "node_modules/state-local": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", @@ -46514,6 +46012,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, "license": "MIT" }, "node_modules/textextensions": { @@ -48047,15 +47546,6 @@ "spdx-expression-parse": "^3.0.0" } }, - "node_modules/validate-npm-package-name": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-3.0.0.tgz", - "integrity": "sha512-M6w37eVCMMouJ9V/sdPGnC5H4uDr73/+xdq0FBLO3TFFX1+7wiUY6Es328NN+y43tmY+doUdN9g9J21vqB7iLw==", - "license": "ISC", - "dependencies": { - "builtins": "^1.0.3" - } - }, "node_modules/value-equal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", @@ -49494,6 +48984,7 @@ "version": "17.7.3", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -49512,6 +49003,7 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -49521,6 +49013,7 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -49535,12 +49028,14 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/yargs/node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -49550,6 +49045,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -49564,6 +49060,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -49718,7 +49215,6 @@ "@finos/calm-widgets": "file:../calm-widgets", "@mermaid-js/layout-elk": "^0.2.1", "@stoplight/json": "^3.21.7", - "@stoplight/spectral-cli": "^6.14.3", "@stoplight/spectral-core": "^1.19.5", "@stoplight/spectral-functions": "^1.9.4", "ajv": "^8.18.0", @@ -49741,7 +49237,9 @@ "ts-node": "10.9.2", "winston": "^3.17.0" }, - "devDependencies": {} + "devDependencies": { + "esbuild": "^0.28.1" + } }, "shared/node_modules/ajv-formats": { "version": "2.1.1", diff --git a/shared/AGENTS.md b/shared/AGENTS.md index 00ebf149e..c4ffb36ca 100644 --- a/shared/AGENTS.md +++ b/shared/AGENTS.md @@ -36,9 +36,26 @@ npm test npx vitest run ${TEST FILE} ``` +## Entry points: Node vs browser + +`@finos/calm-shared` exposes two entry points via the package `exports` map: + +| Entry | File | Audience | +|---|---|---| +| `@finos/calm-shared` | `src/index.ts` | CLI, calm-server, anything on Node. Registers winston logging and the JUnit formatter at load. | +| `@finos/calm-shared/browser` | `src/browser.ts` | Browser bundles (the docs learning lab, Studio/Guard). Validate (JSON Schema + Spectral), generate, diff (including `diff --timeline` via `diffTimeline`), `SchemaDirectory`, loaders, the CLI capability manifest. | + +Rules: +- New modules are browser-safe by default. Node-only code (`fs`, `path`, `net`, `process.exit`, `__dirname`, winston, mkdirp, playwright) lives in a `*.node.ts` / `node-*.ts` module or in a wrapper that the root barrel imports — never imported from `browser.ts` or anything it reaches. +- Prefer seams over conditionals: pure core + Node wrapper (`generate-core.ts` / `generate.ts`, `diff-core.ts` / `diff.ts`, `validate-core.ts` / `validate.ts`), injected `DocumentLoader`s, registries (`registerNodeLoggerFactory`, `registerOutputFormatter`). +- `scripts/check-browser-entry.mjs` runs in `npm test`. It bundles `src/browser.ts` with esbuild for the browser and fails on any Node builtin request outside a four-entry allowlist (Spectral's dependency chain requests `fs`/`path`/`buffer` but never touches `fs`/`path` at runtime), then executes a real probe (`validate()`, `generate()`, `diffDocuments()`) with those builtins stubbed to throw. Do not extend the allowlist to make a red build green — fix the seam. +- Deep imports (`@finos/calm-shared/src/...`, `/dist/...`) are sealed by the `exports` map. Import from the barrel. +- Browser consumers bundling the entry must map the allowlisted builtins to nothing — webpack: `resolve.fallback: { fs: false, path: false, buffer: false }`; esbuild: the same stub plugin the guard uses. +- Not in the browser entry (follow-ups): template/docify (filesystem-bound loaders and output strategies), Hub read/write commands (CORS), diagram rasterisation, the standalone `timeline` command (synthesises from versioned architecture files on the local filesystem — `diff --timeline` via `diffTimeline` is supported). + ## Key Components -- **Document Loader** (`document-loader/`): Strategies for loading CALM documents — FileSystem, MultiStrategy, plus CalmHub, direct-URL, and mapped loaders. +- **Document Loader** (`document-loader/`): Strategies for loading CALM documents — FileSystem, MultiStrategy, plus CalmHub, direct-URL, and mapped loaders. Also `InMemoryDocumentLoader` (pass-a-map loader for tests and embedders) and `buildBrowserDocumentLoader` (the browser-entry loader factory, `document-loader/browser-document-loader.ts`). - **Template Processor** (`template/`): Handlebars-based template generation logic. - **Model Visitors** (`model-visitor/`): Visitor pattern implementations for traversing CALM models. - **Validation** (`commands/validate/`, `spectral/`): Core validation logic (Spectral integration) and output enrichment. @@ -67,7 +84,10 @@ npm run build:shared This package builds with `tsc` (not tsup/esbuild): `tsc -p ./tsconfig.build.json` followed by the `copy:docify-template-bundle` post-build step (`scripts/copy-templates.mjs`), which copies the docify -template bundles into `dist`. +template bundles into `dist`. `npm test --workspace shared` also runs `scripts/check-browser-entry.mjs` +first, an esbuild-based guard that bundles `src/browser.ts` for the browser and fails the test run if it +pulls in a Node builtin outside its allowlist or touches one at runtime — see "Entry points: Node vs +browser" above. #### Build configuration `tsconfig.build.json` is the production build config. It enables `"strict": true` and **excludes** spec diff --git a/shared/README.md b/shared/README.md index 9c76b7ac7..a4ac7c98e 100644 --- a/shared/README.md +++ b/shared/README.md @@ -2,7 +2,23 @@ This module provides shared logic such as validation and visualization utilities, intended for use across various plugins and tools in the codebase. It simplifies code reuse and promotes a unified logic layer, making it easier to maintain and extend. +## Browser entry point +Browser bundles import from `@finos/calm-shared/browser`, not the package root — the root entry pulls in Node-only code (winston, `fs`, etc.). +The browser entry covers validate (JSON Schema + Spectral), generate, diff (including `diff --timeline` via `diffTimeline`), `SchemaDirectory`, the document loaders, and auth plugins. The standalone `timeline` command is not supported in the browser — it synthesises a timeline from versioned architecture files on the local filesystem. +Bundlers must stub out the Node builtins the browser entry's dependency chain still requests but never touches at runtime; for webpack: + +```js +resolve: { + fallback: { fs: false, path: false, buffer: false } +} +``` + +The browser entry guard's allowlist assumes bundlers resolve dependencies with the `browser` main field first (`mainFields: ['browser', 'module', 'main']`); a node/SSR-target bundle resolves the Node builds of the same dependencies instead and will see more builtin requests than the allowlist covers. + +`BROWSER_COMMAND_SUPPORT` (from `browser-capabilities.ts`) lists which `calm` CLI commands the browser entry can honour and why the rest are unsupported there, so consumers can report this to users instead of guessing. + +In a browser, `DirectUrlDocumentLoader` and `CalmHubDocumentLoader` also revalidate the final response origin against the one they requested, because the browser follows redirects transparently (axios's `maxRedirects` option only applies in Node) and a redirect from an allowed origin could otherwise be answered by another origin. # Spectral validation rules for CALM implementations diff --git a/shared/package.json b/shared/package.json index 4d706765a..94fca2c88 100644 --- a/shared/package.json +++ b/shared/package.json @@ -4,6 +4,16 @@ "description": "A set of tools for interacting with the Common Architecture Language Model (CALM)", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./browser": { + "types": "./dist/browser.d.ts", + "default": "./dist/browser.js" + } + }, "files": [ "dist" ], @@ -13,7 +23,8 @@ "copy:docify-template-bundle": "node scripts/copy-templates.mjs", "watch": "tsc -watch -p ./tsconfig.build.json", "clean": "rimraf dist tsconfig.build.tsbuildinfo", - "test": "vitest run", + "test": "node scripts/check-browser-entry.mjs && vitest run", + "check:browser-entry": "node scripts/check-browser-entry.mjs", "lint": "eslint src", "lint-fix": "eslint src --fix", "dependency-check": "dependency-check --project 'calm-shared' --scan . --out ./dependency-check-report --format ALL --suppression ../.github/node-cve-ignore-list.xml" @@ -27,7 +38,6 @@ ], "author": "", "license": "ISC", - "module": "esnext", "type": "module", "dependencies": { "@apidevtools/json-schema-ref-parser": "^14.0.0", @@ -35,7 +45,6 @@ "@finos/calm-widgets": "file:../calm-widgets", "@mermaid-js/layout-elk": "^0.2.1", "@stoplight/json": "^3.21.7", - "@stoplight/spectral-cli": "^6.14.3", "@stoplight/spectral-core": "^1.19.5", "@stoplight/spectral-functions": "^1.9.4", "ajv": "^8.18.0", @@ -59,6 +68,7 @@ "winston": "^3.17.0" }, "devDependencies": { + "esbuild": "^0.28.1" }, "overrides": { "path-to-regexp": "8.4.2", diff --git a/shared/scripts/browser-probe.ts b/shared/scripts/browser-probe.ts new file mode 100644 index 000000000..5fc7fefd7 --- /dev/null +++ b/shared/scripts/browser-probe.ts @@ -0,0 +1,70 @@ +// Bundled by check-browser-entry.mjs with fs/path stubbed to throw on touch. Exercises the real +// validate(), generate() and diffDocuments() paths through the browser entry with in-memory schemas. +import { validate, SchemaDirectory, buildBrowserDocumentLoader, formatOutput, browserSupportFor, generate, diffDocuments } from '../src/browser'; +import calm from '../../calm/release/1.2/meta/calm.json'; +import core from '../../calm/release/1.2/meta/core.json'; +import iface from '../../calm/release/1.2/meta/interface.json'; +import control from '../../calm/release/1.2/meta/control.json'; +import controlRequirement from '../../calm/release/1.2/meta/control-requirement.json'; +import evidence from '../../calm/release/1.2/meta/evidence.json'; +import flow from '../../calm/release/1.2/meta/flow.json'; +import units from '../../calm/release/1.2/meta/units.json'; +import decorators from '../../calm/release/1.2/meta/decorators.json'; +import timeline from '../../calm/release/1.2/meta/timeline.json'; +import calmTimeline from '../../calm/release/1.2/meta/calm-timeline.json'; + +const documents: Record = Object.fromEntries( + [calm, core, iface, control, controlRequirement, evidence, flow, units, decorators, timeline, calmTimeline] + .map((schema) => [(schema as { $id: string }).$id, schema]) +); + +const arch = (destination: string) => ({ + $schema: 'https://calm.finos.org/release/1.2/meta/calm.json', + 'unique-id': 'probe', + nodes: [ + { 'unique-id': 'svc', 'node-type': 'service', name: 'Service', description: 'a service' }, + { 'unique-id': 'db', 'node-type': 'database', name: 'DB', description: 'a database' }, + ], + relationships: [ + { 'unique-id': 'svc-db', 'relationship-type': { connects: { source: { node: 'svc' }, destination: { node: destination } } } }, + ], +}); + +async function directory(): Promise { + const dir = new SchemaDirectory(buildBrowserDocumentLoader({ documents, allowRemote: false })); + await dir.loadSchemas(); + return dir; +} + +const good = await validate(arch('db'), undefined, undefined, await directory()); +if (good.hasErrors) { + throw new Error('probe: valid architecture reported errors:\n' + formatOutput(good, 'pretty')); +} +const bad = await validate(arch('ghost'), undefined, undefined, await directory()); +if (!bad.hasErrors) { + throw new Error('probe: dangling relationship was not reported'); +} +if (browserSupportFor('docify')?.status !== 'unsupported') { + throw new Error('probe: manifest missing docify'); +} + +const minimalPattern = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://x/p.json', + type: 'object', + properties: { + nodes: { type: 'array', prefixItems: [] }, + relationships: { type: 'array', prefixItems: [] }, + }, +}; +const generated = await generate(minimalPattern, await directory()) as { nodes: unknown[] }; +if (!Array.isArray(generated.nodes) || generated.nodes.length !== 0) { + throw new Error('probe: generate did not produce the expected empty nodes array'); +} + +const diffResult = diffDocuments(arch('db'), { ...arch('db'), nodes: arch('db').nodes.slice(0, 1) }); +if (diffResult.hasChanges !== true) { + throw new Error('probe: diffDocuments did not report a change for the removed node'); +} + +console.log('browser probe ok: ' + bad.spectralSchemaValidationOutputs.length + ' spectral issue(s) on the broken document; generate and diff also ran'); diff --git a/shared/scripts/check-browser-entry.mjs b/shared/scripts/check-browser-entry.mjs new file mode 100644 index 000000000..b486848e8 --- /dev/null +++ b/shared/scripts/check-browser-entry.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +// Guards the browser entry point: bundles src/browser.ts for the browser, fails on any Node +// builtin request outside the documented allowlist, then executes a probe (exercising validate, +// generate and diff) with those builtins stubbed to throw if touched. Run as part of `npm test` +// (see package.json). +import * as esbuild from 'esbuild'; +import { builtinModules } from 'node:module'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const sharedRoot = path.resolve(here, '..'); +const repoRoot = path.resolve(sharedRoot, '..'); +const builtins = new Set([...builtinModules, ...builtinModules.map((m) => `node:${m}`)]); + +// Every Node builtin the browser bundle is allowed to *request* (none may be *touched* at +// runtime on the validate, generate or diff paths — the probe proves that). Each entry: builtin +// + a regex on the importer path. Anything else fails the build. Extend only with a matching +// probe change. +const ALLOWED = [ + { builtin: 'fs', importer: /@stoplight\/spectral-runtime\/dist\/reader\.js$/ }, + { builtin: 'fs', importer: /@stoplight\/json-ref-readers\/file\.js$/ }, + { builtin: 'path', importer: /minimatch\/minimatch\.js$/ }, + { builtin: 'buffer', importer: /@stoplight\/yaml-ast-parser\/dist\/src\/type\/binary\.js$/ }, +]; + +function stubPlugin(requests) { + return { + name: 'browser-entry-guard', + setup(build) { + build.onResolve({ filter: /.*/ }, (args) => { + if (!builtins.has(args.path)) return null; + const builtin = args.path.replace(/^node:/, ''); + requests.push({ builtin, importer: args.importer }); + return { path: builtin, namespace: 'guard-stub' }; + }); + build.onLoad({ filter: /.*/, namespace: 'guard-stub' }, (args) => ({ + loader: 'js', + contents: args.path === 'buffer' + ? 'export const Buffer = undefined; export default { Buffer };' + : `const stub = new Proxy({}, { get(_, key) { + if (key === '__esModule' || key === 'default' || key === 'then') return undefined; + throw new Error('browser entry touched Node builtin ${args.path}.' + String(key) + ' at runtime'); + } }); + export default stub;`, + })); + }, + }; +} + +async function bundle(entry, outfile, requests) { + await esbuild.build({ + entryPoints: [entry], + outfile, + bundle: true, + platform: 'browser', + format: 'esm', + mainFields: ['browser', 'module', 'main'], + define: { + 'process.env.NODE_ENV': '"production"', + process: 'undefined', + Buffer: 'undefined', + }, + logLevel: 'silent', + plugins: [stubPlugin(requests)], + }); +} + +function checkRequests(requests) { + const problems = []; + const matchedAllowed = new Set(); + const sharedSrc = path.join(sharedRoot, 'src'); + for (const { builtin, importer } of requests) { + const rel = path.relative(repoRoot, importer); + // Normalise separators before matching so the guard behaves the same on Windows, where + // esbuild's importer paths use backslashes. + const normalizedImporter = importer.replace(/\\/g, '/'); + const relFromSharedSrc = path.relative(sharedSrc, importer); + const isSharedSrc = relFromSharedSrc !== '' && !relFromSharedSrc.startsWith('..') && !path.isAbsolute(relFromSharedSrc); + if (isSharedSrc) { + problems.push(`shared source imports Node builtin '${builtin}': ${rel}`); + continue; + } + const allowedIndex = ALLOWED.findIndex((a) => a.builtin === builtin && a.importer.test(normalizedImporter)); + if (allowedIndex === -1) { + problems.push(`unexpected Node builtin '${builtin}' requested by ${rel}`); + } else { + matchedAllowed.add(allowedIndex); + } + } + ALLOWED.forEach((allowed, index) => { + if (!matchedAllowed.has(index)) { + problems.push(`allowlist entry never matched: ${allowed.builtin} <- ${allowed.importer}`); + } + }); + return problems; +} + +async function main() { + const workDir = await mkdtemp(path.join(tmpdir(), 'calm-browser-guard-')); + try { + const entryRequests = []; + await bundle(path.join(sharedRoot, 'src/browser.ts'), path.join(workDir, 'browser.js'), entryRequests); + const problems = checkRequests(entryRequests); + if (problems.length) { + console.error('Browser entry guard FAILED:\n ' + problems.join('\n ')); + process.exitCode = 1; + return; + } + console.log(`browser entry: ${entryRequests.length} allowlisted builtin request(s), none from shared/src`); + + const probeOut = path.join(workDir, 'probe.js'); + // The probe's module graph is the entry's graph plus JSON schema fixtures, already + // checked above, so its builtin requests are intentionally not re-checked here. + await bundle(path.join(here, 'browser-probe.ts'), probeOut, []); + await import(pathToFileURL(probeOut).href); + } catch (err) { + console.error('Browser entry guard FAILED: ' + (err instanceof Error ? err.message : String(err))); + process.exitCode = 1; + } finally { + await rm(workDir, { recursive: true, force: true }); + } +} + +await main(); diff --git a/shared/src/browser-capabilities.spec.ts b/shared/src/browser-capabilities.spec.ts new file mode 100644 index 000000000..3bb3ba947 --- /dev/null +++ b/shared/src/browser-capabilities.spec.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { BROWSER_COMMAND_SUPPORT, browserSupportFor } from './browser-capabilities'; + +describe('browser capability manifest', () => { + it('marks the pure engine commands as supported', () => { + for (const cmd of ['validate', 'generate', 'diff']) { + expect(browserSupportFor(cmd)).toEqual({ command: cmd, status: 'supported' }); + } + }); + + it('marks timeline as unsupported because it synthesises from the local filesystem', () => { + const entry = browserSupportFor('timeline'); + expect(entry?.status).toBe('unsupported'); + expect(entry && 'reason' in entry ? entry.reason : '').toContain('diffTimeline'); + }); + + it('gives a reason for every unsupported command', () => { + const unsupported = BROWSER_COMMAND_SUPPORT.filter((e) => e.status === 'unsupported'); + expect(unsupported.length).toBeGreaterThan(0); + for (const entry of unsupported) { + expect(entry.reason.length).toBeGreaterThan(10); + } + }); + + it('returns undefined for unknown commands', () => { + expect(browserSupportFor('frobnicate')).toBeUndefined(); + }); + + it('has no duplicate keys', () => { + const keys = BROWSER_COMMAND_SUPPORT.map((e) => e.command); + expect(new Set(keys).size).toBe(keys.length); + }); +}); diff --git a/shared/src/browser-capabilities.ts b/shared/src/browser-capabilities.ts new file mode 100644 index 000000000..710339e2a --- /dev/null +++ b/shared/src/browser-capabilities.ts @@ -0,0 +1,37 @@ +/** + * Which `calm` CLI commands the browser entry point can honour. Browser consumers (e.g. the + * in-browser learning lab) use this to report honestly which commands are available and why the + * others are not. `cli/src/browser-manifest.spec.ts` asserts this list matches the commands the + * CLI actually registers, so the two cannot drift — at top-level commands plus the `hub` + * subgroups' granularity; `workspace` subcommands are covered by the single `workspace` entry, + * not enumerated individually. + */ +export type BrowserCommandSupport = + | { command: string; status: 'supported' } + | { command: string; status: 'unsupported'; reason: string }; + +const FILESYSTEM_REASON = 'reads template bundles and writes its output through the local filesystem'; + +export const BROWSER_COMMAND_SUPPORT: readonly BrowserCommandSupport[] = [ + { command: 'validate', status: 'supported' }, + { command: 'generate', status: 'supported' }, + { command: 'diff', status: 'supported' }, + { + command: 'timeline', + status: 'unsupported', + reason: 'synthesises a timeline from versioned architecture files on the local filesystem; timeline diffing is available in the browser through diffTimeline (the diff --timeline core)' + }, + { command: 'template', status: 'unsupported', reason: FILESYSTEM_REASON }, + { command: 'docify', status: 'unsupported', reason: `${FILESYSTEM_REASON}, and rasterises diagrams with a headless browser` }, + { command: 'init-ai', status: 'unsupported', reason: 'installs AI assistant files into the local project' }, + { command: 'init-config', status: 'unsupported', reason: 'writes the CLI configuration file on the local machine' }, + { command: 'hub pull', status: 'unsupported', reason: 'reads from a CALM Hub over HTTP, which needs CORS headers on the target Hub' }, + { command: 'hub list', status: 'unsupported', reason: 'reads from a CALM Hub over HTTP, which needs CORS headers on the target Hub' }, + { command: 'hub push', status: 'unsupported', reason: 'writes to a CALM Hub; browser consumers simulate publishing instead' }, + { command: 'hub create', status: 'unsupported', reason: 'writes to a CALM Hub; browser consumers simulate publishing instead' }, + { command: 'workspace', status: 'unsupported', reason: 'operates on a git-rooted workspace bundle on the local filesystem' }, +]; + +export function browserSupportFor(command: string): BrowserCommandSupport | undefined { + return BROWSER_COMMAND_SUPPORT.find((entry) => entry.command === command); +} diff --git a/shared/src/browser-surface.spec.ts b/shared/src/browser-surface.spec.ts new file mode 100644 index 000000000..726dc40ac --- /dev/null +++ b/shared/src/browser-surface.spec.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync } from 'fs'; +import path from 'path'; +import { validate, SchemaDirectory, buildBrowserDocumentLoader, formatOutput, generate, diffDocuments } from './browser'; + +// The spec itself reads the meta-schemas from disk; the code under test only ever sees objects. +const META_DIR = path.join(__dirname, '../../calm/release/1.2/meta'); +const schemas: Record = Object.fromEntries( + readdirSync(META_DIR).filter((f) => f.endsWith('.json')).map((f) => { + const doc = JSON.parse(readFileSync(path.join(META_DIR, f), 'utf-8')); + return [doc.$id, doc]; + }) +); + +const validArch = { + $schema: 'https://calm.finos.org/release/1.2/meta/calm.json', + 'unique-id': 'arch', + nodes: [ + { 'unique-id': 'svc', 'node-type': 'service', name: 'Service', description: 'a service' }, + { 'unique-id': 'db', 'node-type': 'database', name: 'DB', description: 'a database' }, + ], + relationships: [ + { 'unique-id': 'svc-db', 'relationship-type': { connects: { source: { node: 'svc' }, destination: { node: 'db' } } } }, + ], +}; + +async function schemaDirectory(extra: Record = {}): Promise { + const dir = new SchemaDirectory(buildBrowserDocumentLoader({ documents: { ...schemas, ...extra }, allowRemote: false })); + await dir.loadSchemas(); + return dir; +} + +describe('browser entry point', () => { + it('validates a well-formed architecture with schema + spectral rules through injected loaders', async () => { + const outcome = await validate(validArch, undefined, undefined, await schemaDirectory()); + expect(outcome.hasErrors).toBe(false); + expect(formatOutput(outcome, 'pretty')).toContain('No issues found'); + }); + + it('reports a dangling relationship reference via the spectral rules', async () => { + const broken = { ...validArch, relationships: [{ 'unique-id': 'x', 'relationship-type': { connects: { source: { node: 'svc' }, destination: { node: 'ghost' } } } }] }; + const outcome = await validate(broken, undefined, undefined, await schemaDirectory()); + expect(outcome.hasErrors).toBe(true); + expect(outcome.spectralSchemaValidationOutputs.some((o) => /ghost/.test(o.message))).toBe(true); + }); + + it('refuses junit formatting with a clear error', async () => { + const outcome = await validate(validArch, undefined, undefined, await schemaDirectory()); + expect(() => formatOutput(outcome, 'junit')).toThrow(/junit.*not available/i); + }); + + it('exposes the pure generate and diff cores', async () => { + const pattern = { $schema: 'https://json-schema.org/draft/2020-12/schema', $id: 'https://x/p.json', type: 'object', properties: { nodes: { type: 'array', prefixItems: [] }, relationships: { type: 'array', prefixItems: [] } } }; + const generated = await generate(pattern, await schemaDirectory()) as { nodes: unknown[] }; + expect(generated.nodes).toEqual([]); + expect(diffDocuments(validArch, { ...validArch, nodes: validArch.nodes.slice(0, 1) }).hasChanges).toBe(true); + }); +}); diff --git a/shared/src/browser.ts b/shared/src/browser.ts new file mode 100644 index 000000000..d9b0eebf3 --- /dev/null +++ b/shared/src/browser.ts @@ -0,0 +1,74 @@ +/** + * Browser-safe entry point (`@finos/calm-shared/browser`). + * + * Everything exported here must be importable in a browser bundle: no `fs`, `path`, `net`, + * `winston`, `mkdirp`, `playwright-core`, no `process.exit`, no `__dirname`. The guard script + * `scripts/check-browser-entry.mjs` enforces this on every test run. Node-only code lives + * behind the root entry (`index.ts`) and is never imported from here. + */ +export { validate } from './commands/validate/validate-core.js'; +export { + formatOutput, + formatOutput as getFormattedOutput, + registerOutputFormatter, + type OutputFormat, + type ValidateOutputFormat, + type OutputFormatter, + type ValidationDocumentContext, + type ValidationFormattingOptions, +} from './commands/validate/format-output.js'; +export { ValidationOutcome, ValidationOutput } from './commands/validate/validation.output.js'; +export { + enrichWithDocumentPositions, + parseDocumentWithPositions, + type ParsedDocumentContext, +} from './commands/validate/validation-enrichment.js'; +export { SchemaDirectory } from './schema-directory.js'; +export { + type DocumentLoader, + DocumentLoadError, + assertJsonObject, + CALM_HUB_PROTOS, +} from './document-loader/document-loader.js'; +export { InMemoryDocumentLoader } from './document-loader/in-memory-document-loader.js'; +export { CalmHubDocumentLoader } from './document-loader/calmhub-document-loader.js'; +export { DirectUrlDocumentLoader } from './document-loader/direct-url-document-loader.js'; +export { MultiStrategyDocumentLoader } from './document-loader/multi-strategy-document-loader.js'; +export { buildBrowserDocumentLoader, type BrowserDocumentLoaderOptions } from './document-loader/browser-document-loader.js'; +export { generate, type GenerateOptions } from './commands/generate/generate-core.js'; +export { extractOptions, selectChoices, CalmChoice, CalmOption } from './commands/generate/components/options.js'; +export { + diffDocuments, + diffTimeline, + formatDiff, + detectDocumentType, + tryDetectDocumentType, + hasChanges as diffHasChanges, + type DiffOutputFormat, + type DiffDocumentType, + type DiffDocumentsOptions, + type DiffRunResult, + type TimelineDiffRunOptions, + type TimelineDiffRunResult, +} from './commands/diff/diff-core.js'; +export type { ArchitectureResolver, MomentDiff } from '@finos/calm-models/diff'; +export { initLogger, registerNodeLoggerFactory } from './logger.js'; +export type { Logger, LogLevel, NodeLoggerFactory } from './logger.js'; +export { AuthPlugin } from './auth/auth-plugin.js'; +export { NoAuthPlugin } from './auth/no-auth-plugin.js'; +export { + constructDocumentId, + isConformantDocumentId, + namespaceFromDocumentId, + extractDocumentMetadata, + updateDocumentMetadata, + type DocumentMetadata, + constructControlDocumentId, + extractControlMetadata, + updateControlDocumentMetadata, + type ControlDocumentMetadata, + type ControlDocumentKind, +} from './hub/document-id-utils.js'; +export { computeSemVerBump, compareSemVer, sortSemVer } from './hub/semver.js'; +export { canonicalEqual, canonicalize } from './hub/canonical.js'; +export { BROWSER_COMMAND_SUPPORT, browserSupportFor, type BrowserCommandSupport } from './browser-capabilities.js'; diff --git a/shared/src/commands/diff/diff-core.ts b/shared/src/commands/diff/diff-core.ts new file mode 100644 index 000000000..b8da9efc1 --- /dev/null +++ b/shared/src/commands/diff/diff-core.ts @@ -0,0 +1,244 @@ +import { + diffArchitectures, + diffPatterns, + diffTimelineAdjacent, + diffTimelineMoments, + type ArchitectureResolver, + type MomentDiff, + type NodesAndRelationshipsDiffResult, + type TimelineInput, +} from '@finos/calm-models/diff'; +import type { CalmArchitectureSchema, CalmNodeSchema, CalmRelationshipSchema } from '@finos/calm-models/types'; +import { initLogger } from '../../logger.js'; + +export type DiffOutputFormat = 'json' | 'summary'; + +export type DiffDocumentType = 'architecture' | 'pattern'; + +export interface DiffRunResult { + diff: NodesAndRelationshipsDiffResult; + formatted: string; + hasChanges: boolean; +} + +export function hasChanges(diff: NodesAndRelationshipsDiffResult): boolean { + return ( + diff.nodesAdded.length > 0 || + diff.nodesRemoved.length > 0 || + diff.nodesModified.length > 0 || + diff.nodesRenamed.length > 0 || + diff.edgesAdded.length > 0 || + diff.edgesRemoved.length > 0 || + diff.edgesModified.length > 0 || + diff.edgesRenamed.length > 0 || + (diff.invalidItems?.nodes.length ?? 0) > 0 || + (diff.invalidItems?.relationships.length ?? 0) > 0 || + (diff.undiffableItems?.nodes.length ?? 0) > 0 || + (diff.undiffableItems?.relationships.length ?? 0) > 0 + ); +} + +/** + * Label for a node/relationship in the summary view. Falls back to a content + * hint for pattern items that have no pinned `unique-id`, so they don't render + * as `undefined`. + */ +function nodeLabel(node: CalmNodeSchema): string { + const item = node as Record; + if (typeof item['unique-id'] === 'string') return item['unique-id']; + const detail = [item['node-type'], item['name']].filter((v) => typeof v === 'string').join(' '); + return detail ? `(unpinned ${detail})` : '(unpinned node)'; +} + +function edgeLabel(edge: CalmRelationshipSchema): string { + const item = edge as Record; + return typeof item['unique-id'] === 'string' ? item['unique-id'] : '(unpinned relationship)'; +} + +export function formatDiff( + diff: NodesAndRelationshipsDiffResult, + format: DiffOutputFormat, + documentType: DiffDocumentType = 'architecture', +): string { + if (format === 'json') { + return JSON.stringify(diff, null, 2); + } + const invalidNodes = diff.invalidItems?.nodes.length ?? 0; + const invalidEdges = diff.invalidItems?.relationships.length ?? 0; + const undiffableNodes = diff.undiffableItems?.nodes.length ?? 0; + const undiffableEdges = diff.undiffableItems?.relationships.length ?? 0; + const title = `CALM ${documentType} diff`; + const lines = [ + title, + '-'.repeat(title.length), + `Nodes: +${diff.nodesAdded.length} -${diff.nodesRemoved.length} ~${diff.nodesModified.length} ↔${diff.nodesRenamed.length} =${diff.nodesSame.length}`, + `Relationships: +${diff.edgesAdded.length} -${diff.edgesRemoved.length} ~${diff.edgesModified.length} ↔${diff.edgesRenamed.length} =${diff.edgesSame.length}`, + ]; + if (invalidNodes + invalidEdges > 0) { + lines.push(`Invalid items: ${invalidNodes} node(s) + ${invalidEdges} relationship(s) skipped (missing unique-id)`); + } + if (undiffableNodes + undiffableEdges > 0) { + lines.push(`Undiffable items: ${undiffableNodes} node(s) + ${undiffableEdges} relationship(s) (no constrained unique-id to diff by)`); + } + lines.push(''); + const list = (label: string, ids: string[]) => { + if (ids.length === 0) return; + lines.push(label); + for (const id of ids) lines.push(` - ${id}`); + lines.push(''); + }; + list('Nodes added:', diff.nodesAdded.map(nodeLabel)); + list('Nodes removed:', diff.nodesRemoved.map(nodeLabel)); + list('Nodes modified:', diff.nodesModified.map((n) => nodeLabel(n.original))); + list('Nodes renamed:', diff.nodesRenamed.map((r) => `${r.oldId} -> ${r.newId}`)); + list('Relationships added:', diff.edgesAdded.map(edgeLabel)); + list('Relationships removed:', diff.edgesRemoved.map(edgeLabel)); + list('Relationships modified:', diff.edgesModified.map((e) => edgeLabel(e.original))); + list('Relationships renamed:', diff.edgesRenamed.map((r) => `${r.oldId} -> ${r.newId}`)); + return lines.join('\n'); +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function looksLikePattern(doc: Record): boolean { + const hasNodeOrRelProps = (schema: Record): boolean => { + const props = isObject(schema['properties']) ? schema['properties'] : undefined; + return !!props && (isObject(props['nodes']) || isObject(props['relationships'])); + }; + if (hasNodeOrRelProps(doc)) return true; + return Array.isArray(doc['allOf']) + && doc['allOf'].some((sub) => isObject(sub) && hasNodeOrRelProps(sub)); +} + +/** + * Classifies a document as an architecture instance (top-level + * `nodes`/`relationships` arrays) or a pattern (a JSON Schema describing those + * arrays under `properties`/`allOf`). Returns `null` when the input clearly + * matches neither shape, leaving the decision to the caller. + */ +export function tryDetectDocumentType(doc: Record): DiffDocumentType | null { + if (Array.isArray(doc['nodes']) || Array.isArray(doc['relationships'])) { + return 'architecture'; + } + if (looksLikePattern(doc)) { + return 'pattern'; + } + return null; +} + +/** + * Like {@link tryDetectDocumentType} but throws when the input matches neither + * shape, so malformed input is surfaced rather than silently diffed to an empty + * result. + */ +export function detectDocumentType(doc: Record): DiffDocumentType { + const detected = tryDetectDocumentType(doc); + if (detected) { + return detected; + } + throw new Error( + 'Could not determine the CALM document type: expected an architecture ' + + '(top-level nodes/relationships arrays) or a pattern (a JSON Schema ' + + 'describing them). Pass --type architecture|pattern to specify it explicitly.', + ); +} + +export interface TimelineDiffRunOptions { + /** Diff only this single pair instead of all adjacent pairs. */ + fromMomentId?: string; + toMomentId?: string; + verbose?: boolean; +} + +export interface TimelineDiffRunResult { + /** Ordered diffs: one per adjacent pair, or a single entry for an explicit pair. */ + diffs: MomentDiff[]; +} + +export interface DiffDocumentsOptions { + format?: DiffOutputFormat; + verbose?: boolean; + /** Override automatic architecture/pattern detection. */ + documentType?: DiffDocumentType; + /** Names used for the two documents in messages (e.g. file paths). */ + labels?: [string, string]; +} + +export function diffDocuments( + docA: Record, + docB: Record, + options: DiffDocumentsOptions = {}, +): DiffRunResult { + const logger = initLogger(!!options.verbose, 'calm-diff'); + const format = options.format ?? 'json'; + const [labelA, labelB] = options.labels ?? ['document A', 'document B']; + + let documentType: DiffDocumentType; + if (options.documentType) { + documentType = options.documentType; + for (const [label, doc] of [[labelA, docA], [labelB, docB]] as const) { + const detected = tryDetectDocumentType(doc); + if (detected && detected !== documentType) { + throw new Error( + `--type was set to '${documentType}', but ${label} matches '${detected}'. ` + + 'Remove --type to auto-detect, or pass inputs of the forced type.', + ); + } + } + } else { + const typeA = detectDocumentType(docA); + const typeB = detectDocumentType(docB); + if (typeA !== typeB) { + throw new Error( + `Cannot diff mismatched document types: ${typeA} vs ${typeB}. Both inputs must be the ` + + 'same CALM document type; pass --type to override detection.', + ); + } + documentType = typeA; + } + + const diff = documentType === 'pattern' + ? diffPatterns(docA, docB) + : diffArchitectures(docA as CalmArchitectureSchema, docB as CalmArchitectureSchema); + + const invalidNodeCount = diff.invalidItems?.nodes.length ?? 0; + const invalidEdgeCount = diff.invalidItems?.relationships.length ?? 0; + if (invalidNodeCount + invalidEdgeCount > 0) { + logger.warn( + `Skipped ${invalidNodeCount} node(s) and ${invalidEdgeCount} relationship(s) ` + + 'because they were missing a unique-id. These items are reported under ' + + 'invalidItems and contribute to hasChanges so --exit-code does not pass on them silently.', + ); + } + const undiffableNodeCount = diff.undiffableItems?.nodes.length ?? 0; + const undiffableEdgeCount = diff.undiffableItems?.relationships.length ?? 0; + if (undiffableNodeCount + undiffableEdgeCount > 0) { + logger.warn( + `Could not diff ${undiffableNodeCount} node(s) and ${undiffableEdgeCount} relationship(s) ` + + 'because they constrain no comparable content (e.g. an unconstrained unique-id). ' + + 'These items are reported under undiffableItems and contribute to hasChanges so ' + + '--exit-code does not pass on them silently.', + ); + } + + const formatted = formatDiff(diff, format, documentType); + return { diff, formatted, hasChanges: hasChanges(diff) }; +} + +export async function diffTimeline( + timeline: TimelineInput, + resolver: ArchitectureResolver, + options: TimelineDiffRunOptions = {}, +): Promise { + if (options.fromMomentId || options.toMomentId) { + if (!options.fromMomentId || !options.toMomentId) { + throw new Error('Both fromMomentId and toMomentId must be supplied to diff a specific pair.'); + } + const diff = await diffTimelineMoments(timeline, options.fromMomentId, options.toMomentId, resolver); + return { diffs: [diff] }; + } + const diffs = await diffTimelineAdjacent(timeline, resolver); + return { diffs }; +} diff --git a/shared/src/commands/diff/diff.spec.ts b/shared/src/commands/diff/diff.spec.ts index b2013a0dc..9a9dfa54d 100644 --- a/shared/src/commands/diff/diff.spec.ts +++ b/shared/src/commands/diff/diff.spec.ts @@ -4,6 +4,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'no import path from 'node:path'; import { runDiff, formatDiff, hasChanges, detectDocumentType } from './diff.js'; import type { NodesAndRelationshipsDiffResult } from '@finos/calm-models/diff'; +import type { TimelineInput } from '@finos/calm-models/diff'; const loggerMock = { info: vi.fn(), @@ -313,3 +314,37 @@ describe('formatDiff', () => { expect(out).not.toContain('undefined'); }); }); + +describe('diff core', () => { + const archA = { nodes: [{ 'unique-id': 'a', 'node-type': 'service', name: 'A', description: 'x' }], relationships: [] }; + const archB = { nodes: [...archA.nodes, { 'unique-id': 'b', 'node-type': 'service', name: 'B', description: 'y' }], relationships: [] }; + + it('diffs two architecture objects and formats a summary', async () => { + const { diffDocuments } = await import('./diff-core'); + const result = diffDocuments(archA, archB, { format: 'summary' }); + expect(result.hasChanges).toBe(true); + expect(result.diff.nodesAdded.map((n) => n['unique-id'])).toEqual(['b']); + expect(result.formatted).toContain('Nodes added:'); + }); + + it('uses labels in the mismatch error instead of file paths', async () => { + const { diffDocuments } = await import('./diff-core'); + expect(() => diffDocuments(archA, archB, { documentType: 'pattern', labels: ['left.json', 'right.json'] })) + .toThrow(/left\.json matches 'architecture'/); + }); + + it('diffs a timeline through an injected resolver', async () => { + const { diffTimeline } = await import('./diff-core'); + const timeline = { + 'unique-id': 't', + moments: [ + { 'unique-id': 'm1', details: { 'detailed-architecture': 'mem://a' } }, + { 'unique-id': 'm2', details: { 'detailed-architecture': 'mem://b' } }, + ], + } as unknown as TimelineInput; + const resolver = vi.fn(async (ref: string) => (ref === 'mem://a' ? archA : archB)); + const { diffs } = await diffTimeline(timeline, resolver); + expect(resolver).toHaveBeenCalledWith('mem://a'); + expect(diffs).toHaveLength(1); + }); +}); diff --git a/shared/src/commands/diff/diff.ts b/shared/src/commands/diff/diff.ts index 0da9dc6ac..b17b75e87 100644 --- a/shared/src/commands/diff/diff.ts +++ b/shared/src/commands/diff/diff.ts @@ -1,22 +1,19 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { mkdirp } from 'mkdirp'; -import { - diffArchitectures, - diffPatterns, - diffTimelineAdjacent, - diffTimelineMoments, - type ArchitectureResolver, - type MomentDiff, - type NodesAndRelationshipsDiffResult, - type TimelineInput, -} from '@finos/calm-models/diff'; -import type { CalmArchitectureSchema, CalmNodeSchema, CalmRelationshipSchema } from '@finos/calm-models/types'; +import type { ArchitectureResolver, TimelineInput } from '@finos/calm-models/diff'; import { initLogger } from '../../logger.js'; +import { + diffDocuments, + diffTimeline, + type DiffDocumentType, + type DiffOutputFormat, + type DiffRunResult, + type TimelineDiffRunOptions, + type TimelineDiffRunResult, +} from './diff-core.js'; -export type DiffOutputFormat = 'json' | 'summary'; - -export type DiffDocumentType = 'architecture' | 'pattern'; +export * from './diff-core.js'; export interface DiffRunOptions { format?: DiffOutputFormat; @@ -26,229 +23,28 @@ export interface DiffRunOptions { documentType?: DiffDocumentType; } -export interface DiffRunResult { - diff: NodesAndRelationshipsDiffResult; - formatted: string; - hasChanges: boolean; -} - -export function hasChanges(diff: NodesAndRelationshipsDiffResult): boolean { - return ( - diff.nodesAdded.length > 0 || - diff.nodesRemoved.length > 0 || - diff.nodesModified.length > 0 || - diff.nodesRenamed.length > 0 || - diff.edgesAdded.length > 0 || - diff.edgesRemoved.length > 0 || - diff.edgesModified.length > 0 || - diff.edgesRenamed.length > 0 || - (diff.invalidItems?.nodes.length ?? 0) > 0 || - (diff.invalidItems?.relationships.length ?? 0) > 0 || - (diff.undiffableItems?.nodes.length ?? 0) > 0 || - (diff.undiffableItems?.relationships.length ?? 0) > 0 - ); -} - -/** - * Label for a node/relationship in the summary view. Falls back to a content - * hint for pattern items that have no pinned `unique-id`, so they don't render - * as `undefined`. - */ -function nodeLabel(node: CalmNodeSchema): string { - const item = node as Record; - if (typeof item['unique-id'] === 'string') return item['unique-id']; - const detail = [item['node-type'], item['name']].filter((v) => typeof v === 'string').join(' '); - return detail ? `(unpinned ${detail})` : '(unpinned node)'; -} - -function edgeLabel(edge: CalmRelationshipSchema): string { - const item = edge as Record; - return typeof item['unique-id'] === 'string' ? item['unique-id'] : '(unpinned relationship)'; -} - -export function formatDiff( - diff: NodesAndRelationshipsDiffResult, - format: DiffOutputFormat, - documentType: DiffDocumentType = 'architecture', -): string { - if (format === 'json') { - return JSON.stringify(diff, null, 2); - } - const invalidNodes = diff.invalidItems?.nodes.length ?? 0; - const invalidEdges = diff.invalidItems?.relationships.length ?? 0; - const undiffableNodes = diff.undiffableItems?.nodes.length ?? 0; - const undiffableEdges = diff.undiffableItems?.relationships.length ?? 0; - const title = `CALM ${documentType} diff`; - const lines = [ - title, - '-'.repeat(title.length), - `Nodes: +${diff.nodesAdded.length} -${diff.nodesRemoved.length} ~${diff.nodesModified.length} ↔${diff.nodesRenamed.length} =${diff.nodesSame.length}`, - `Relationships: +${diff.edgesAdded.length} -${diff.edgesRemoved.length} ~${diff.edgesModified.length} ↔${diff.edgesRenamed.length} =${diff.edgesSame.length}`, - ]; - if (invalidNodes + invalidEdges > 0) { - lines.push(`Invalid items: ${invalidNodes} node(s) + ${invalidEdges} relationship(s) skipped (missing unique-id)`); - } - if (undiffableNodes + undiffableEdges > 0) { - lines.push(`Undiffable items: ${undiffableNodes} node(s) + ${undiffableEdges} relationship(s) (no constrained unique-id to diff by)`); - } - lines.push(''); - const list = (label: string, ids: string[]) => { - if (ids.length === 0) return; - lines.push(label); - for (const id of ids) lines.push(` - ${id}`); - lines.push(''); - }; - list('Nodes added:', diff.nodesAdded.map(nodeLabel)); - list('Nodes removed:', diff.nodesRemoved.map(nodeLabel)); - list('Nodes modified:', diff.nodesModified.map((n) => nodeLabel(n.original))); - list('Nodes renamed:', diff.nodesRenamed.map((r) => `${r.oldId} -> ${r.newId}`)); - list('Relationships added:', diff.edgesAdded.map(edgeLabel)); - list('Relationships removed:', diff.edgesRemoved.map(edgeLabel)); - list('Relationships modified:', diff.edgesModified.map((e) => edgeLabel(e.original))); - list('Relationships renamed:', diff.edgesRenamed.map((r) => `${r.oldId} -> ${r.newId}`)); - return lines.join('\n'); -} - function readDocument(filePath: string): Record { const resolved = path.resolve(filePath); const raw = fs.readFileSync(resolved, 'utf-8'); return JSON.parse(raw) as Record; } -function isObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function looksLikePattern(doc: Record): boolean { - const hasNodeOrRelProps = (schema: Record): boolean => { - const props = isObject(schema['properties']) ? schema['properties'] : undefined; - return !!props && (isObject(props['nodes']) || isObject(props['relationships'])); - }; - if (hasNodeOrRelProps(doc)) return true; - return Array.isArray(doc['allOf']) - && doc['allOf'].some((sub) => isObject(sub) && hasNodeOrRelProps(sub)); -} - -/** - * Classifies a document as an architecture instance (top-level - * `nodes`/`relationships` arrays) or a pattern (a JSON Schema describing those - * arrays under `properties`/`allOf`). Returns `null` when the input clearly - * matches neither shape, leaving the decision to the caller. - */ -export function tryDetectDocumentType(doc: Record): DiffDocumentType | null { - if (Array.isArray(doc['nodes']) || Array.isArray(doc['relationships'])) { - return 'architecture'; - } - if (looksLikePattern(doc)) { - return 'pattern'; - } - return null; -} - -/** - * Like {@link tryDetectDocumentType} but throws when the input matches neither - * shape, so malformed input is surfaced rather than silently diffed to an empty - * result. - */ -export function detectDocumentType(doc: Record): DiffDocumentType { - const detected = tryDetectDocumentType(doc); - if (detected) { - return detected; - } - throw new Error( - 'Could not determine the CALM document type: expected an architecture ' + - '(top-level nodes/relationships arrays) or a pattern (a JSON Schema ' + - 'describing them). Pass --type architecture|pattern to specify it explicitly.', - ); -} - -export async function runDiff( - docAPath: string, - docBPath: string, - options: DiffRunOptions = {}, -): Promise { +export async function runDiff(docAPath: string, docBPath: string, options: DiffRunOptions = {}): Promise { const logger = initLogger(!!options.verbose, 'calm-diff'); - const format = options.format ?? 'json'; - logger.info(`Comparing ${docAPath} -> ${docBPath}`); - const docA = readDocument(docAPath); - const docB = readDocument(docBPath); - - let documentType: DiffDocumentType; - if (options.documentType) { - // An explicit --type overrides auto-detection (and rescues genuinely - // ambiguous inputs), but if a document's content confidently matches the - // opposite type the override is almost certainly a mistake — fail loudly - // rather than emit a misleading empty diff. - documentType = options.documentType; - for (const [docPath, doc] of [[docAPath, docA], [docBPath, docB]] as const) { - const detected = tryDetectDocumentType(doc); - if (detected && detected !== documentType) { - throw new Error( - `--type was set to '${documentType}', but ${docPath} matches '${detected}'. ` + - 'Remove --type to auto-detect, or pass inputs of the forced type.', - ); - } - } - } else { - const typeA = detectDocumentType(docA); - const typeB = detectDocumentType(docB); - if (typeA !== typeB) { - throw new Error( - `Cannot diff mismatched document types: ${typeA} vs ${typeB}. Both inputs must be the ` + - 'same CALM document type; pass --type to override detection.', - ); - } - documentType = typeA; - } - - const diff = documentType === 'pattern' - ? diffPatterns(docA, docB) - : diffArchitectures(docA as CalmArchitectureSchema, docB as CalmArchitectureSchema); - - const invalidNodeCount = diff.invalidItems?.nodes.length ?? 0; - const invalidEdgeCount = diff.invalidItems?.relationships.length ?? 0; - if (invalidNodeCount + invalidEdgeCount > 0) { - logger.warn( - `Skipped ${invalidNodeCount} node(s) and ${invalidEdgeCount} relationship(s) ` + - 'because they were missing a unique-id. These items are reported under ' + - 'invalidItems and contribute to hasChanges so --exit-code does not pass on them silently.', - ); - } - - const undiffableNodeCount = diff.undiffableItems?.nodes.length ?? 0; - const undiffableEdgeCount = diff.undiffableItems?.relationships.length ?? 0; - if (undiffableNodeCount + undiffableEdgeCount > 0) { - logger.warn( - `Could not diff ${undiffableNodeCount} node(s) and ${undiffableEdgeCount} relationship(s) ` + - 'because they constrain no comparable content (e.g. an unconstrained unique-id). ' + - 'These items are reported under undiffableItems and contribute to hasChanges so ' + - '--exit-code does not pass on them silently.', - ); - } - - const formatted = formatDiff(diff, format, documentType); - + const result = diffDocuments(readDocument(docAPath), readDocument(docBPath), { + format: options.format, + verbose: options.verbose, + documentType: options.documentType, + labels: [docAPath, docBPath], + }); if (options.outputPath) { const dir = path.dirname(path.resolve(options.outputPath)); mkdirp.sync(dir); - fs.writeFileSync(options.outputPath, formatted); + fs.writeFileSync(options.outputPath, result.formatted); logger.info(`Wrote diff to ${options.outputPath}`); } - - return { diff, formatted, hasChanges: hasChanges(diff) }; -} - -export interface TimelineDiffRunOptions { - /** Diff only this single pair instead of all adjacent pairs. */ - fromMomentId?: string; - toMomentId?: string; - verbose?: boolean; -} - -export interface TimelineDiffRunResult { - /** Ordered diffs: one per adjacent pair, or a single entry for an explicit pair. */ - diffs: MomentDiff[]; + return result; } /** @@ -258,9 +54,7 @@ export interface TimelineDiffRunResult { */ export function createFileSystemArchitectureResolver(baseDir: string): ArchitectureResolver { return async (reference: string) => { - const resolved = path.isAbsolute(reference) - ? reference - : path.resolve(baseDir, reference); + const resolved = path.isAbsolute(reference) ? reference : path.resolve(baseDir, reference); const raw = await fs.promises.readFile(resolved, 'utf-8'); return JSON.parse(raw) as Record; }; @@ -272,32 +66,10 @@ export function createFileSystemArchitectureResolver(baseDir: string): Architect * adjacent moment pairs unless an explicit {@link TimelineDiffRunOptions.fromMomentId} * / {@link TimelineDiffRunOptions.toMomentId} pair is supplied. */ -export async function runTimelineDiff( - timelinePath: string, - options: TimelineDiffRunOptions = {}, -): Promise { +export async function runTimelineDiff(timelinePath: string, options: TimelineDiffRunOptions = {}): Promise { const logger = initLogger(!!options.verbose, 'calm-timeline-diff'); const resolvedPath = path.resolve(timelinePath); logger.info(`Diffing timeline ${resolvedPath}`); - const timeline = readDocument(resolvedPath) as TimelineInput; - const resolver = createFileSystemArchitectureResolver(path.dirname(resolvedPath)); - - if (options.fromMomentId || options.toMomentId) { - if (!options.fromMomentId || !options.toMomentId) { - throw new Error( - 'Both fromMomentId and toMomentId must be supplied to diff a specific pair.', - ); - } - const diff = await diffTimelineMoments( - timeline, - options.fromMomentId, - options.toMomentId, - resolver, - ); - return { diffs: [diff] }; - } - - const diffs = await diffTimelineAdjacent(timeline, resolver); - return { diffs }; + return diffTimeline(timeline, createFileSystemArchitectureResolver(path.dirname(resolvedPath)), options); } diff --git a/shared/src/commands/generate/generate-core.spec.ts b/shared/src/commands/generate/generate-core.spec.ts new file mode 100644 index 000000000..a37da61ac --- /dev/null +++ b/shared/src/commands/generate/generate-core.spec.ts @@ -0,0 +1,27 @@ +import { generate } from './generate-core'; +import { SchemaDirectory } from '../../schema-directory'; + +describe('generate core', () => { + it('returns the instantiated architecture object without touching the filesystem', async () => { + const pattern = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://x/pattern.json', + type: 'object', + properties: { + nodes: { type: 'array', prefixItems: [ + { type: 'object', properties: { 'unique-id': { const: 'a' }, 'node-type': { const: 'service' }, name: { const: 'A' }, description: { const: 'd' } } } + ] }, + relationships: { type: 'array', prefixItems: [] }, + }, + }; + const schemaDirectory = { loadSchemas: vi.fn(), getSchema: vi.fn(), getDefinition: vi.fn(), loadCurrentPatternAsSchema: vi.fn() } as unknown as SchemaDirectory; + const result = await generate(pattern, schemaDirectory) as { nodes: { 'unique-id': string }[] }; + expect(schemaDirectory.loadSchemas).toHaveBeenCalled(); + expect(result.nodes[0]['unique-id']).toBe('a'); + }); + + it('propagates errors instead of swallowing them', async () => { + const schemaDirectory = { loadSchemas: vi.fn().mockRejectedValue(new Error('boom')) } as unknown as SchemaDirectory; + await expect(generate({}, schemaDirectory)).rejects.toThrow('boom'); + }); +}); diff --git a/shared/src/commands/generate/generate-core.ts b/shared/src/commands/generate/generate-core.ts new file mode 100644 index 000000000..ef7e7908c --- /dev/null +++ b/shared/src/commands/generate/generate-core.ts @@ -0,0 +1,22 @@ +import { CalmChoice, selectChoices } from './components/options.js'; +import { instantiate } from './components/instantiate.js'; +import { flattenAllOf } from './components/flatten-allof.js'; +import { SchemaDirectory } from '../../schema-directory.js'; + +export interface GenerateOptions { + debug?: boolean; + chosenChoices?: CalmChoice[]; +} + +/** + * Instantiate an architecture from a pattern. Pure: no filesystem access, errors propagate. + */ +export async function generate(pattern: object, schemaDirectory: SchemaDirectory, options: GenerateOptions = {}): Promise { + const debug = options.debug ?? false; + await schemaDirectory.loadSchemas(); + let flattenedPattern = await flattenAllOf(pattern as Record, schemaDirectory, debug); + if (options.chosenChoices) { + flattenedPattern = selectChoices(flattenedPattern, options.chosenChoices, debug); + } + return instantiate(flattenedPattern, debug, schemaDirectory) as Promise; +} diff --git a/shared/src/commands/generate/generate.ts b/shared/src/commands/generate/generate.ts index 15ce20e9d..439fc4f49 100644 --- a/shared/src/commands/generate/generate.ts +++ b/shared/src/commands/generate/generate.ts @@ -2,33 +2,20 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { mkdirp } from 'mkdirp'; -import { CalmChoice, selectChoices } from './components/options.js'; -import { instantiate } from './components/instantiate'; -import { flattenAllOf } from './components/flatten-allof'; +import { CalmChoice } from './components/options.js'; import { initLogger } from '../../logger.js'; import { SchemaDirectory } from '../../schema-directory.js'; +import { generate } from './generate-core.js'; + +export { generate, type GenerateOptions } from './generate-core.js'; export async function runGenerate(pattern: object, outputPath: string, debug: boolean, schemaDirectory: SchemaDirectory, chosenChoices?: CalmChoice[]): Promise { const logger = initLogger(debug, 'calm-generate'); logger.info('Generating a CALM architecture...'); try { - // Flatten any allOf compositions before processing - await schemaDirectory.loadSchemas(); - let flattenedPattern = await flattenAllOf( - pattern as Record, - schemaDirectory, - debug - ); - - if (chosenChoices) { - flattenedPattern = selectChoices(flattenedPattern, chosenChoices, debug); - } - - const final = await instantiate(flattenedPattern, debug, schemaDirectory); + const final = await generate(pattern, schemaDirectory, { debug, chosenChoices }); const output = JSON.stringify(final, null, 2); - const dirname = path.dirname(outputPath); - - mkdirp.sync(dirname); + mkdirp.sync(path.dirname(outputPath)); fs.writeFileSync(outputPath, output); logger.info(`Successfully generated architecture to [${outputPath}]`); } catch (err) { diff --git a/shared/src/commands/validate/format-output.spec.ts b/shared/src/commands/validate/format-output.spec.ts new file mode 100644 index 000000000..068d190a6 --- /dev/null +++ b/shared/src/commands/validate/format-output.spec.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ValidationOutcome } from './validation.output'; + +describe('formatOutput registry', () => { + beforeEach(() => vi.resetModules()); + + const outcome = new ValidationOutcome([], [], false, false); + + it('formats json and pretty without any registration', async () => { + const { formatOutput } = await import('./format-output'); + expect(JSON.parse(formatOutput(outcome, 'json'))).toMatchObject({ hasErrors: false }); + expect(formatOutput(outcome, 'pretty')).toContain('No issues found'); + }); + + it('throws a clear error for an unregistered format', async () => { + const { formatOutput } = await import('./format-output'); + expect(() => formatOutput(outcome, 'junit')).toThrow(/junit.*not available/i); + }); + + it('uses a registered formatter', async () => { + const { formatOutput, registerOutputFormatter } = await import('./format-output'); + registerOutputFormatter('junit', () => ''); + expect(formatOutput(outcome, 'junit')).toBe(''); + }); + + it('the root barrel registers junit', async () => { + const { getFormattedOutput } = await import('../../index'); + expect(getFormattedOutput(outcome, 'junit')).toContain('; +} + +export type OutputFormatter = (outcome: ValidationOutcome, options?: ValidationFormattingOptions) => string; + +const formatters = new Map([ + ['json', (outcome) => prettifyJson(outcome)], + ['pretty', (outcome, options) => prettyFormat(outcome, options)], +]); + +/** + * Registers (or replaces) the formatter for an output format. The root entry point registers + * `junit`, which depends on a Node-oriented XML builder; the browser entry ships json + pretty. + */ +export function registerOutputFormatter(format: OutputFormat, formatter: OutputFormatter): void { + formatters.set(format, formatter); +} + +export function formatOutput( + validationOutcome: ValidationOutcome, + format: OutputFormat, + options?: ValidationFormattingOptions +): string { + const formatter = formatters.get(format); + if (!formatter) { + throw new Error(`Output format '${format}' is not available in this environment. Available: ${[...formatters.keys()].join(', ')}`); + } + return formatter(validationOutcome, options); +} diff --git a/shared/src/commands/validate/output-formats/junit-output.ts b/shared/src/commands/validate/output-formats/junit-output.ts index 1d07463fb..4f709e16d 100644 --- a/shared/src/commands/validate/output-formats/junit-output.ts +++ b/shared/src/commands/validate/output-formats/junit-output.ts @@ -1,5 +1,9 @@ import junitReportBuilder, { TestSuite } from 'junit-report-builder'; +import { RulesetDefinition } from '@stoplight/spectral-core'; import { ValidationOutcome } from '../validation.output'; +import validationRulesForPattern from '../../../spectral/rules-pattern.js'; +import validationRulesForArchitecture from '../../../spectral/rules-architecture.js'; +import type { OutputFormatter } from '../format-output.js'; export default function createJUnitReport( validationOutcome: ValidationOutcome, @@ -52,3 +56,13 @@ function createFailingTestCase(testSuite: TestSuite, testName: string){ .failure(); } +function getRuleNamesFromRuleset(ruleset: RulesetDefinition): string[] { + return Object.keys((ruleset as { rules: Record }).rules); +} + +function extractSpectralRuleNames(): string[] { + return getRuleNamesFromRuleset(validationRulesForArchitecture) + .concat(getRuleNamesFromRuleset(validationRulesForPattern)); +} + +export const junitFormatter: OutputFormatter = (outcome) => createJUnitReport(outcome, extractSpectralRuleNames()); diff --git a/shared/src/commands/validate/output-formats/pretty-output.ts b/shared/src/commands/validate/output-formats/pretty-output.ts index a6f3a6a5b..3ca9eb16e 100644 --- a/shared/src/commands/validate/output-formats/pretty-output.ts +++ b/shared/src/commands/validate/output-formats/pretty-output.ts @@ -1,13 +1,14 @@ -import path from 'path'; import { ValidationOutcome, ValidationOutput } from '../validation.output.js'; -import { ValidationFormattingOptions, ValidationDocumentContext } from '../validate.js'; +import type { ValidationFormattingOptions, ValidationDocumentContext } from '../format-output.js'; type Severity = 'error' | 'warning' | 'info' | 'hint' | string; const severityOrder: Severity[] = ['error', 'warning', 'info', 'hint']; -const supportsColor = Boolean(process?.stdout?.isTTY) && process.env.NO_COLOR !== '1'; +const supportsColor = typeof process !== 'undefined' + && Boolean(process.stdout?.isTTY) + && process.env?.NO_COLOR !== '1'; const colors = { red: (text: string) => (supportsColor ? `\u001b[31m${text}\u001b[0m` : text), @@ -32,6 +33,11 @@ const severityColor: Partial string>> = { hint: colors.gray }; +function basename(filePath: string): string { + const segments = filePath.split(/[\\/]/).filter(Boolean); + return segments.length ? segments[segments.length - 1] : filePath; +} + function formatSeverity(severity: Severity): string { const label = severityLabel[severity] ?? (severity ? severity.toUpperCase() : 'ISSUE'); const color = severityColor[severity] ?? ((text: string) => text); @@ -92,7 +98,7 @@ function buildDocumentHeader(documentId: string, context?: ValidationDocumentCon if (!context) { return `- In ${documentId || 'document'}:`; } - const label = context.label || path.basename(context.filePath || context.id || ''); + const label = context.label || basename(context.filePath || context.id || ''); const location = context.filePath ? ` (${context.filePath})` : ''; return `- In ${label}${location}:`; } diff --git a/shared/src/commands/validate/validate-core.ts b/shared/src/commands/validate/validate-core.ts new file mode 100644 index 000000000..81b720c44 --- /dev/null +++ b/shared/src/commands/validate/validate-core.ts @@ -0,0 +1,102 @@ +import { initLogger, Logger } from '../../logger.js'; +import { ValidationOutcome } from './validation.output.js'; +import { SchemaDirectory } from '../../schema-directory.js'; +import { ValidationContext, ValidationMode } from './validation-rule.js'; +import { createDefaultValidationEngine, ValidationEngine } from './validation-engine.js'; +import { CachingTrackingResolver } from '../../resolver/caching-tracking-resolver.js'; +import { SchemaDirectoryReferenceResolver } from '../../resolver/schema-directory-reference-resolver.js'; +import { ChainReferenceResolver } from '../../resolver/chain-reference-resolver.js'; +import { CalmReferenceResolver } from '../../resolver/calm-reference-resolver.js'; + +let logger: Logger; // defined later at startup + +/** + * Asserts that a schema directory was provided. Validating a pattern, a timeline, + * or an architecture against a pattern all require schema resolution, so a missing + * directory is a caller error rather than a recoverable condition. + */ +function assertSchemaDirectory(schemaDirectory: SchemaDirectory | undefined): asserts schemaDirectory is SchemaDirectory { + if (!schemaDirectory) { + throw new Error('A schema directory is required for schema validation'); + } +} + +/** + * Validation - with simple input parameters and output validation outcomes. + * + * The input combination is resolved to a {@link ValidationMode} and a {@link ValidationContext}, + * which the {@link ValidationEngine} runs through the registered rules (Spectral linting, + * JSON-Schema, controls and recursive node-details). + * + * @param architecture The architecture as a JS object, or undefined if not provided + * @param patternOrSchema The pattern (or schema) as a JS object, or undefined if not provided + * @param timeline The timeline as a JS object, or undefined if not provided + * @param schemaDirectory SchemaDirectory instance for schema resolution + * @param debug Whether to log at debug level + * @returns Validation report + */ +export async function validate( + architecture: object | undefined, + patternOrSchema: object | undefined, + timeline: object | undefined, + schemaDirectory?: SchemaDirectory, + debug: boolean = false, + additionalResolver?: CalmReferenceResolver +): Promise { + logger = initLogger(debug, 'calm-validate'); + + try { + const engine = createDefaultValidationEngine(); + const context = buildValidationContext(architecture, patternOrSchema, timeline, schemaDirectory, debug, engine, additionalResolver); + return await engine.validate(context); + } catch (error) { + logger.error('An error occurred:' + error); + throw error; + } +} + +/** + * Resolve the input combination to a mode + context, preserving the historical caller-error + * throws for invalid combinations. + */ +function buildValidationContext( + architecture: object | undefined, + patternOrSchema: object | undefined, + timeline: object | undefined, + schemaDirectory: SchemaDirectory | undefined, + debug: boolean, + engine: ValidationEngine, + additionalResolver?: CalmReferenceResolver +): ValidationContext { + const schemaResolver = new SchemaDirectoryReferenceResolver(schemaDirectory); + const baseResolver: CalmReferenceResolver = additionalResolver + ? new ChainReferenceResolver([additionalResolver, schemaResolver]) + : schemaResolver; + const references = new CachingTrackingResolver(baseResolver); + const base = { references, debug, engine }; + + if (timeline) { + if (architecture) { + throw new Error('You cannot provide an architecture when validating a timeline'); + } + if (!patternOrSchema) { + throw new Error('You must provide a schema to validate the timeline against, or the timeline must reference it internally'); + } + // It is acceptable, in fact desired, for `patternOrSchema` to be set, and be the CALM timeline schema. + assertSchemaDirectory(schemaDirectory); + return { ...base, mode: 'timeline' as ValidationMode, timeline, pattern: patternOrSchema, schemaDirectory }; + } else if (architecture && patternOrSchema) { + // Note that patternOrSchema may be a CALM pattern, or might be the CALM core schema. + assertSchemaDirectory(schemaDirectory); + return { ...base, mode: 'architecture-with-pattern' as ValidationMode, architecture, pattern: patternOrSchema, schemaDirectory }; + } else if (patternOrSchema) { + // `patternOrSchema` should really be a CALM pattern in this case. + assertSchemaDirectory(schemaDirectory); + return { ...base, mode: 'pattern-only' as ValidationMode, pattern: patternOrSchema, schemaDirectory }; + } else if (architecture) { + return { ...base, mode: 'architecture-only' as ValidationMode, architecture, schemaDirectory }; + } + + logger.debug('You must provide an architecture, a pattern, or a timeline'); + throw new Error('You must provide an architecture, a pattern, or a timeline'); +} diff --git a/shared/src/commands/validate/validate.ts b/shared/src/commands/validate/validate.ts index 524487c78..e6e837983 100644 --- a/shared/src/commands/validate/validate.ts +++ b/shared/src/commands/validate/validate.ts @@ -1,17 +1,6 @@ -import { RulesetDefinition } from '@stoplight/spectral-core'; - -import validationRulesForPattern from '../../spectral/rules-pattern.js'; -import validationRulesForArchitecture from '../../spectral/rules-architecture.js'; -import { initLogger, Logger } from '../../logger.js'; import { ValidationOutcome } from './validation.output.js'; -import createJUnitReport from './output-formats/junit-output.js'; -import prettyFormat from './output-formats/pretty-output.js'; -import { SchemaDirectory } from '../../schema-directory.js'; -import { ValidationContext, ValidationMode } from './validation-rule.js'; -import { createDefaultValidationEngine, ValidationEngine } from './validation-engine.js'; -import { prettifyJson } from './validation-helpers.js'; -import { CachingTrackingResolver } from '../../resolver/caching-tracking-resolver.js'; -import { SchemaDirectoryReferenceResolver } from '../../resolver/schema-directory-reference-resolver.js'; + +export { validate } from './validate-core.js'; // Re-export the shared helpers from their new home so existing importers/tests keep working. export { @@ -23,20 +12,15 @@ export { convertSpectralDiagnosticToValidationOutputs } from './validation-helpers.js'; -let logger: Logger; // defined later at startup - -export type ValidateOutputFormat = 'json' | 'junit' | 'pretty'; - -export interface ValidationDocumentContext { - id: string; - label?: string; - filePath?: string; - lines?: string[]; -} - -export interface ValidationFormattingOptions { - documents?: Record; -} +export { + formatOutput, + registerOutputFormatter, + type OutputFormat, + type ValidateOutputFormat, + type ValidationDocumentContext, + type ValidationFormattingOptions, + type OutputFormatter, +} from './format-output.js'; /** * TODO - move this out of shared and into the CLI - this is process-management code. @@ -53,118 +37,3 @@ export function exitBasedOffOfValidationOutcome(validationOutcome: ValidationOut } process.exit(0); } - -export type OutputFormat = 'junit' | 'json' | 'pretty' - -export function formatOutput( - validationOutcome: ValidationOutcome, - format: OutputFormat, - options?: ValidationFormattingOptions -): string { - logger.info(`Formatting output as ${format}`); - switch (format) { - case 'junit': { - const spectralRuleNames = extractSpectralRuleNames(); - return createJUnitReport(validationOutcome, spectralRuleNames); - } - case 'pretty': - return prettyFormat(validationOutcome, options); - case 'json': - return prettifyJson(validationOutcome); - } -} - -/** - * Asserts that a schema directory was provided. Validating a pattern, a timeline, - * or an architecture against a pattern all require schema resolution, so a missing - * directory is a caller error rather than a recoverable condition. - */ -function assertSchemaDirectory(schemaDirectory: SchemaDirectory | undefined): asserts schemaDirectory is SchemaDirectory { - if (!schemaDirectory) { - throw new Error('A schema directory is required for schema validation'); - } -} - -/** - * Validation - with simple input parameters and output validation outcomes. - * - * The input combination is resolved to a {@link ValidationMode} and a {@link ValidationContext}, - * which the {@link ValidationEngine} runs through the registered rules (Spectral linting, - * JSON-Schema, controls and recursive node-details). - * - * @param architecture The architecture as a JS object, or undefined if not provided - * @param patternOrSchema The pattern (or schema) as a JS object, or undefined if not provided - * @param timeline The timeline as a JS object, or undefined if not provided - * @param schemaDirectory SchemaDirectory instance for schema resolution - * @param debug Whether to log at debug level - * @returns Validation report - */ -export async function validate( - architecture: object | undefined, - patternOrSchema: object | undefined, - timeline: object | undefined, - schemaDirectory?: SchemaDirectory, - debug: boolean = false -): Promise { - logger = initLogger(debug, 'calm-validate'); - - try { - const engine = createDefaultValidationEngine(); - const context = buildValidationContext(architecture, patternOrSchema, timeline, schemaDirectory, debug, engine); - return await engine.validate(context); - } catch (error) { - logger.error('An error occurred:' + error); - throw error; - } -} - -/** - * Resolve the input combination to a mode + context, preserving the historical caller-error - * throws for invalid combinations. - */ -function buildValidationContext( - architecture: object | undefined, - patternOrSchema: object | undefined, - timeline: object | undefined, - schemaDirectory: SchemaDirectory | undefined, - debug: boolean, - engine: ValidationEngine -): ValidationContext { - const references = new CachingTrackingResolver(new SchemaDirectoryReferenceResolver(schemaDirectory)); - const base = { references, debug, engine }; - - if (timeline) { - if (architecture) { - throw new Error('You cannot provide an architecture when validating a timeline'); - } - if (!patternOrSchema) { - throw new Error('You must provide a schema to validate the timeline against, or the timeline must reference it internally'); - } - // It is acceptable, in fact desired, for `patternOrSchema` to be set, and be the CALM timeline schema. - assertSchemaDirectory(schemaDirectory); - return { ...base, mode: 'timeline' as ValidationMode, timeline, pattern: patternOrSchema, schemaDirectory }; - } else if (architecture && patternOrSchema) { - // Note that patternOrSchema may be a CALM pattern, or might be the CALM core schema. - assertSchemaDirectory(schemaDirectory); - return { ...base, mode: 'architecture-with-pattern' as ValidationMode, architecture, pattern: patternOrSchema, schemaDirectory }; - } else if (patternOrSchema) { - // `patternOrSchema` should really be a CALM pattern in this case. - assertSchemaDirectory(schemaDirectory); - return { ...base, mode: 'pattern-only' as ValidationMode, pattern: patternOrSchema, schemaDirectory }; - } else if (architecture) { - return { ...base, mode: 'architecture-only' as ValidationMode, architecture, schemaDirectory }; - } - - logger.debug('You must provide an architecture, a pattern, or a timeline'); - throw new Error('You must provide an architecture, a pattern, or a timeline'); -} - -function extractSpectralRuleNames(): string[] { - const architectureRuleNames = getRuleNamesFromRuleset(validationRulesForArchitecture); - const patternRuleNames = getRuleNamesFromRuleset(validationRulesForPattern); - return architectureRuleNames.concat(patternRuleNames); -} - -function getRuleNamesFromRuleset(ruleset: RulesetDefinition): string[] { - return Object.keys((ruleset as { rules: Record }).rules); -} diff --git a/shared/src/controls/merge-controls.spec.ts b/shared/src/controls/merge-controls.spec.ts new file mode 100644 index 000000000..03357608a --- /dev/null +++ b/shared/src/controls/merge-controls.spec.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { mergeControls } from './merge-controls.js'; + +describe('mergeControls', () => { + it('preserves existing key (not overwritten by incoming)', () => { + const existing = { security: { level: 'high' } }; + const incoming = { security: { level: 'low' } }; + const result = mergeControls(existing, incoming); + expect(result.security).toEqual({ level: 'high' }); + }); + + it('fills missing key from incoming (deep-copied)', () => { + const existing = { security: { level: 'high' } }; + const incoming = { compliance: { standard: 'PCI-DSS' } }; + const result = mergeControls(existing, incoming); + expect(result.security).toEqual({ level: 'high' }); + expect(result.compliance).toEqual({ standard: 'PCI-DSS' }); + }); + + it('returns all incoming keys when existing is empty', () => { + const existing = {}; + const incoming = { security: { level: 'high' }, compliance: { standard: 'SOC2' } }; + const result = mergeControls(existing, incoming); + expect(result).toEqual({ security: { level: 'high' }, compliance: { standard: 'SOC2' } }); + }); + + it('returns existing unchanged when incoming is empty', () => { + const existing = { security: { level: 'high' } }; + const incoming = {}; + const result = mergeControls(existing, incoming); + expect(result).toEqual({ security: { level: 'high' } }); + }); + + it('deep copies incoming values (modifying source does not affect merged result)', () => { + const existing = {}; + const incomingValue = { nested: { deep: 'original' } }; + const incoming = { control: incomingValue }; + const result = mergeControls(existing, incoming); + + // Mutate the source + incomingValue.nested.deep = 'mutated'; + + // Merged result should not be affected + expect((result.control as { nested: { deep: string } }).nested.deep).toBe('original'); + }); +}); diff --git a/shared/src/controls/merge-controls.ts b/shared/src/controls/merge-controls.ts new file mode 100644 index 000000000..1ba708e20 --- /dev/null +++ b/shared/src/controls/merge-controls.ts @@ -0,0 +1,12 @@ +export function mergeControls( + existing: Record, + incoming: Record, +): Record { + const merged = { ...existing }; + for (const [key, value] of Object.entries(incoming)) { + if (!(key in merged)) { + merged[key] = JSON.parse(JSON.stringify(value)); + } + } + return merged; +} diff --git a/shared/src/document-loader/browser-document-loader.spec.ts b/shared/src/document-loader/browser-document-loader.spec.ts new file mode 100644 index 000000000..987115f20 --- /dev/null +++ b/shared/src/document-loader/browser-document-loader.spec.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + inMemory: vi.fn(function () { return { kind: 'memory', initialise: vi.fn(), loadMissingDocument: vi.fn(), resolvePath: vi.fn() }; }), + calmHub: vi.fn(function () { return { kind: 'hub', initialise: vi.fn(), loadMissingDocument: vi.fn(), resolvePath: vi.fn() }; }), + directUrl: vi.fn(function () { return { kind: 'url', initialise: vi.fn(), loadMissingDocument: vi.fn(), resolvePath: vi.fn() }; }), + multi: vi.fn(function (loaders: unknown[]) { return { kind: 'multi', loaders }; }), +})); + +vi.mock('./in-memory-document-loader', () => ({ InMemoryDocumentLoader: mocks.inMemory })); +vi.mock('./calmhub-document-loader', () => ({ CalmHubDocumentLoader: mocks.calmHub })); +vi.mock('./direct-url-document-loader', () => ({ DirectUrlDocumentLoader: mocks.directUrl })); +vi.mock('./multi-strategy-document-loader', () => ({ MultiStrategyDocumentLoader: mocks.multi })); + +import { buildBrowserDocumentLoader } from './browser-document-loader'; + +describe('buildBrowserDocumentLoader', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('composes in-memory then direct-url by default', () => { + const docs = { 'https://x/a.json': {} }; + const loader = buildBrowserDocumentLoader({ documents: docs }) as unknown as { loaders: { kind: string }[] }; + expect(mocks.inMemory).toHaveBeenCalledWith(docs, false); + expect(mocks.calmHub).not.toHaveBeenCalled(); + expect(mocks.directUrl).toHaveBeenCalledWith(false, undefined, undefined); + expect(loader.loaders.map((l) => l.kind)).toEqual(['memory', 'url']); + }); + + it('inserts a CalmHub loader between memory and url when a hub url is given', () => { + const authPlugin = { getAuthHeaders: vi.fn() }; + const loader = buildBrowserDocumentLoader({ + documents: {}, calmHubUrl: 'https://hub', authPlugin: authPlugin as never, allowedRemoteHosts: ['calm.finos.org'], debug: true, + }) as unknown as { loaders: { kind: string }[] }; + expect(mocks.calmHub).toHaveBeenCalledWith('https://hub', true, authPlugin); + expect(mocks.directUrl).toHaveBeenCalledWith(true, undefined, ['calm.finos.org']); + expect(loader.loaders.map((l) => l.kind)).toEqual(['memory', 'hub', 'url']); + }); + + it('omits the direct-url loader when allowRemote is false', () => { + const loader = buildBrowserDocumentLoader({ documents: {}, allowRemote: false }) as unknown as { loaders: { kind: string }[] }; + expect(mocks.directUrl).not.toHaveBeenCalled(); + expect(loader.loaders.map((l) => l.kind)).toEqual(['memory']); + }); +}); diff --git a/shared/src/document-loader/browser-document-loader.ts b/shared/src/document-loader/browser-document-loader.ts new file mode 100644 index 000000000..f2774f0a8 --- /dev/null +++ b/shared/src/document-loader/browser-document-loader.ts @@ -0,0 +1,33 @@ +import type { AuthPlugin } from '../auth/auth-plugin.js'; +import type { DocumentLoader } from './document-loader.js'; +import { InMemoryDocumentLoader } from './in-memory-document-loader.js'; +import { CalmHubDocumentLoader } from './calmhub-document-loader.js'; +import { DirectUrlDocumentLoader } from './direct-url-document-loader.js'; +import { MultiStrategyDocumentLoader } from './multi-strategy-document-loader.js'; + +export interface BrowserDocumentLoaderOptions { + /** documentId -> document. Entries with a string `$id` are registered as schemas. */ + documents: Record; + calmHubUrl?: string; + authPlugin?: AuthPlugin; + allowedRemoteHosts?: string[]; + /** Set false to disable direct HTTP(S) loading entirely. Default true. */ + allowRemote?: boolean; + debug?: boolean; +} + +/** + * Browser counterpart of `buildDocumentLoader`: no filesystem strategies, no `process.cwd()`. + * Order of precedence: in-memory documents, then CALM Hub (if configured), then direct URLs. + */ +export function buildBrowserDocumentLoader(opts: BrowserDocumentLoaderOptions): DocumentLoader { + const debug = opts.debug ?? false; + const loaders: DocumentLoader[] = [new InMemoryDocumentLoader(opts.documents, debug)]; + if (opts.calmHubUrl) { + loaders.push(new CalmHubDocumentLoader(opts.calmHubUrl, debug, opts.authPlugin)); + } + if (opts.allowRemote !== false) { + loaders.push(new DirectUrlDocumentLoader(debug, undefined, opts.allowedRemoteHosts)); + } + return new MultiStrategyDocumentLoader(loaders, debug); +} diff --git a/shared/src/document-loader/calmhub-document-loader.spec.ts b/shared/src/document-loader/calmhub-document-loader.spec.ts index 015a1e55d..5083e9e26 100644 --- a/shared/src/document-loader/calmhub-document-loader.spec.ts +++ b/shared/src/document-loader/calmhub-document-loader.spec.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios, { Axios } from 'axios'; import AxiosMockAdapter from 'axios-mock-adapter'; import { CalmHubDocumentLoader } from './calmhub-document-loader'; import { DocumentLoadError } from './document-loader'; @@ -119,4 +119,38 @@ describe('calmhub-document-loader', () => { await expect(promise).rejects.toBeInstanceOf(DocumentLoadError); await expect(promise).rejects.toThrow('Expected a JSON object'); }); + + it('rejects a response redirected to a different origin', async () => { + const redirectAx = axios.create({ baseURL: calmHubBaseUrl }); + vi.spyOn(redirectAx, 'get').mockResolvedValue({ + data: { '$id': 'https://evil.example/x.json' }, + status: 200, + statusText: 'OK', + headers: {}, + config: {}, + request: { responseURL: 'https://evil.example/x.json' } + }); + const redirectedLoader = new CalmHubDocumentLoader(calmHubBaseUrl, false, undefined, redirectAx as unknown as Axios); + + const calmHubUrl = 'calm:/schemas/2025-03/meta/core.json'; + await expect(redirectedLoader.loadMissingDocument(calmHubUrl, 'schema')) + .rejects.toThrow('redirected to a different origin'); + }); + + it('accepts a response whose responseURL confirms the same origin', async () => { + const sameOriginAx = axios.create({ baseURL: calmHubBaseUrl }); + vi.spyOn(sameOriginAx, 'get').mockResolvedValue({ + data: { '$id': 'https://calm.finos.org/core.json' }, + status: 200, + statusText: 'OK', + headers: {}, + config: {}, + request: { responseURL: `${calmHubBaseUrl}/schemas/2025-03/meta/core.json` } + }); + const sameOriginLoader = new CalmHubDocumentLoader(calmHubBaseUrl, false, undefined, sameOriginAx as unknown as Axios); + + const calmHubUrl = 'calm:/schemas/2025-03/meta/core.json'; + const document = await sameOriginLoader.loadMissingDocument(calmHubUrl, 'schema'); + expect(document).toEqual({ '$id': 'https://calm.finos.org/core.json' }); + }); }); \ No newline at end of file diff --git a/shared/src/document-loader/calmhub-document-loader.ts b/shared/src/document-loader/calmhub-document-loader.ts index f70450ce6..cdab374ec 100644 --- a/shared/src/document-loader/calmhub-document-loader.ts +++ b/shared/src/document-loader/calmhub-document-loader.ts @@ -1,6 +1,7 @@ import axios, { Axios } from 'axios'; import { SchemaDirectory } from '../schema-directory'; import { DocumentLoader, assertJsonObject, DocumentLoadError, CALM_HUB_PROTOS } from './document-loader'; +import { assertResponseOrigin } from './response-origin.js'; import { initLogger, Logger } from '../logger'; import { AuthPlugin } from '../auth/auth-plugin'; import type { CalmDocumentType } from '@finos/calm-models/types'; @@ -120,6 +121,7 @@ export class CalmHubDocumentLoader implements DocumentLoader { try { const response = await this.ax.get(path); + assertResponseOrigin(response, this.calmHubOrigin, documentId); const document = response.data; assertJsonObject(document, documentId); this.logger.debug('Successfully loaded document from CALMHub with id ' + documentId); diff --git a/shared/src/document-loader/direct-url-document-loader.spec.ts b/shared/src/document-loader/direct-url-document-loader.spec.ts index 9065f4523..daf1accf8 100644 --- a/shared/src/document-loader/direct-url-document-loader.spec.ts +++ b/shared/src/document-loader/direct-url-document-loader.spec.ts @@ -1,4 +1,4 @@ -import axios from 'axios'; +import axios, { Axios } from 'axios'; import AxiosMockAdapter from 'axios-mock-adapter'; import { DirectUrlDocumentLoader } from './direct-url-document-loader'; import { DocumentLoadError } from './document-loader'; @@ -191,4 +191,38 @@ describe('direct-url-document-loader', () => { await expect(directUrlDocumentLoader.loadMissingDocument(url, 'schema')) .rejects.toThrow('query string'); }); + + it('rejects a response redirected to a different origin', async () => { + const redirectAx = axios.create({}); + vi.spyOn(redirectAx, 'get').mockResolvedValue({ + data: { '$id': 'https://evil.example/x.json' }, + status: 200, + statusText: 'OK', + headers: {}, + config: {}, + request: { responseURL: 'https://evil.example/x.json' } + }); + const redirectedLoader = new DirectUrlDocumentLoader(false, redirectAx as unknown as Axios); + + const url = 'https://calm.finos.org/calm/schemas/2025-03/meta/core.json'; + await expect(redirectedLoader.loadMissingDocument(url, 'schema')) + .rejects.toThrow('redirected to a different origin'); + }); + + it('accepts a response whose responseURL confirms the same origin', async () => { + const sameOriginAx = axios.create({}); + vi.spyOn(sameOriginAx, 'get').mockResolvedValue({ + data: { '$id': 'https://calm.finos.org/core.json' }, + status: 200, + statusText: 'OK', + headers: {}, + config: {}, + request: { responseURL: 'https://calm.finos.org/calm/schemas/2025-03/meta/core.json' } + }); + const sameOriginLoader = new DirectUrlDocumentLoader(false, sameOriginAx as unknown as Axios); + + const url = 'https://calm.finos.org/calm/schemas/2025-03/meta/core.json'; + const document = await sameOriginLoader.loadMissingDocument(url, 'schema'); + expect(document).toEqual({ '$id': 'https://calm.finos.org/core.json' }); + }); }); diff --git a/shared/src/document-loader/direct-url-document-loader.ts b/shared/src/document-loader/direct-url-document-loader.ts index 96671d7e5..97625b3f2 100644 --- a/shared/src/document-loader/direct-url-document-loader.ts +++ b/shared/src/document-loader/direct-url-document-loader.ts @@ -1,7 +1,8 @@ import axios, { Axios } from 'axios'; -import { isIP } from 'net'; +import { ipLiteralVersion } from '../util/ip-literal.js'; import { SchemaDirectory } from '../schema-directory'; import { DocumentLoader, DocumentLoadError, assertJsonObject } from './document-loader'; +import { assertResponseOrigin } from './response-origin.js'; import { Logger, initLogger } from '../logger'; import type { CalmDocumentType } from '@finos/calm-models/types'; @@ -29,11 +30,11 @@ const SAFE_PATH_PATTERN = /^[a-zA-Z0-9/_.-]+$/; function isPrivateHost(hostname: string): boolean { if (/^localhost$/i.test(hostname)) return true; - // URL.hostname wraps IPv6 in brackets; strip them for isIP/pattern checks + // URL.hostname wraps IPv6 in brackets; strip them for ipLiteralVersion/pattern checks const bare = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname; - const ipVersion = isIP(bare); + const ipVersion = ipLiteralVersion(bare); if (ipVersion === 4) return PRIVATE_IPV4_PATTERNS.some(p => p.test(bare)); if (ipVersion === 6) return PRIVATE_IPV6_PATTERNS.some(p => p.test(bare)); return false; @@ -170,6 +171,7 @@ export class DirectUrlDocumentLoader implements DocumentLoader { maxRedirects: 0, allowAbsoluteUrls: false }); + assertResponseOrigin(response, new URL(baseURL).origin, documentId); assertJsonObject(response.data, documentId); return response.data; } catch (error) { diff --git a/shared/src/document-loader/document-loader.spec.ts b/shared/src/document-loader/document-loader.spec.ts index 7da574805..49962b8f7 100644 --- a/shared/src/document-loader/document-loader.spec.ts +++ b/shared/src/document-loader/document-loader.spec.ts @@ -1,190 +1,4 @@ -import { AuthPlugin } from '../auth/auth-plugin'; -import { CALM_META_SCHEMA_DIRECTORY } from '../consts'; -import { CALM_DOCUMENT_TYPES_LIST, isValidCalmDocumentType } from '@finos/calm-models/types'; -import { assertJsonObject, buildDocumentLoader, DocumentLoaderOptions, DocumentLoadError } from './document-loader'; - -const mocks = vi.hoisted(() => { - return { - fsDocLoader: vi.fn(function () { return { - initialise: vi.fn(), - loadMissingDocument: vi.fn() - }; }), - calmHubDocLoader: vi.fn(function () { return { - initialise: vi.fn(), - loadMissingDocument: vi.fn() - }; }), - mappedDocLoader: vi.fn(function () { return { - initialise: vi.fn(), - loadMissingDocument: vi.fn() - }; }), - directDocLoader: vi.fn(function () { return { - initialise: vi.fn(), - loadMissingDocument: vi.fn() - }; }), - workspaceDocLoader: vi.fn(function () { return { - initialise: vi.fn(), - loadMissingDocument: vi.fn() - }; }) - }; -}); - - -vi.mock('./file-system-document-loader', () => { - return { - FileSystemDocumentLoader: mocks.fsDocLoader - }; -}); - -vi.mock('./calmhub-document-loader', () => { - return { - CalmHubDocumentLoader: mocks.calmHubDocLoader - }; -}); - -vi.mock('./mapped-document-loader', () => { - return { - MappedDocumentLoader: mocks.mappedDocLoader - }; -}); - -vi.mock('./direct-url-document-loader', () => { - return { - DirectUrlDocumentLoader: mocks.directDocLoader - }; -}); - -vi.mock('./workspace-document-loader', () => { - return { - WorkspaceDocumentLoader: mocks.workspaceDocLoader - }; -}); - -describe('DocumentLoader', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.resetModules(); - }); - - it('should create a FileSystemDocumentLoader', () => { - - const docLoaderOpts: DocumentLoaderOptions = { - schemaDirectoryPath: 'schemas' - }; - - buildDocumentLoader(docLoaderOpts); - - expect(mocks.fsDocLoader).toHaveBeenCalledWith([CALM_META_SCHEMA_DIRECTORY, 'schemas'], false, process.cwd()); - }); - - it('should not create a WorkspaceDocumentLoader when workspaceBundlePath is absent', () => { - buildDocumentLoader({ schemaDirectoryPath: 'schemas' }); - expect(mocks.workspaceDocLoader).not.toHaveBeenCalled(); - }); - - it('should create a WorkspaceDocumentLoader when workspaceBundlePath is provided', () => { - const docLoaderOpts: DocumentLoaderOptions = { - workspaceBundlePath: '/repo/.calm-workspace/bundles/default' - }; - - buildDocumentLoader(docLoaderOpts); - - expect(mocks.workspaceDocLoader).toHaveBeenCalledWith('/repo/.calm-workspace/bundles/default', false); - }); - - it('should create a CalmHubDocumentLoader when calmHubUrl is defined in loader options', () => { - - const docLoaderOpts: DocumentLoaderOptions = { - calmHubUrl: 'https://example.com' - }; - - buildDocumentLoader(docLoaderOpts); - - expect(mocks.calmHubDocLoader).toHaveBeenCalledWith('https://example.com', false, undefined); - }); - - it('should pass authplugin to CalmHubDocumentLoader', () => { - - const mockAuthPlugin: AuthPlugin = { - getAuthHeaders: vi.fn() - }; - - const docLoaderOpts: DocumentLoaderOptions = { - calmHubUrl: 'https://example.com', - authPlugin: mockAuthPlugin - }; - - buildDocumentLoader(docLoaderOpts); - - expect(mocks.calmHubDocLoader).toHaveBeenCalledWith('https://example.com', false, mockAuthPlugin); - }); - - it('should pass allowedRemoteHosts to DirectUrlDocumentLoader when provided', () => { - const docLoaderOpts: DocumentLoaderOptions = { - allowedRemoteHosts: ['schemas.example.com'] - }; - - buildDocumentLoader(docLoaderOpts); - - expect(mocks.directDocLoader).toHaveBeenCalledWith(false, undefined, ['schemas.example.com']); - }); - - it('should create a MappedDocumentLoader when urlToLocalMap is provided', () => { - const urlMap = new Map([ - ['https://example.com/schema.json', 'local/schema.json'] - ]); - - const docLoaderOpts: DocumentLoaderOptions = { - urlToLocalMap: urlMap - }; - - buildDocumentLoader(docLoaderOpts); - - expect(mocks.mappedDocLoader).toHaveBeenCalledWith(urlMap, process.cwd(), false); - }); - - it('should create a MappedDocumentLoader when basePath is provided', () => { - const docLoaderOpts: DocumentLoaderOptions = { - basePath: '/project/patterns' - }; - - buildDocumentLoader(docLoaderOpts); - - expect(mocks.mappedDocLoader).toHaveBeenCalledWith(new Map(), '/project/patterns', false); - }); - - it('should create a MappedDocumentLoader with both urlToLocalMap and basePath', () => { - const urlMap = new Map([ - ['https://example.com/schema.json', 'local/schema.json'] - ]); - - const docLoaderOpts: DocumentLoaderOptions = { - urlToLocalMap: urlMap, - basePath: '/custom/base' - }; - - buildDocumentLoader(docLoaderOpts); - - expect(mocks.mappedDocLoader).toHaveBeenCalledWith(urlMap, '/custom/base', false); - }); - - it('should not create a MappedDocumentLoader when neither urlToLocalMap nor basePath provided', () => { - const docLoaderOpts: DocumentLoaderOptions = {}; - - buildDocumentLoader(docLoaderOpts); - - expect(mocks.mappedDocLoader).not.toHaveBeenCalled(); - }); - - it('should not create a MappedDocumentLoader when urlToLocalMap is empty and no basePath', () => { - const docLoaderOpts: DocumentLoaderOptions = { - urlToLocalMap: new Map() - }; - - buildDocumentLoader(docLoaderOpts); - - expect(mocks.mappedDocLoader).not.toHaveBeenCalled(); - }); -}); +import { assertJsonObject, DocumentLoadError } from './document-loader'; describe('DocumentLoadError', () => { it('defaults to recoverable so unmarked errors fall through to the next loader', () => { @@ -220,16 +34,3 @@ describe('assertJsonObject', () => { expect((thrown as DocumentLoadError).message).toBe(`Expected a JSON object from calm:/foo but received: ${kind}`); }); }); - -describe('isValidCalmDocumentType', () => { - it.each(CALM_DOCUMENT_TYPES_LIST)('returns true for the valid document type %s', (type) => { - expect(isValidCalmDocumentType(type)).toBe(true); - }); - - it.each(['unknown', 'architectures', 'Pattern', '', 'foo'])( - 'returns false for the invalid document type %s', - (type) => { - expect(isValidCalmDocumentType(type)).toBe(false); - } - ); -}); diff --git a/shared/src/document-loader/document-loader.ts b/shared/src/document-loader/document-loader.ts index 0935ee7f4..a8d42d007 100644 --- a/shared/src/document-loader/document-loader.ts +++ b/shared/src/document-loader/document-loader.ts @@ -1,12 +1,5 @@ -import { CALM_META_SCHEMA_DIRECTORY } from '../consts'; -import { SchemaDirectory } from '../schema-directory'; -import { CalmHubDocumentLoader } from './calmhub-document-loader'; -import { FileSystemDocumentLoader } from './file-system-document-loader'; -import { DirectUrlDocumentLoader } from './direct-url-document-loader'; -import { MultiStrategyDocumentLoader } from './multi-strategy-document-loader'; -import { MappedDocumentLoader } from './mapped-document-loader'; -import { WorkspaceDocumentLoader } from './workspace-document-loader'; -import { AuthPlugin } from '..'; +import type { SchemaDirectory } from '../schema-directory.js'; +import type { AuthPlugin } from '../auth/auth-plugin.js'; import type { CalmDocumentType } from '@finos/calm-models/types'; export const CALM_HUB_PROTOS = ['http:', 'https:', 'calm:']; @@ -34,47 +27,6 @@ export type DocumentLoaderOptions = { workspaceBundlePath?: string; }; -export function buildDocumentLoader(docLoaderOpts: DocumentLoaderOptions): DocumentLoader { - const loaders = []; - const debug = docLoaderOpts.debug ?? false; - - // Workspace bundle takes top priority: local working copies override CalmHub and every - // other source, for any reference form (bare id, $id, versioned path, or full URL). - if (docLoaderOpts.workspaceBundlePath) { - loaders.push(new WorkspaceDocumentLoader(docLoaderOpts.workspaceBundlePath, debug)); - } - - // Add MappedDocumentLoader FIRST if mapping or basePath provided - // This ensures URL mappings are resolved before other loaders. - // Note: Relative paths are handled by FileSystemDocumentLoader later in the chain. - if ((docLoaderOpts.urlToLocalMap && docLoaderOpts.urlToLocalMap.size > 0) || docLoaderOpts.basePath) { - loaders.push(new MappedDocumentLoader( - docLoaderOpts.urlToLocalMap ?? new Map(), - docLoaderOpts.basePath ?? process.cwd(), - debug - )); - } - - if (docLoaderOpts.calmHubUrl) { - loaders.push(new CalmHubDocumentLoader(docLoaderOpts.calmHubUrl, debug, docLoaderOpts.authPlugin)); - } - - // Always configure FileSystemDocumentLoader with CALM_META_SCHEMA_DIRECTORY - const directoryPaths = [CALM_META_SCHEMA_DIRECTORY]; - if (docLoaderOpts.schemaDirectoryPath) { - directoryPaths.push(docLoaderOpts.schemaDirectoryPath); - } - loaders.push(new FileSystemDocumentLoader( - directoryPaths, - debug, - docLoaderOpts.basePath ?? process.cwd() - )); - - loaders.push(new DirectUrlDocumentLoader(debug, undefined, docLoaderOpts.allowedRemoteHosts)); - - return new MultiStrategyDocumentLoader(loaders, debug); -} - export function assertJsonObject(data: unknown, source: string): asserts data is object { if (typeof data !== 'object' || data === null || Array.isArray(data)) { const kind = data === null ? 'null' : Array.isArray(data) ? 'array' : typeof data; diff --git a/shared/src/document-loader/in-memory-document-loader.spec.ts b/shared/src/document-loader/in-memory-document-loader.spec.ts new file mode 100644 index 000000000..18f4fbd59 --- /dev/null +++ b/shared/src/document-loader/in-memory-document-loader.spec.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, vi } from 'vitest'; +import { InMemoryDocumentLoader } from './in-memory-document-loader'; +import { DocumentLoadError } from './document-loader'; +import { SchemaDirectory } from '../schema-directory'; + +const CORE = { $id: 'https://calm.finos.org/release/1.2/meta/core.json', $schema: 'https://json-schema.org/draft/2020-12/schema' }; +const ARCH = { 'unique-id': 'arch', nodes: [], relationships: [] }; + +describe('InMemoryDocumentLoader', () => { + it('stores every document with a $id into the schema directory on initialise', async () => { + const loader = new InMemoryDocumentLoader({ [CORE.$id]: CORE, 'https://x/arch.json': ARCH }); + const schemaDirectory = { storeDocument: vi.fn() } as unknown as SchemaDirectory; + await loader.initialise(schemaDirectory); + expect(schemaDirectory.storeDocument).toHaveBeenCalledTimes(1); + expect(schemaDirectory.storeDocument).toHaveBeenCalledWith(CORE.$id, 'schema', CORE); + }); + + it('serves documents by id regardless of type', async () => { + const loader = new InMemoryDocumentLoader({ 'https://x/arch.json': ARCH }); + await expect(loader.loadMissingDocument('https://x/arch.json', 'architecture')).resolves.toBe(ARCH); + }); + + it('throws a recoverable OPERATION_NOT_IMPLEMENTED error for unknown ids', async () => { + const loader = new InMemoryDocumentLoader({}); + await expect(loader.loadMissingDocument('https://x/missing.json', 'schema')).rejects.toMatchObject({ + name: 'OPERATION_NOT_IMPLEMENTED', + recoverable: true, + }); + await expect(loader.loadMissingDocument('https://x/missing.json', 'schema')).rejects.toBeInstanceOf(DocumentLoadError); + }); + + it('never resolves references to local paths', () => { + expect(new InMemoryDocumentLoader({}).resolvePath('./foo.json')).toBeUndefined(); + }); + + it('lets SchemaDirectory resolve a missing schema to undefined', async () => { + const schemaDirectory = new SchemaDirectory(new InMemoryDocumentLoader({ [CORE.$id]: CORE })); + await schemaDirectory.loadSchemas(); + expect(schemaDirectory.getLoadedSchemas()).toEqual([CORE.$id]); + await expect(schemaDirectory.getSchema('https://x/nope.json')).resolves.toBeUndefined(); + }); +}); diff --git a/shared/src/document-loader/in-memory-document-loader.ts b/shared/src/document-loader/in-memory-document-loader.ts new file mode 100644 index 000000000..c7bd3fdcb --- /dev/null +++ b/shared/src/document-loader/in-memory-document-loader.ts @@ -0,0 +1,49 @@ +import type { SchemaDirectory } from '../schema-directory.js'; +import { DocumentLoader, DocumentLoadError } from './document-loader.js'; +import { initLogger, Logger } from '../logger.js'; +import type { CalmDocumentType } from '@finos/calm-models/types'; + +/** + * A {@link DocumentLoader} over a caller-supplied map of documentId -> document. Browser + * consumers mount their virtual filesystem (and the CALM meta-schemas they bundle) through + * this loader; it is also convenient for tests. + * + * Documents whose `$id` is a string are registered as schemas on initialise, so schema lookups + * behave exactly as they do with {@link FileSystemDocumentLoader} over a schema directory. + * + * Documents are returned by reference, not cloned, so a consumer reusing one `documents` map + * across runs shares object identity between runs — unlike {@link FileSystemDocumentLoader}, + * which re-parses from disk on every load. + */ +export class InMemoryDocumentLoader implements DocumentLoader { + private readonly logger: Logger; + + constructor(private readonly documents: Record, debug: boolean = false) { + this.logger = initLogger(debug, 'in-memory-document-loader'); + } + + async initialise(schemaDirectory: SchemaDirectory): Promise { + for (const [key, document] of Object.entries(this.documents)) { + const id = (document as { $id?: unknown })['$id']; + if (typeof id !== 'string') { + this.logger.debug(`Skipping ${key}: no $id, not a schema.`); + continue; + } + schemaDirectory.storeDocument(id, 'schema', document); + this.logger.debug(`Registered schema ${id} from in-memory document ${key}.`); + } + } + + async loadMissingDocument(documentId: string, type: CalmDocumentType): Promise { + if (Object.prototype.hasOwnProperty.call(this.documents, documentId)) { + return this.documents[documentId]; + } + const message = `Document with id [${documentId}] and type [${type}] is not present in the in-memory document store.`; + this.logger.debug(message); + throw new DocumentLoadError({ name: 'OPERATION_NOT_IMPLEMENTED', message }); + } + + resolvePath(_reference: string): string | undefined { + return undefined; + } +} diff --git a/shared/src/document-loader/node-document-loader.spec.ts b/shared/src/document-loader/node-document-loader.spec.ts new file mode 100644 index 000000000..03d41d80d --- /dev/null +++ b/shared/src/document-loader/node-document-loader.spec.ts @@ -0,0 +1,201 @@ +import { AuthPlugin } from '../auth/auth-plugin'; +import { CALM_META_SCHEMA_DIRECTORY } from '../consts'; +import { CALM_DOCUMENT_TYPES_LIST, isValidCalmDocumentType } from '@finos/calm-models/types'; +import { DocumentLoaderOptions } from './document-loader'; +import { buildDocumentLoader } from './node-document-loader'; + +const mocks = vi.hoisted(() => { + return { + fsDocLoader: vi.fn(function () { return { + initialise: vi.fn(), + loadMissingDocument: vi.fn() + }; }), + calmHubDocLoader: vi.fn(function () { return { + initialise: vi.fn(), + loadMissingDocument: vi.fn() + }; }), + mappedDocLoader: vi.fn(function () { return { + initialise: vi.fn(), + loadMissingDocument: vi.fn() + }; }), + directDocLoader: vi.fn(function () { return { + initialise: vi.fn(), + loadMissingDocument: vi.fn() + }; }), + workspaceDocLoader: vi.fn(function () { return { + initialise: vi.fn(), + loadMissingDocument: vi.fn() + }; }) + }; +}); + + +vi.mock('./file-system-document-loader', () => { + return { + FileSystemDocumentLoader: mocks.fsDocLoader + }; +}); + +vi.mock('./calmhub-document-loader', () => { + return { + CalmHubDocumentLoader: mocks.calmHubDocLoader + }; +}); + +vi.mock('./mapped-document-loader', () => { + return { + MappedDocumentLoader: mocks.mappedDocLoader + }; +}); + +vi.mock('./direct-url-document-loader', () => { + return { + DirectUrlDocumentLoader: mocks.directDocLoader + }; +}); + +vi.mock('./workspace-document-loader', () => { + return { + WorkspaceDocumentLoader: mocks.workspaceDocLoader + }; +}); + +describe('DocumentLoader', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + it('should create a FileSystemDocumentLoader', () => { + + const docLoaderOpts: DocumentLoaderOptions = { + schemaDirectoryPath: 'schemas' + }; + + buildDocumentLoader(docLoaderOpts); + + expect(mocks.fsDocLoader).toHaveBeenCalledWith([CALM_META_SCHEMA_DIRECTORY, 'schemas'], false, process.cwd()); + }); + + it('should not create a WorkspaceDocumentLoader when workspaceBundlePath is absent', () => { + buildDocumentLoader({ schemaDirectoryPath: 'schemas' }); + expect(mocks.workspaceDocLoader).not.toHaveBeenCalled(); + }); + + it('should create a WorkspaceDocumentLoader when workspaceBundlePath is provided', () => { + const docLoaderOpts: DocumentLoaderOptions = { + workspaceBundlePath: '/repo/.calm-workspace/bundles/default' + }; + + buildDocumentLoader(docLoaderOpts); + + expect(mocks.workspaceDocLoader).toHaveBeenCalledWith('/repo/.calm-workspace/bundles/default', false); + }); + + it('should create a CalmHubDocumentLoader when calmHubUrl is defined in loader options', () => { + + const docLoaderOpts: DocumentLoaderOptions = { + calmHubUrl: 'https://example.com' + }; + + buildDocumentLoader(docLoaderOpts); + + expect(mocks.calmHubDocLoader).toHaveBeenCalledWith('https://example.com', false, undefined); + }); + + it('should pass authplugin to CalmHubDocumentLoader', () => { + + const mockAuthPlugin: AuthPlugin = { + getAuthHeaders: vi.fn() + }; + + const docLoaderOpts: DocumentLoaderOptions = { + calmHubUrl: 'https://example.com', + authPlugin: mockAuthPlugin + }; + + buildDocumentLoader(docLoaderOpts); + + expect(mocks.calmHubDocLoader).toHaveBeenCalledWith('https://example.com', false, mockAuthPlugin); + }); + + it('should pass allowedRemoteHosts to DirectUrlDocumentLoader when provided', () => { + const docLoaderOpts: DocumentLoaderOptions = { + allowedRemoteHosts: ['schemas.example.com'] + }; + + buildDocumentLoader(docLoaderOpts); + + expect(mocks.directDocLoader).toHaveBeenCalledWith(false, undefined, ['schemas.example.com']); + }); + + it('should create a MappedDocumentLoader when urlToLocalMap is provided', () => { + const urlMap = new Map([ + ['https://example.com/schema.json', 'local/schema.json'] + ]); + + const docLoaderOpts: DocumentLoaderOptions = { + urlToLocalMap: urlMap + }; + + buildDocumentLoader(docLoaderOpts); + + expect(mocks.mappedDocLoader).toHaveBeenCalledWith(urlMap, process.cwd(), false); + }); + + it('should create a MappedDocumentLoader when basePath is provided', () => { + const docLoaderOpts: DocumentLoaderOptions = { + basePath: '/project/patterns' + }; + + buildDocumentLoader(docLoaderOpts); + + expect(mocks.mappedDocLoader).toHaveBeenCalledWith(new Map(), '/project/patterns', false); + }); + + it('should create a MappedDocumentLoader with both urlToLocalMap and basePath', () => { + const urlMap = new Map([ + ['https://example.com/schema.json', 'local/schema.json'] + ]); + + const docLoaderOpts: DocumentLoaderOptions = { + urlToLocalMap: urlMap, + basePath: '/custom/base' + }; + + buildDocumentLoader(docLoaderOpts); + + expect(mocks.mappedDocLoader).toHaveBeenCalledWith(urlMap, '/custom/base', false); + }); + + it('should not create a MappedDocumentLoader when neither urlToLocalMap nor basePath provided', () => { + const docLoaderOpts: DocumentLoaderOptions = {}; + + buildDocumentLoader(docLoaderOpts); + + expect(mocks.mappedDocLoader).not.toHaveBeenCalled(); + }); + + it('should not create a MappedDocumentLoader when urlToLocalMap is empty and no basePath', () => { + const docLoaderOpts: DocumentLoaderOptions = { + urlToLocalMap: new Map() + }; + + buildDocumentLoader(docLoaderOpts); + + expect(mocks.mappedDocLoader).not.toHaveBeenCalled(); + }); +}); + +describe('isValidCalmDocumentType', () => { + it.each(CALM_DOCUMENT_TYPES_LIST)('returns true for the valid document type %s', (type) => { + expect(isValidCalmDocumentType(type)).toBe(true); + }); + + it.each(['unknown', 'architectures', 'Pattern', '', 'foo'])( + 'returns false for the invalid document type %s', + (type) => { + expect(isValidCalmDocumentType(type)).toBe(false); + } + ); +}); diff --git a/shared/src/document-loader/node-document-loader.ts b/shared/src/document-loader/node-document-loader.ts new file mode 100644 index 000000000..3d35e47ed --- /dev/null +++ b/shared/src/document-loader/node-document-loader.ts @@ -0,0 +1,49 @@ +import { CALM_META_SCHEMA_DIRECTORY } from '../consts.js'; +import { CalmHubDocumentLoader } from './calmhub-document-loader.js'; +import { FileSystemDocumentLoader } from './file-system-document-loader.js'; +import { DirectUrlDocumentLoader } from './direct-url-document-loader.js'; +import { MultiStrategyDocumentLoader } from './multi-strategy-document-loader.js'; +import { MappedDocumentLoader } from './mapped-document-loader.js'; +import { WorkspaceDocumentLoader } from './workspace-document-loader.js'; +import type { DocumentLoader, DocumentLoaderOptions } from './document-loader.js'; + +export function buildDocumentLoader(docLoaderOpts: DocumentLoaderOptions): DocumentLoader { + const loaders = []; + const debug = docLoaderOpts.debug ?? false; + + // Workspace bundle takes top priority: local working copies override CalmHub and every + // other source, for any reference form (bare id, $id, versioned path, or full URL). + if (docLoaderOpts.workspaceBundlePath) { + loaders.push(new WorkspaceDocumentLoader(docLoaderOpts.workspaceBundlePath, debug)); + } + + // Add MappedDocumentLoader FIRST if mapping or basePath provided + // This ensures URL mappings are resolved before other loaders. + // Note: Relative paths are handled by FileSystemDocumentLoader later in the chain. + if ((docLoaderOpts.urlToLocalMap && docLoaderOpts.urlToLocalMap.size > 0) || docLoaderOpts.basePath) { + loaders.push(new MappedDocumentLoader( + docLoaderOpts.urlToLocalMap ?? new Map(), + docLoaderOpts.basePath ?? process.cwd(), + debug + )); + } + + if (docLoaderOpts.calmHubUrl) { + loaders.push(new CalmHubDocumentLoader(docLoaderOpts.calmHubUrl, debug, docLoaderOpts.authPlugin)); + } + + // Always configure FileSystemDocumentLoader with CALM_META_SCHEMA_DIRECTORY + const directoryPaths = [CALM_META_SCHEMA_DIRECTORY]; + if (docLoaderOpts.schemaDirectoryPath) { + directoryPaths.push(docLoaderOpts.schemaDirectoryPath); + } + loaders.push(new FileSystemDocumentLoader( + directoryPaths, + debug, + docLoaderOpts.basePath ?? process.cwd() + )); + + loaders.push(new DirectUrlDocumentLoader(debug, undefined, docLoaderOpts.allowedRemoteHosts)); + + return new MultiStrategyDocumentLoader(loaders, debug); +} diff --git a/shared/src/document-loader/response-origin.spec.ts b/shared/src/document-loader/response-origin.spec.ts new file mode 100644 index 000000000..7730a5e42 --- /dev/null +++ b/shared/src/document-loader/response-origin.spec.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { assertResponseOrigin } from './response-origin.js'; +import { DocumentLoadError } from './document-loader.js'; + +const expectedOrigin = 'https://calm.finos.org'; + +describe('assertResponseOrigin', () => { + it('passes when the response carries no request info (Node http adapter)', () => { + expect(() => assertResponseOrigin({}, expectedOrigin, 'doc-1')).not.toThrow(); + }); + + it('passes when request.responseURL matches the expected origin (XHR adapter)', () => { + const response = { request: { responseURL: 'https://calm.finos.org/core.json' } }; + expect(() => assertResponseOrigin(response, expectedOrigin, 'doc-1')).not.toThrow(); + }); + + it('passes when request.responseURL is a relative path (mocked/http adapter artifact)', () => { + const response = { request: { responseURL: '/core.json' } }; + expect(() => assertResponseOrigin(response, expectedOrigin, 'doc-1')).not.toThrow(); + }); + + it('throws a non-recoverable DocumentLoadError when responseURL is a different origin', () => { + const response = { request: { responseURL: 'https://evil.example/x.json' } }; + expect(() => assertResponseOrigin(response, expectedOrigin, 'doc-1')).toThrow(DocumentLoadError); + try { + assertResponseOrigin(response, expectedOrigin, 'doc-1'); + throw new Error('expected assertResponseOrigin to throw'); + } catch (err) { + expect(err).toBeInstanceOf(DocumentLoadError); + expect((err as DocumentLoadError).recoverable).toBe(false); + expect((err as DocumentLoadError).message).toContain('redirected to a different origin'); + } + }); + + it('throws when request.url (fetch-style) is a different origin and responseURL is absent', () => { + const response = { request: { url: 'https://evil.example/x.json' } }; + expect(() => assertResponseOrigin(response, expectedOrigin, 'doc-1')).toThrow(DocumentLoadError); + }); + + it('passes when responseURL has a different host case than the expected origin', () => { + const response = { request: { responseURL: 'https://CALM.FINOS.ORG/core.json' } }; + expect(() => assertResponseOrigin(response, expectedOrigin, 'doc-1')).not.toThrow(); + }); + + it('prefers responseURL over url when both are present', () => { + const response = { request: { responseURL: 'https://calm.finos.org/core.json', url: 'https://evil.example/x.json' } }; + expect(() => assertResponseOrigin(response, expectedOrigin, 'doc-1')).not.toThrow(); + }); +}); diff --git a/shared/src/document-loader/response-origin.ts b/shared/src/document-loader/response-origin.ts new file mode 100644 index 000000000..89c4d28c4 --- /dev/null +++ b/shared/src/document-loader/response-origin.ts @@ -0,0 +1,37 @@ +import { DocumentLoadError } from './document-loader.js'; + +/** + * Browsers follow redirects transparently (axios's maxRedirects applies only in Node), so a request + * to an allowed origin can end up answered by another origin. When the adapter exposes the final URL + * (XHR `responseURL`, fetch `Response.url`), reject a response whose origin differs from the one requested. + */ +export function assertResponseOrigin(response: { request?: unknown }, expectedOrigin: string, documentId: string): void { + const requestInfo = response.request as { responseURL?: unknown; url?: unknown } | undefined; + const responseURL = requestInfo?.responseURL; + const urlField = requestInfo?.url; + + const finalUrl = typeof responseURL === 'string' && responseURL.length > 0 + ? responseURL + : typeof urlField === 'string' && urlField.length > 0 + ? urlField + : undefined; + + if (finalUrl === undefined) { + // Node's http adapter doesn't expose a final URL on the request — nothing to verify. + return; + } + + // Resolve against expectedOrigin rather than parsing finalUrl alone: a real XHR/fetch + // responseURL is always absolute, so the base is ignored and this is equivalent to comparing + // finalUrl's own origin; a relative value (as some test mocks/adapters produce) resolves onto + // the requested origin instead of throwing, which is the correct "no signal" outcome. + const finalOrigin = new URL(finalUrl, expectedOrigin).origin; + + if (finalOrigin.toLowerCase() !== expectedOrigin.toLowerCase()) { + throw new DocumentLoadError({ + name: 'UNKNOWN', + message: `Request for ${documentId} was redirected to a different origin (${finalOrigin}); refusing to use the response.`, + recoverable: false + }); + } +} diff --git a/shared/src/hub/calm-hub-client.ts b/shared/src/hub/calm-hub-client.ts index 56490eb3a..acb48aa34 100644 --- a/shared/src/hub/calm-hub-client.ts +++ b/shared/src/hub/calm-hub-client.ts @@ -2,6 +2,9 @@ import axios, { Axios } from 'axios'; import { AuthPlugin } from '../auth/auth-plugin'; import { initLogger, Logger } from '../logger'; import { DocumentMetadata, extractDocumentMetadata, validateDocumentId } from './document-id-utils'; +import { ResourceType } from './resource-types.js'; + +export { ResourceType, RESOURCE_TYPES, isValidResourceType } from './resource-types.js'; export interface CalmHubOptions { calmHubUrl?: string; @@ -39,13 +42,6 @@ export interface HubControlSummary { export type ResourceChangeType = 'MAJOR' | 'MINOR' | 'PATCH'; -export type ResourceType = 'patterns' | 'architectures' | 'standards' | 'interfaces'; -export const RESOURCE_TYPES = ['patterns', 'architectures', 'standards', 'interfaces']; - -export function isValidResourceType(input: string): input is ResourceType { - return RESOURCE_TYPES.includes(input); -} - export class HubClientError extends Error { /** * Creates a normalized Hub client error. diff --git a/shared/src/hub/curie.spec.ts b/shared/src/hub/curie.spec.ts new file mode 100644 index 000000000..b28095bbc --- /dev/null +++ b/shared/src/hub/curie.spec.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest'; +import { isCurie, parseCurie, expandCurie, CurieComponents } from './curie.js'; + +describe('isCurie', () => { + it('returns true for a valid CURIE with version', () => { + expect(isCurie('fae-calm:building-blocks:my-block@a1b2c3d')).toBe(true); + }); + + it('returns true for a valid CURIE without version', () => { + expect(isCurie('fae-calm:building-blocks:my-block')).toBe(true); + }); + + it('returns true for simple namespaces', () => { + expect(isCurie('ns:type:slug')).toBe(true); + }); + + it('returns false for URLs (contains slashes before first colon)', () => { + expect(isCurie('https://example.com/path')).toBe(false); + }); + + it('returns false for http URLs', () => { + expect(isCurie('http://hub.example.com/calm/namespaces/foo')).toBe(false); + }); + + it('returns false for file paths', () => { + expect(isCurie('/some/file/path.json')).toBe(false); + }); + + it('returns false for plain strings with no colons', () => { + expect(isCurie('just-a-string')).toBe(false); + }); + + it('returns false for strings with only one colon', () => { + expect(isCurie('foo:bar')).toBe(false); + }); + + it('returns false for strings with more than two colons', () => { + expect(isCurie('foo:bar:baz:qux')).toBe(false); + }); + + it('returns false when namespace contains a dot (e.g. domain-like)', () => { + expect(isCurie('example.com:type:slug')).toBe(false); + }); +}); + +describe('parseCurie', () => { + it('extracts components correctly with version', () => { + const result = parseCurie('fae-calm:building-blocks:my-block@a1b2c3d'); + expect(result).toEqual({ + namespace: 'fae-calm', + type: 'building-blocks', + slug: 'my-block', + version: 'a1b2c3d', + } satisfies CurieComponents); + }); + + it('extracts components correctly without version', () => { + const result = parseCurie('fae-calm:building-blocks:my-block'); + expect(result).toEqual({ + namespace: 'fae-calm', + type: 'building-blocks', + slug: 'my-block', + } satisfies CurieComponents); + }); + + it('handles version with multiple characters after @', () => { + const result = parseCurie('ns:patterns:api-gateway@v1.2.3'); + expect(result).toEqual({ + namespace: 'ns', + type: 'patterns', + slug: 'api-gateway', + version: 'v1.2.3', + }); + }); + + it('returns null for invalid input (URL)', () => { + expect(parseCurie('https://example.com/path')).toBeNull(); + }); + + it('returns null for plain string', () => { + expect(parseCurie('not-a-curie')).toBeNull(); + }); + + it('returns null for string with wrong number of segments', () => { + expect(parseCurie('only:two')).toBeNull(); + }); +}); + +describe('expandCurie', () => { + const hubBaseUrl = 'http://hub.example.com'; + + it('produces correct Hub URL with version', () => { + const result = expandCurie('fae-calm:building-blocks:my-block@a1b2c3d', hubBaseUrl); + expect(result).toBe('http://hub.example.com/calm/namespaces/fae-calm/building-blocks/my-block/versions/a1b2c3d'); + }); + + it('produces correct Hub URL without version (no /versions/ suffix)', () => { + const result = expandCurie('fae-calm:building-blocks:my-block', hubBaseUrl); + expect(result).toBe('http://hub.example.com/calm/namespaces/fae-calm/building-blocks/my-block'); + }); + + it('returns input unchanged if not a CURIE', () => { + const url = 'https://other-service.com/resource'; + expect(expandCurie(url, hubBaseUrl)).toBe(url); + }); + + it('returns file path unchanged if not a CURIE', () => { + const path = '/some/local/file.json'; + expect(expandCurie(path, hubBaseUrl)).toBe(path); + }); + + it('handles trailing slash in hubBaseUrl gracefully', () => { + const result = expandCurie('ns:type:slug@v1', 'http://hub.example.com/'); + expect(result).toBe('http://hub.example.com//calm/namespaces/ns/type/slug/versions/v1'); + }); +}); diff --git a/shared/src/hub/curie.ts b/shared/src/hub/curie.ts new file mode 100644 index 000000000..632e95c86 --- /dev/null +++ b/shared/src/hub/curie.ts @@ -0,0 +1,40 @@ +export interface CurieComponents { + namespace: string; + type: string; + slug: string; + version?: string; +} + +export function isCurie(ref: string): boolean { + // A CURIE has exactly 2 colons separating 3 segments, no slashes before the first colon + // Pattern: word:word:word (optionally word:word:word@sha) + const parts = ref.split(':'); + if (parts.length !== 3) return false; + // First segment (namespace) must not contain slashes or dots (distinguishes from URLs) + return !parts[0].includes('/') && !parts[0].includes('.'); +} + +export function parseCurie(curie: string): CurieComponents | null { + if (!isCurie(curie)) return null; + const [namespace, type, slugAndVersion] = curie.split(':'); + const atIndex = slugAndVersion.indexOf('@'); + if (atIndex === -1) { + return { namespace, type, slug: slugAndVersion }; + } + return { + namespace, + type, + slug: slugAndVersion.substring(0, atIndex), + version: slugAndVersion.substring(atIndex + 1), + }; +} + +export function expandCurie(curie: string, hubBaseUrl: string): string { + const components = parseCurie(curie); + if (!components) return curie; // Not a CURIE, return as-is + const base = `${hubBaseUrl}/calm/namespaces/${components.namespace}/${components.type}/${components.slug}`; + if (components.version) { + return `${base}/versions/${components.version}`; + } + return base; +} diff --git a/shared/src/hub/document-id-utils.ts b/shared/src/hub/document-id-utils.ts index 9db1503b3..66fa54ed5 100644 --- a/shared/src/hub/document-id-utils.ts +++ b/shared/src/hub/document-id-utils.ts @@ -1,4 +1,4 @@ -import { isValidResourceType, ResourceType } from './calm-hub-client'; +import { isValidResourceType, ResourceType } from './resource-types.js'; // Namespace documents: namespace-scoped // diff --git a/shared/src/hub/resource-types.ts b/shared/src/hub/resource-types.ts new file mode 100644 index 000000000..a63f6c04a --- /dev/null +++ b/shared/src/hub/resource-types.ts @@ -0,0 +1,6 @@ +export type ResourceType = 'patterns' | 'architectures' | 'standards' | 'interfaces'; +export const RESOURCE_TYPES = ['patterns', 'architectures', 'standards', 'interfaces']; + +export function isValidResourceType(input: string): input is ResourceType { + return RESOURCE_TYPES.includes(input); +} diff --git a/shared/src/index.ts b/shared/src/index.ts index 8cb74ca1c..76c932fb2 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -1,3 +1,14 @@ +import { registerNodeLoggerFactory } from './logger.js'; +import { createWinstonLogger } from './logger.node.js'; +import { registerOutputFormatter } from './commands/validate/format-output.js'; +import { junitFormatter } from './commands/validate/output-formats/junit-output.js'; + +// These top-level calls are load-bearing side effects (they register the Node logger factory and +// the junit output formatter for consumers of this entry point) — never add "sideEffects": false +// to shared/package.json, or bundlers will tree-shake them away. +registerNodeLoggerFactory(createWinstonLogger); +registerOutputFormatter('junit', junitFormatter); + export { validate, formatOutput as getFormattedOutput, @@ -31,8 +42,9 @@ export { export { ValidationOutput } from './commands/validate/validation.output.js'; export { CALM_META_SCHEMA_DIRECTORY } from './consts.js'; export { SchemaDirectory } from './schema-directory.js'; -export { initLogger } from './logger.js'; -export type { Logger } from './logger.js'; +export { initLogger, registerNodeLoggerFactory } from './logger.js'; +export type { Logger, LogLevel, NodeLoggerFactory } from './logger.js'; +export { createWinstonLogger } from './logger.node.js'; export { AuthPlugin } from './auth/auth-plugin.js'; export { NoAuthPlugin } from './auth/no-auth-plugin.js'; export { TemplateProcessor, TemplateProcessingMode } from './template/template-processor.js'; @@ -50,12 +62,12 @@ export { export { Docifier, DocifyMode, DiagramExportFormat } from './docify/docifier.js'; export { C4Model } from './docify/graphing/c4.js'; export { CalmRelationshipGraph } from './docify/graphing/relationship-graph.js'; -export { ValidationOutcome } from './commands/validate/validation.output'; -export * from './test/file-comparison.js'; +export { ValidationOutcome } from './commands/validate/validation.output.js'; export { setWidgetLogger, type WidgetLogger } from '@finos/calm-widgets'; -export { buildDocumentLoader, DocumentLoader, DocumentLoaderOptions } from './document-loader/document-loader'; -export { FileSystemDocumentLoader } from './document-loader/file-system-document-loader'; -export { WorkspaceDocumentLoader } from './document-loader/workspace-document-loader'; +export { DocumentLoader, DocumentLoaderOptions, DocumentLoadError, assertJsonObject, CALM_HUB_PROTOS } from './document-loader/document-loader.js'; +export { buildDocumentLoader } from './document-loader/node-document-loader.js'; +export { FileSystemDocumentLoader } from './document-loader/file-system-document-loader.js'; +export { WorkspaceDocumentLoader } from './document-loader/workspace-document-loader.js'; export * from './document-loader/loading-helpers.js'; export { hasArchitectureExtension, @@ -65,6 +77,7 @@ export { export { CalmHubClient, HubClientError, + RESOURCE_TYPES, type HubNamespaceSummary, type HubCreateResult, type HubNamespaceCreateResult, @@ -91,9 +104,26 @@ export { } from './hub/document-id-utils.js'; export { computeSemVerBump, compareSemVer, sortSemVer } from './hub/semver.js'; export { canonicalEqual, canonicalize } from './hub/canonical.js'; +export { + isCurie, + parseCurie, + expandCurie, + type CurieComponents, +} from './hub/curie.js'; +export { CurieReferenceResolver } from './resolver/curie-resolver.js'; +export { LocalCurieReferenceResolver } from './resolver/local-curie-resolver.js'; +export { ShaCacheReferenceResolver } from './resolver/sha-cache-resolver.js'; +export { ChainReferenceResolver } from './resolver/chain-reference-resolver.js'; +export { type CalmReferenceResolver, HttpReferenceResolver } from './resolver/calm-reference-resolver.js'; +export { mergeControls } from './controls/merge-controls.js'; export { enrichWithDocumentPositions, parseDocumentWithPositions, type ParsedDocumentContext, __test__ as validationEnrichmentTest } from './commands/validate/validation-enrichment.js'; +export { InMemoryDocumentLoader } from './document-loader/in-memory-document-loader.js'; +export { buildBrowserDocumentLoader, type BrowserDocumentLoaderOptions } from './document-loader/browser-document-loader.js'; +export { generate, type GenerateOptions } from './commands/generate/generate-core.js'; +export { diffDocuments, diffTimeline, tryDetectDocumentType, type DiffDocumentsOptions } from './commands/diff/diff-core.js'; +export { BROWSER_COMMAND_SUPPORT, browserSupportFor, type BrowserCommandSupport } from './browser-capabilities.js'; diff --git a/shared/src/logger.node.ts b/shared/src/logger.node.ts new file mode 100644 index 000000000..43468e3c9 --- /dev/null +++ b/shared/src/logger.node.ts @@ -0,0 +1,35 @@ +import winston from 'winston'; +import { Logger } from './logger.js'; + +/** + * Winston-backed logger for Node.js. Lives in its own module so the browser entry point never + * imports winston (and its fs/os/tty transport chain). The root entry registers this factory + * with {@link registerNodeLoggerFactory} at module load. + */ +export function createWinstonLogger(debug: boolean, label?: string): Logger { + const level = debug ? 'debug' : 'info'; + const winstonLogger = winston.createLogger({ + level, + transports: [ + new winston.transports.Console({ stderrLevels: ['error', 'warn', 'info'] }), + ], + format: winston.format.combine( + winston.format.label({ label }), + winston.format.cli(), + winston.format.errors({ stack: true }), + winston.format.printf(({ level, message, stack, label }) => + stack + ? `${level} [${label}]: ${message} - ${stack}` + : `${level} [${label}]: ${message}` + ) + ), + }); + + return { + log: (lvl, msg) => winstonLogger.log({ level: lvl, message: msg }), + debug: (msg) => winstonLogger.debug(msg), + info: (msg) => winstonLogger.info(msg), + warn: (msg) => winstonLogger.warn(msg), + error: (msg) => winstonLogger.error(msg), + }; +} diff --git a/shared/src/logger.spec.ts b/shared/src/logger.spec.ts index f1aece575..8c6ef17f8 100644 --- a/shared/src/logger.spec.ts +++ b/shared/src/logger.spec.ts @@ -21,17 +21,40 @@ describe('initLogger', () => { delete (globalThis as { window?: typeof globalThis.window }).window; }); - it('returns a logger that exposes debug/info/warn/error', async () => { + it('falls back to loglevel when no node logger factory is registered', async () => { + vi.resetModules(); + const log = (await import('loglevel')).default; + vi.spyOn(log, 'setLevel').mockImplementation(() => {}); + const infoSpy = vi.spyOn(log, 'info').mockImplementation(() => {}); const { initLogger } = await import('./logger'); - const logger = initLogger(false); - expect(typeof logger.debug).toBe('function'); - expect(typeof logger.info).toBe('function'); - expect(typeof logger.warn).toBe('function'); - expect(typeof logger.error).toBe('function'); - expect(typeof logger.log).toBe('function'); + initLogger(false).info('hello'); + expect(infoSpy).toHaveBeenCalledWith('hello'); + }); + + it('uses the registered node logger factory', async () => { + vi.resetModules(); + const { initLogger, registerNodeLoggerFactory } = await import('./logger'); + const fake = { log: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const factory = vi.fn(() => fake); + registerNodeLoggerFactory(factory); + const logger = initLogger(true, 'my-label'); + logger.info('b'); + expect(factory).toHaveBeenCalledWith(true, 'my-label'); + expect(fake.info).toHaveBeenCalledWith('b'); }); - it('forwards each level method to winston with the message intact', async () => { + it('ignores the registered factory when quiet=true', async () => { + vi.resetModules(); + const { initLogger, registerNodeLoggerFactory } = await import('./logger'); + const factory = vi.fn(); + registerNodeLoggerFactory(factory); + const logger = initLogger(true, 'x', true); + logger.info('silent'); + expect(factory).not.toHaveBeenCalled(); + }); + + it('createWinstonLogger forwards each level method to winston with the message intact', async () => { + vi.resetModules(); const winston = (await import('winston')).default; const winstonSpy = { log: vi.fn(), @@ -44,8 +67,8 @@ describe('initLogger', () => { winstonSpy as unknown as ReturnType ); - const { initLogger } = await import('./logger'); - const logger = initLogger(true, 'my-label'); + const { createWinstonLogger } = await import('./logger.node'); + const logger = createWinstonLogger(true, 'my-label'); logger.debug('a'); logger.info('b'); @@ -59,6 +82,15 @@ describe('initLogger', () => { expect(winstonSpy.error).toHaveBeenCalledWith('d'); expect(winstonSpy.log).toHaveBeenCalledWith({ level: 'warn', message: 'e' }); }); + + it('the root barrel registers winston as the node logger', async () => { + vi.resetModules(); + const winston = (await import('winston')).default; + const createLogger = vi.spyOn(winston, 'createLogger'); + const { initLogger } = await import('./index'); + initLogger(false, 'via-barrel'); + expect(createLogger).toHaveBeenCalled(); + }); }); describe('browser environment', () => { @@ -67,6 +99,7 @@ describe('initLogger', () => { }); it('returns a browser logger that delegates to loglevel', async () => { + vi.resetModules(); const log = (await import('loglevel')).default; const setLevelSpy = vi.spyOn(log, 'setLevel').mockImplementation(() => {}); const debugSpy = vi.spyOn(log, 'debug').mockImplementation(() => {}); @@ -94,6 +127,7 @@ describe('initLogger', () => { }); it('sets debug log level when debug=true', async () => { + vi.resetModules(); const log = (await import('loglevel')).default; const setLevelSpy = vi.spyOn(log, 'setLevel').mockImplementation(() => {}); diff --git a/shared/src/logger.ts b/shared/src/logger.ts index 340312d9e..8cc67e406 100644 --- a/shared/src/logger.ts +++ b/shared/src/logger.ts @@ -1,4 +1,3 @@ -import winston from 'winston'; import log from 'loglevel'; export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; @@ -11,6 +10,20 @@ export interface Logger { error(message: string): void; } +export type NodeLoggerFactory = (debug: boolean, label?: string) => Logger; + +let nodeLoggerFactory: NodeLoggerFactory | undefined; + +/** + * Registers the logger used in Node.js environments. The root entry point registers the + * winston implementation (see `logger.node.ts`); the browser entry registers nothing and + * therefore always uses loglevel. Calling this in a browser has no effect on behaviour because + * `initLogger` only consults the factory when `window` is undefined. + */ +export function registerNodeLoggerFactory(factory: NodeLoggerFactory): void { + nodeLoggerFactory = factory; +} + /** * Initializes a logger that works in both Node.js and browser environments. * @param debug - Enables debug logging if true. @@ -22,66 +35,17 @@ export function initLogger(debug: boolean, label?: string, quiet: boolean = fals if (quiet) { return createQuietLogger(); } - if (typeof window === 'undefined') { - return initNodeLogger(debug, label); - } else { - return initBrowserLogger(debug); + if (typeof window === 'undefined' && nodeLoggerFactory) { + return nodeLoggerFactory(debug, label); } + return initBrowserLogger(debug); } -/** - * Creates a no-op logger that suppresses all output. - */ function createQuietLogger(): Logger { const noop = () => { }; - return { - log: noop, - debug: noop, - info: noop, - warn: noop, - error: noop, - }; + return { log: noop, debug: noop, info: noop, warn: noop, error: noop }; } -/** - * Initializes a logger for Node.js environment using winston. - * @param debug - Whether to enable debug logging. - * @param label - Optional label to prefix Node.js logs. - * @returns Logger instance for Node.js. - */ -function initNodeLogger(debug: boolean, label?: string): Logger { - const level = debug ? 'debug' : 'info'; - const winstonLogger = winston.createLogger({ - level, - transports: [ - new winston.transports.Console({ stderrLevels: ['error', 'warn', 'info'] }), - ], - format: winston.format.combine( - winston.format.label({ label }), - winston.format.cli(), - winston.format.errors({ stack: true }), - winston.format.printf(({ level, message, stack, label }) => - stack - ? `${level} [${label}]: ${message} - ${stack}` - : `${level} [${label}]: ${message}` - ) - ), - }); - - return { - log: (lvl, msg) => winstonLogger.log({ level: lvl, message: msg }), - debug: (msg) => winstonLogger.debug(msg), - info: (msg) => winstonLogger.info(msg), - warn: (msg) => winstonLogger.warn(msg), - error: (msg) => winstonLogger.error(msg), - }; -} - -/** - * Initializes a logger for the browser environment using loglevel. - * @param debug - Whether to enable debug logging. - * @returns Logger instance for browser. - */ function initBrowserLogger(debug: boolean): Logger { const level = debug ? 'debug' : 'info'; log.setLevel(level); diff --git a/shared/src/resolver/caching-tracking-resolver.ts b/shared/src/resolver/caching-tracking-resolver.ts index d68c51a16..d18093cab 100644 --- a/shared/src/resolver/caching-tracking-resolver.ts +++ b/shared/src/resolver/caching-tracking-resolver.ts @@ -1,4 +1,4 @@ -import { CalmReferenceResolver } from './calm-reference-resolver.js'; +import type { CalmReferenceResolver } from './calm-reference-resolver.js'; /** * A caching, tracking {@link CalmReferenceResolver} decorator. diff --git a/shared/src/resolver/chain-reference-resolver.spec.ts b/shared/src/resolver/chain-reference-resolver.spec.ts new file mode 100644 index 000000000..d69a70fbd --- /dev/null +++ b/shared/src/resolver/chain-reference-resolver.spec.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi } from 'vitest'; +import { ChainReferenceResolver } from './chain-reference-resolver'; +import { CalmReferenceResolver } from './calm-reference-resolver'; + +function mockResolver(canResolve: boolean, result?: unknown, shouldThrow = false): CalmReferenceResolver { + return { + canResolve: vi.fn().mockReturnValue(canResolve), + resolve: vi.fn().mockImplementation(async () => { + if (shouldThrow) throw new Error('resolver failed'); + return result; + }), + }; +} + +describe('ChainReferenceResolver', () => { + it('returns the first successful resolution', async () => { + const r1 = mockResolver(true, { first: true }); + const r2 = mockResolver(true, { second: true }); + const chain = new ChainReferenceResolver([r1, r2]); + + const result = await chain.resolve('some:ref'); + expect(result).toEqual({ first: true }); + expect(r2.resolve).not.toHaveBeenCalled(); + }); + + it('skips resolvers that cannot resolve', async () => { + const r1 = mockResolver(false); + const r2 = mockResolver(true, { data: 42 }); + const chain = new ChainReferenceResolver([r1, r2]); + + const result = await chain.resolve('some:ref'); + expect(result).toEqual({ data: 42 }); + expect(r1.resolve).not.toHaveBeenCalled(); + }); + + it('falls through to next resolver on error', async () => { + const r1 = mockResolver(true, undefined, true); + const r2 = mockResolver(true, { fallback: true }); + const chain = new ChainReferenceResolver([r1, r2]); + + const result = await chain.resolve('some:ref'); + expect(result).toEqual({ fallback: true }); + }); + + it('throws when no resolver can handle the ref', async () => { + const r1 = mockResolver(false); + const chain = new ChainReferenceResolver([r1]); + + await expect(chain.resolve('unknown:ref')).rejects.toThrow('No resolver in chain could resolve'); + }); + + it('canResolve returns true if any resolver can handle it', () => { + const r1 = mockResolver(false); + const r2 = mockResolver(true); + const chain = new ChainReferenceResolver([r1, r2]); + + expect(chain.canResolve('some:ref')).toBe(true); + }); + + it('canResolve returns false if no resolver can handle it', () => { + const r1 = mockResolver(false); + const chain = new ChainReferenceResolver([r1]); + + expect(chain.canResolve('some:ref')).toBe(false); + }); +}); diff --git a/shared/src/resolver/chain-reference-resolver.ts b/shared/src/resolver/chain-reference-resolver.ts new file mode 100644 index 000000000..e2f71c578 --- /dev/null +++ b/shared/src/resolver/chain-reference-resolver.ts @@ -0,0 +1,32 @@ +import { CalmReferenceResolver } from './calm-reference-resolver.js'; + +/** + * Tries a list of resolvers in order, returning the first successful result. + * More flexible than CompositeReferenceResolver — accepts any resolver chain. + * + * Resolution chain for CURIEs (as designed): + * 1. SHA cache (~/.calm/cache) — permanent, offline-first + * 2. Local path (--assets-path) — if configured, resolve from filesystem + * 3. CalmHub (--hub-url) — if configured, expand CURIE to Hub URL + * 4. HTTP — absolute URLs resolve directly + */ +export class ChainReferenceResolver implements CalmReferenceResolver { + constructor(private resolvers: CalmReferenceResolver[]) {} + + canResolve(ref: string): boolean { + return this.resolvers.some((r) => r.canResolve(ref)); + } + + async resolve(ref: string): Promise { + for (const resolver of this.resolvers) { + if (resolver.canResolve(ref)) { + try { + return await resolver.resolve(ref); + } catch { + // Try next resolver in chain + } + } + } + throw new Error(`No resolver in chain could resolve: ${ref}`); + } +} diff --git a/shared/src/resolver/curie-resolver.spec.ts b/shared/src/resolver/curie-resolver.spec.ts new file mode 100644 index 000000000..524483bb9 --- /dev/null +++ b/shared/src/resolver/curie-resolver.spec.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi } from 'vitest'; +import { CurieReferenceResolver } from './curie-resolver.js'; +import { CalmReferenceResolver } from './calm-reference-resolver.js'; + +describe('CurieReferenceResolver', () => { + const hubBaseUrl = 'http://hub.example.com'; + + function createMockDelegate(canResolveResult: boolean, resolveResult?: unknown): CalmReferenceResolver { + return { + canResolve: vi.fn().mockReturnValue(canResolveResult), + resolve: vi.fn().mockResolvedValue(resolveResult), + }; + } + + describe('canResolve', () => { + it('returns true for a CURIE when delegate can resolve the expanded URL', () => { + const delegate = createMockDelegate(true); + const resolver = new CurieReferenceResolver(hubBaseUrl, delegate); + + expect(resolver.canResolve('fae-calm:building-blocks:my-block@a1b2c3d')).toBe(true); + expect(delegate.canResolve).toHaveBeenCalledWith( + 'http://hub.example.com/calm/namespaces/fae-calm/building-blocks/my-block/versions/a1b2c3d' + ); + }); + + it('returns false for non-CURIE strings', () => { + const delegate = createMockDelegate(true); + const resolver = new CurieReferenceResolver(hubBaseUrl, delegate); + + expect(resolver.canResolve('https://example.com/resource')).toBe(false); + expect(delegate.canResolve).not.toHaveBeenCalled(); + }); + + it('returns false when delegate rejects the expanded URL', () => { + const delegate = createMockDelegate(false); + const resolver = new CurieReferenceResolver(hubBaseUrl, delegate); + + expect(resolver.canResolve('fae-calm:building-blocks:my-block@a1b2c3d')).toBe(false); + expect(delegate.canResolve).toHaveBeenCalledWith( + 'http://hub.example.com/calm/namespaces/fae-calm/building-blocks/my-block/versions/a1b2c3d' + ); + }); + }); + + describe('resolve', () => { + it('expands the CURIE and delegates to the underlying resolver', async () => { + const expectedData = { nodes: [], relationships: [] }; + const delegate = createMockDelegate(true, expectedData); + const resolver = new CurieReferenceResolver(hubBaseUrl, delegate); + + const result = await resolver.resolve('fae-calm:building-blocks:my-block@a1b2c3d'); + + expect(result).toEqual(expectedData); + expect(delegate.resolve).toHaveBeenCalledWith( + 'http://hub.example.com/calm/namespaces/fae-calm/building-blocks/my-block/versions/a1b2c3d' + ); + }); + + it('expands CURIE without version correctly', async () => { + const expectedData = { name: 'latest' }; + const delegate = createMockDelegate(true, expectedData); + const resolver = new CurieReferenceResolver(hubBaseUrl, delegate); + + const result = await resolver.resolve('fae-calm:patterns:api-gateway'); + + expect(result).toEqual(expectedData); + expect(delegate.resolve).toHaveBeenCalledWith( + 'http://hub.example.com/calm/namespaces/fae-calm/patterns/api-gateway' + ); + }); + }); +}); diff --git a/shared/src/resolver/curie-resolver.ts b/shared/src/resolver/curie-resolver.ts new file mode 100644 index 000000000..2cfcaa6ba --- /dev/null +++ b/shared/src/resolver/curie-resolver.ts @@ -0,0 +1,20 @@ +import { CalmReferenceResolver } from './calm-reference-resolver.js'; +import { expandCurie, isCurie } from '../hub/curie.js'; + +export class CurieReferenceResolver implements CalmReferenceResolver { + constructor( + private hubBaseUrl: string, + private delegate: CalmReferenceResolver + ) {} + + canResolve(ref: string): boolean { + if (!isCurie(ref)) return false; + const expanded = expandCurie(ref, this.hubBaseUrl); + return this.delegate.canResolve(expanded); + } + + async resolve(ref: string): Promise { + const expanded = expandCurie(ref, this.hubBaseUrl); + return this.delegate.resolve(expanded); + } +} diff --git a/shared/src/resolver/local-curie-resolver.spec.ts b/shared/src/resolver/local-curie-resolver.spec.ts new file mode 100644 index 000000000..805129ee3 --- /dev/null +++ b/shared/src/resolver/local-curie-resolver.spec.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { LocalCurieReferenceResolver } from './local-curie-resolver'; + +describe('LocalCurieReferenceResolver', () => { + let tmpDir: string; + let resolver: LocalCurieReferenceResolver; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'local-curie-test-')); + resolver = new LocalCurieReferenceResolver(tmpDir); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('canResolve returns false for non-CURIE refs', () => { + expect(resolver.canResolve('https://example.com/doc.json')).toBe(false); + }); + + it('canResolve returns false when file does not exist', () => { + expect(resolver.canResolve('ns:controls:my-ctrl@1.0.0')).toBe(false); + }); + + it('canResolve returns true when direct path file exists', () => { + const controlsDir = path.join(tmpDir, 'controls'); + fs.mkdirSync(controlsDir, { recursive: true }); + fs.writeFileSync(path.join(controlsDir, 'my-ctrl.json'), '{"id":"ctrl-1"}'); + + expect(resolver.canResolve('ns:controls:my-ctrl@1.0.0')).toBe(true); + }); + + it('resolve returns parsed JSON from direct path', async () => { + const controlsDir = path.join(tmpDir, 'controls'); + fs.mkdirSync(controlsDir, { recursive: true }); + fs.writeFileSync(path.join(controlsDir, 'my-ctrl.json'), '{"id":"ctrl-1"}'); + + const result = await resolver.resolve('ns:controls:my-ctrl@1.0.0'); + expect(result).toEqual({ id: 'ctrl-1' }); + }); + + it('resolve finds file in subdirectory', () => { + const subDir = path.join(tmpDir, 'controls', 'sub'); + fs.mkdirSync(subDir, { recursive: true }); + fs.writeFileSync(path.join(subDir, 'nested-ctrl.json'), '{"id":"nested"}'); + + expect(resolver.canResolve('ns:controls:nested-ctrl@1.0.0')).toBe(true); + }); + + it('resolve throws when file not found', async () => { + await expect(resolver.resolve('ns:controls:missing@1.0.0')).rejects.toThrow('Local CURIE resolution failed'); + }); +}); diff --git a/shared/src/resolver/local-curie-resolver.ts b/shared/src/resolver/local-curie-resolver.ts new file mode 100644 index 000000000..f09bb9051 --- /dev/null +++ b/shared/src/resolver/local-curie-resolver.ts @@ -0,0 +1,46 @@ +import fs from 'fs'; +import path from 'path'; +import { CalmReferenceResolver } from './calm-reference-resolver.js'; +import { isCurie, parseCurie } from '../hub/curie.js'; + +/** + * Resolves CURIEs against a local filesystem path. + * Maps `namespace:type:slug@version` to `{basePath}/{type}/{slug}.json`. + * The version segment is ignored (local files represent the current state). + * Enables CLI usage without a CalmHub — point at a local clone of the assets repo. + */ +export class LocalCurieReferenceResolver implements CalmReferenceResolver { + constructor(private basePath: string) {} + + canResolve(ref: string): boolean { + if (!isCurie(ref)) return false; + const filePath = this.toFilePath(ref); + return filePath !== null && fs.existsSync(filePath); + } + + async resolve(ref: string): Promise { + const filePath = this.toFilePath(ref); + if (!filePath || !fs.existsSync(filePath)) { + throw new Error(`Local CURIE resolution failed: ${ref} → file not found`); + } + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } + + private toFilePath(ref: string): string | null { + const components = parseCurie(ref); + if (!components) return null; + const { type, slug } = components; + // Try direct path: {basePath}/{type}/{slug}.json + const direct = path.join(this.basePath, type, `${slug}.json`); + if (fs.existsSync(direct)) return direct; + // Try with subdirectories (scan one level): {basePath}/{type}/{subdir}/{slug}.json + const typeDir = path.join(this.basePath, type); + if (fs.existsSync(typeDir) && fs.statSync(typeDir).isDirectory()) { + for (const entry of fs.readdirSync(typeDir)) { + const subPath = path.join(typeDir, entry, `${slug}.json`); + if (fs.existsSync(subPath)) return subPath; + } + } + return null; + } +} diff --git a/shared/src/resolver/schema-directory-reference-resolver.ts b/shared/src/resolver/schema-directory-reference-resolver.ts index 673bec699..52a41cb52 100644 --- a/shared/src/resolver/schema-directory-reference-resolver.ts +++ b/shared/src/resolver/schema-directory-reference-resolver.ts @@ -1,5 +1,5 @@ import { SchemaDirectory } from '../schema-directory.js'; -import { CalmReferenceResolver } from './calm-reference-resolver.js'; +import type { CalmReferenceResolver } from './calm-reference-resolver.js'; import type { CalmDocumentType } from '@finos/calm-models/types'; /** diff --git a/shared/src/resolver/sha-cache-resolver.spec.ts b/shared/src/resolver/sha-cache-resolver.spec.ts new file mode 100644 index 000000000..31dc8426d --- /dev/null +++ b/shared/src/resolver/sha-cache-resolver.spec.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { ShaCacheReferenceResolver } from './sha-cache-resolver'; + +describe('ShaCacheReferenceResolver', () => { + let tmpDir: string; + let resolver: ShaCacheReferenceResolver; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sha-cache-test-')); + resolver = new ShaCacheReferenceResolver(tmpDir); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('canResolve returns false for non-CURIE refs', () => { + expect(resolver.canResolve('https://example.com/doc.json')).toBe(false); + }); + + it('canResolve returns false when file does not exist', () => { + expect(resolver.canResolve('ns:controls:my-ctrl@1.0.0')).toBe(false); + }); + + it('canResolve returns true when cached file exists', async () => { + await resolver.put('ns:controls:my-ctrl@1.0.0', { id: 'ctrl-1' }); + expect(resolver.canResolve('ns:controls:my-ctrl@1.0.0')).toBe(true); + }); + + it('resolve returns cached content', async () => { + const content = { id: 'ctrl-1', name: 'Test Control' }; + await resolver.put('ns:controls:my-ctrl@1.0.0', content); + const result = await resolver.resolve('ns:controls:my-ctrl@1.0.0'); + expect(result).toEqual(content); + }); + + it('resolve throws on cache miss', async () => { + await expect(resolver.resolve('ns:controls:missing@1.0.0')).rejects.toThrow('SHA cache miss'); + }); + + it('put is a no-op for invalid CURIEs', async () => { + await resolver.put('not-a-curie', { data: true }); + expect(fs.readdirSync(tmpDir)).toHaveLength(0); + }); + + it('canResolve returns false for CURIEs without version', () => { + expect(resolver.canResolve('ns:controls:my-ctrl')).toBe(false); + }); +}); diff --git a/shared/src/resolver/sha-cache-resolver.ts b/shared/src/resolver/sha-cache-resolver.ts new file mode 100644 index 000000000..b33c5b9cb --- /dev/null +++ b/shared/src/resolver/sha-cache-resolver.ts @@ -0,0 +1,52 @@ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { CalmReferenceResolver } from './calm-reference-resolver.js'; +import { isCurie, parseCurie } from '../hub/curie.js'; + +/** + * Resolves CURIEs from the permanent SHA cache at ~/.calm/cache. + * Layout: ~/.calm/cache/{namespace}/{type}/{slug}/{version}.json + * Content at a SHA is immutable — once cached, valid forever. + */ +export class ShaCacheReferenceResolver implements CalmReferenceResolver { + private cacheDir: string; + + constructor(cacheDir?: string) { + this.cacheDir = cacheDir ?? path.join(os.homedir(), '.calm', 'cache'); + } + + canResolve(ref: string): boolean { + if (!isCurie(ref)) return false; + const filePath = this.toFilePath(ref); + return filePath !== null && fs.existsSync(filePath); + } + + async resolve(ref: string): Promise { + const filePath = this.toFilePath(ref); + if (!filePath || !fs.existsSync(filePath)) { + throw new Error(`SHA cache miss: ${ref}`); + } + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } + + async put(ref: string, content: unknown): Promise { + const filePath = this.toFilePath(ref); + if (!filePath) return; + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(content, null, 2), 'utf-8'); + } + + private toFilePath(ref: string): string | null { + const components = parseCurie(ref); + if (!components || !components.version) return null; + return path.join( + this.cacheDir, + components.namespace, + components.type, + components.slug, + `${components.version}.json` + ); + } +} diff --git a/shared/src/schema-directory.spec.ts b/shared/src/schema-directory.spec.ts index 8418b7288..64f04a7cd 100644 --- a/shared/src/schema-directory.spec.ts +++ b/shared/src/schema-directory.spec.ts @@ -12,7 +12,8 @@ vi.mock('./logger', () => { warn: () => { }, error: () => { } }; - } + }, + registerNodeLoggerFactory: () => { } }; }); diff --git a/shared/src/util/ip-literal.spec.ts b/shared/src/util/ip-literal.spec.ts new file mode 100644 index 000000000..5b20915fc --- /dev/null +++ b/shared/src/util/ip-literal.spec.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { ipLiteralVersion } from './ip-literal'; + +describe('ipLiteralVersion', () => { + it.each([ + ['127.0.0.1', 4], ['10.0.0.1', 4], ['192.168.1.1', 4], ['255.255.255.255', 4], ['0.0.0.0', 4], + ['::1', 6], ['fe80::1', 6], ['fc00::', 6], ['2001:db8::ff00:42:8329', 6], ['::ffff:192.168.0.1', 6], + ['localhost', 0], ['calm.finos.org', 0], ['256.1.1.1', 0], ['1.2.3', 0], ['1.2.3.4.5', 0], + ['', 0], ['::g', 0], ['1234:5678', 0], ['1:2:3:4:5:6:7:8:9', 0], ['a::b::c', 0], ['::', 6], + ['::ffff:127.0.0.1', 6], ['1.2.3.4:8080', 0], ['fe80::1%eth0', 6], ['01.2.3.4', 0], + ['::1:', 0], ['1:2:3:4:5:6:1.2.3.4', 6], ['::ffff:1.2.3.4.5', 0], + ['fe80::1%', 0], ['1.2.3.4%eth0', 0], ['::1%25', 6], ['fe80::1%eth0%x', 0], + ['db8::a1.2.3.4', 0], ['::abcd:256.1.1.1', 0], ['::a1.2.3.4', 0], + ])('classifies %s as %s', (host, expected) => { + expect(ipLiteralVersion(host)).toBe(expected); + }); +}); diff --git a/shared/src/util/ip-literal.ts b/shared/src/util/ip-literal.ts new file mode 100644 index 000000000..74d7d3b40 --- /dev/null +++ b/shared/src/util/ip-literal.ts @@ -0,0 +1,61 @@ +const IPV4 = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/; +const IPV4_TAIL = /(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/; +const HEXTET = /^[0-9a-f]{1,4}$/i; +// Node's net.isIP accepts a broad zone-id charset (interface names, numeric zone ids, etc.); +// confirmed against net.isIP('fe80::1%eth0'), ('::1%25'), ('fe80::1%'), ('fe80::1%eth0%x'). +const ZONE_ID = /^[0-9a-zA-Z.:_-]+$/; + +/** + * Is `candidate` (with any `%zone` suffix already stripped) an IPv6 literal? Never true for an + * IPv4-only string on its own — the IPv4-mapped-tail branch below only recognises the tail as + * part of a `::`-style IPv6 address, not a bare dotted-quad. + */ +function isIPv6Literal(host: string): boolean { + let candidate = host; + const mapped = candidate.match(IPV4_TAIL); + if (mapped && (mapped.index === 0 || candidate[(mapped.index ?? 0) - 1] === ':')) { + // IPv4-mapped tail counts as two hextets. Must be immediately preceded by ':' (or start + // the string) — otherwise the dotted-quad regex may have partial-matched into the middle + // of a hextet (e.g. the "1.2.3.4" inside "a1.2.3.4"), which is not a real mapped tail. + candidate = candidate.slice(0, mapped.index) + '0:0'; + } + const parts = candidate.split('::'); + if (parts.length > 2) { + return false; + } + const groups = (segment: string) => (segment === '' ? [] : segment.split(':')); + const head = groups(parts[0]); + const tail = parts.length === 2 ? groups(parts[1]) : []; + if (![...head, ...tail].every((g) => HEXTET.test(g))) { + return false; + } + const count = head.length + tail.length; + if (parts.length === 2) { + return count < 8; + } + return count === 8; +} + +/** + * Browser-safe replacement for Node's `net.isIP`: returns 4 for an IPv4 literal, 6 for an IPv6 + * literal, otherwise 0. Handles `::` compression, IPv4-mapped tails (`::ffff:1.2.3.4`), and IPv6 + * zone identifiers (`fe80::1%eth0`) — a zone id is only accepted when the part before `%` + * classifies as IPv6 (an IPv4 address with a zone id, e.g. `1.2.3.4%eth0`, is not an IP literal). + */ +export function ipLiteralVersion(host: string): 0 | 4 | 6 { + const zoneIndex = host.indexOf('%'); + if (zoneIndex !== -1) { + const zone = host.slice(zoneIndex + 1); + if (!ZONE_ID.test(zone)) { + return 0; + } + return isIPv6Literal(host.slice(0, zoneIndex)) ? 6 : 0; + } + if (IPV4.test(host)) { + return 4; + } + if (!host.includes(':')) { + return 0; + } + return isIPv6Literal(host) ? 6 : 0; +} diff --git a/shared/vitest.config.ts b/shared/vitest.config.ts index ba641fd37..60b7e01bc 100644 --- a/shared/vitest.config.ts +++ b/shared/vitest.config.ts @@ -11,7 +11,7 @@ const v8CoverageSettings: CoverageV8Options = { lines: 75, statements: 75 }, - exclude: ['test_fixtures/**', '*.config.ts'], + exclude: ['test_fixtures/**', '*.config.ts', 'scripts/**'], include: ['**/*.ts'] }; From 21ee9750b15c9b371ea9dff8df7f2ed55cf5e309 Mon Sep 17 00:00:00 2001 From: Shivaji Byrapaneni Date: Thu, 10 Sep 2026 14:58:34 -0400 Subject: [PATCH 02/13] feat(vscode plugin): Fix race conditions --- calm-plugins/vscode/src/webview/App.tsx | 74 +++--- .../src/webview/panels/ControlsList.tsx | 24 +- .../panels/controls-list-logic.test.ts | 242 ++++++++++++++++++ .../webview/utils/live-selected-node.test.ts | 105 ++++++++ 4 files changed, 403 insertions(+), 42 deletions(-) create mode 100644 calm-plugins/vscode/src/webview/panels/controls-list-logic.test.ts create mode 100644 calm-plugins/vscode/src/webview/utils/live-selected-node.test.ts diff --git a/calm-plugins/vscode/src/webview/App.tsx b/calm-plugins/vscode/src/webview/App.tsx index bdcc7ce28..d113fee0e 100644 --- a/calm-plugins/vscode/src/webview/App.tsx +++ b/calm-plugins/vscode/src/webview/App.tsx @@ -102,6 +102,7 @@ function CanvasApp() { const syncingRef = useRef(false); const undoingOrRedoing = useRef(false); const loadGeneration = useRef(0); + const pendingNodesRef = useRef(null); // --- Core: emit change --- @@ -120,7 +121,8 @@ function CanvasApp() { if (undoStack.current.length > 50) undoStack.current.shift(); redoStack.current = []; } - const currentNodes = reactFlowInstance.getNodes(); + const currentNodes = pendingNodesRef.current ?? reactFlowInstance.getNodes(); + pendingNodesRef.current = null; const currentEdges = reactFlowInstance.getEdges(); const arch = flowToCalm(currentNodes, currentEdges, currentState.documentControls); const json = JSON.stringify(arch, null, 2); @@ -140,6 +142,7 @@ function CanvasApp() { // Cancel any pending emit timers to prevent stale data from overwriting the file if (debounceTimer.current) { clearTimeout(debounceTimer.current); debounceTimer.current = null; } if (positionDebounceTimer.current) { clearTimeout(positionDebounceTimer.current); positionDebounceTimer.current = null; } + pendingNodesRef.current = null; // A blank/whitespace file means "no architecture" — clear the canvas instead of // keeping the previously loaded diagram (JSON.parse('') would otherwise throw and the @@ -412,23 +415,27 @@ function CanvasApp() { // --- Node update (from properties panel) --- const onNodeUpdate = useCallback((nodeId: string, field: string, value: unknown) => { - setNodes((nds) => nds.map((n) => { - if (n.id !== nodeId) return n; - const data = { ...(n.data as Record) }; - switch (field) { - case 'name': data.label = value; break; - case 'description': data.description = value; break; - case 'node-type': data.calmType = value; break; - case 'interfaces': data.interfaces = value; break; - case 'controls': data.controls = value; break; - case 'metadata': data.metadata = { ...((data.metadata as Record) ?? {}), ...(value as Record) }; break; - case 'containerRole': data.containerRole = value; break; - } - const updated = { ...n, data }; - if (field === 'node-type') updated.type = resolveFlowNodeType(value as string); - setSelectedNode(updated); - return updated; - })); + setNodes((nds) => { + const result = nds.map((n) => { + if (n.id !== nodeId) return n; + const data = { ...(n.data as Record) }; + switch (field) { + case 'name': data.label = value; break; + case 'description': data.description = value; break; + case 'node-type': data.calmType = value; break; + case 'interfaces': data.interfaces = value; break; + case 'controls': data.controls = value; break; + case 'metadata': data.metadata = { ...((data.metadata as Record) ?? {}), ...(value as Record) }; break; + case 'containerRole': data.containerRole = value; break; + } + const updated = { ...n, data }; + if (field === 'node-type') updated.type = resolveFlowNodeType(value as string); + setSelectedNode(updated); + return updated; + }); + pendingNodesRef.current = result; + return result; + }); setTimeout(() => emitChange(field !== 'name' && field !== 'description'), 0); }, [setNodes, emitChange]); @@ -863,7 +870,8 @@ function CanvasApp() { return
Loading CALM architecture...
; } - const data = selectedNode ? (selectedNode.data as Record) : null; + const liveSelectedNode = selectedNode ? nodes.find((n) => n.id === selectedNode.id) ?? selectedNode : null; + const data = liveSelectedNode ? (liveSelectedNode.data as Record) : null; return (
@@ -967,40 +975,40 @@ function CanvasApp() { PROPERTIES
- {selectedEdge && !selectedNode ? ( + {selectedEdge && !liveSelectedNode ? ( - ) : selectedNode && data ? ( + ) : liveSelectedNode && data ? ( <>
- {data.calmId as string ?? selectedNode.id} + {data.calmId as string ?? liveSelectedNode.id} - onNodeUpdate(selectedNode.id, 'name', e.target.value)} style={inputStyle} readOnly={store.readonlyMode} /> + onNodeUpdate(liveSelectedNode.id, 'name', e.target.value)} style={inputStyle} readOnly={store.readonlyMode} /> {data.calmType as string ?? 'system'} -