From 565b1d130b30d6f4d0be45b9090781e8958de804 Mon Sep 17 00:00:00 2001 From: Pavel Shukhman Date: Fri, 2 Oct 2026 12:57:31 -0400 Subject: [PATCH 01/12] feat(perspective): pre-defined perspectives, scopes and traversal steps Adds pre-defined perspectives in the reserved cdx:perspectives: namespace, applied by identity at a pinned version, with a standalone registry (perspectives-defs) and the model-card catalog entry. Perspectives can be scoped to parts of the document, and mappings can follow references (refs, referrers, dependencies) to related data. Signed-off-by: Pavel Shukhman --- .../generate_perspectives_registry.yml | 77 ++++++ perspectives/model-card-perspective.json | 248 ++++++++++++++++++ .../cyclonedx-perspective-2.0.schema.json | 244 ++++++++++++++++- schema/perspectives-defs.json | 20 ++ schema/perspectives-defs.schema.json | 125 +++++++++ .../generate-perspectives-registry.js | 127 +++++++++ .../js/perspectives-registry/package.json | 15 ++ .../perspectives-registry.js | 233 ++++++++++++++++ .../AbstractJsonSchemaVerificationTest.java | 3 + .../schema-v2/json-schema-functional-tests.js | 4 +- .../schema-v2/json-schema-validate-tests.js | 4 +- tools/src/test/js/schema-v2/package.json | 3 +- .../schema-v2/perspectives-registry-tests.js | 178 +++++++++++++ .../invalid-perspective-predefined-2.0.json | 16 ++ ...-perspective-predefined-annotated-2.0.json | 20 ++ ...alid-perspective-predefined-mixed-2.0.json | 24 ++ ...-perspective-predefined-noversion-2.0.json | 15 ++ .../invalid-perspective-scope-empty-2.0.json | 21 ++ ...invalid-perspective-via-direction-2.0.json | 25 ++ .../invalid-perspective-via-mixed-2.0.json | 26 ++ ...d-perspective-via-referrers-mixed-2.0.json | 26 ++ .../2.0/valid-perspective-predefined-2.0.json | 33 +++ .../2.0/valid-perspective-referrers-2.0.json | 109 ++++++++ .../2.0/valid-perspective-scopes-2.0.json | 170 ++++++++++++ 24 files changed, 1751 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/generate_perspectives_registry.yml create mode 100644 perspectives/model-card-perspective.json create mode 100644 schema/perspectives-defs.json create mode 100644 schema/perspectives-defs.schema.json create mode 100644 tools/src/main/js/perspectives-registry/generate-perspectives-registry.js create mode 100644 tools/src/main/js/perspectives-registry/package.json create mode 100644 tools/src/main/js/perspectives-registry/perspectives-registry.js create mode 100644 tools/src/test/js/schema-v2/perspectives-registry-tests.js create mode 100644 tools/src/test/resources/2.0/invalid-perspective-predefined-2.0.json create mode 100644 tools/src/test/resources/2.0/invalid-perspective-predefined-annotated-2.0.json create mode 100644 tools/src/test/resources/2.0/invalid-perspective-predefined-mixed-2.0.json create mode 100644 tools/src/test/resources/2.0/invalid-perspective-predefined-noversion-2.0.json create mode 100644 tools/src/test/resources/2.0/invalid-perspective-scope-empty-2.0.json create mode 100644 tools/src/test/resources/2.0/invalid-perspective-via-direction-2.0.json create mode 100644 tools/src/test/resources/2.0/invalid-perspective-via-mixed-2.0.json create mode 100644 tools/src/test/resources/2.0/invalid-perspective-via-referrers-mixed-2.0.json create mode 100644 tools/src/test/resources/2.0/valid-perspective-predefined-2.0.json create mode 100644 tools/src/test/resources/2.0/valid-perspective-referrers-2.0.json create mode 100644 tools/src/test/resources/2.0/valid-perspective-scopes-2.0.json diff --git a/.github/workflows/generate_perspectives_registry.yml b/.github/workflows/generate_perspectives_registry.yml new file mode 100644 index 000000000..2721a95d8 --- /dev/null +++ b/.github/workflows/generate_perspectives_registry.yml @@ -0,0 +1,77 @@ +name: Generate Perspectives Registry + +on: + push: + branches: + - 'master' + - 'main' + - '2.0-dev' + paths: + - '.github/workflows/generate_perspectives_registry.yml' # self + - 'tools/src/main/js/perspectives-registry/**' + - 'schema/perspectives-defs.schema.json' + - 'perspectives/**' + workflow_dispatch: # Allows manual trigger + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/controlling-permissions-for-github_token +permissions: {} + +jobs: + generate-registry: + name: Generate Registry + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write # Required to push the update branch + pull-requests: write # Required to open the pull request + env: + REGISTRY_FILE: schema/perspectives-defs.json + BASE_BRANCH: ${{ github.ref_name }} + UPDATE_BRANCH: update-perspectives-registry/${{ github.ref_name }} + steps: + - name: Checkout repository + # see https://github.com/actions/checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Setup Node.js + # see https://github.com/actions/setup-node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24.x' + - name: Generate registry + # registers catalog document versions that are not registered yet; + # fails if a catalog document changed without a version bump + run: | + set -eux + node tools/src/main/js/perspectives-registry/generate-perspectives-registry.js + - name: Create Pull Request + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eu + + if git diff --quiet -- "$REGISTRY_FILE" + then + echo "No changes to the perspectives registry" + exit 0 + fi + + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + + git checkout -b "$UPDATE_BRANCH" + git add "$REGISTRY_FILE" + git commit -m "chore: update perspectives registry" + + git push -u "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "$UPDATE_BRANCH" --force + + gh pr create \ + --title "chore: update perspectives registry" \ + --body "This PR updates \`${REGISTRY_FILE}\` with the catalog document versions registered by \`tools/src/main/js/perspectives-registry/generate-perspectives-registry.js\` for \`${BASE_BRANCH}\`." \ + --base "$BASE_BRANCH" \ + --head "$UPDATE_BRANCH" || echo "Pull request already exists" diff --git a/perspectives/model-card-perspective.json b/perspectives/model-card-perspective.json new file mode 100644 index 000000000..0b5626903 --- /dev/null +++ b/perspectives/model-card-perspective.json @@ -0,0 +1,248 @@ +{ + "$schema": "https://cyclonedx.org/schema/2.0/cyclonedx-2.0.schema.json", + "specFormat": "CycloneDX", + "specVersion": "2.0", + "version": 1, + "metadata": { + "timestamp": "2026-09-08T12:00:00Z" + }, + "perspectives": [ + { + "bom-ref": "perspective-model-card", + "name": "Model Card", + "description": "Defines the data elements of a machine learning model card, following the industry-standard model card structure: model details, intended use, training data and procedure, quantitative analysis, technical limitations, and ethical, fairness, and environmental considerations. The mappings project that structure onto the CycloneDX 2.0 decomposition of the former first-class modelCard entity: intrinsic technical characteristics live in modelProperties, which may only appear on components of type machine-learning-model; training datasets are components of type data; intended use cases are use case definitions referenced from the model; and ethical and fairness considerations are entries in the document's risk model. This perspective assumes the AI/ML model properties proposed in CycloneDX/specification#990. Model data is located by descendant search so the perspective applies whether models are declared at the document root, in inventories, or in definitions; related data such as training datasets and use cases is reached through traversal steps from the model's references.", + "domains": [ + "machine-learning", + "artificial-intelligence", + "transparency", + "ethics" + ], + "mappings": [ + { + "expression": "$..[?(@.type=='machine-learning-model')]['name','version','description']", + "nativeName": "Model Details", + "nativeDescription": "The identifying facts of the model: its name, version, and a description of what it is and does.", + "relevance": "required", + "weight": 1.0, + "rationale": "A model card is meaningless without stating which model, and which revision of it, the card describes." + }, + { + "expression": "$..[?(@.type=='machine-learning-model')].identifiers", + "nativeName": "Model Identifiers", + "nativeDescription": "Identifiers under which the model is published or catalogued, such as a Package-URL for a model hub entry, grouped by the party asserting them.", + "relevance": "recommended", + "weight": 0.7, + "rationale": "Name and version alone are ambiguous across hubs and forks; asserted identifiers let a card be matched to the published artefact." + }, + { + "expression": "$..[?(@.type=='machine-learning-model')].parties[?(@.roles[?(@.role=='supplier')])]", + "nativeName": "Developed By", + "nativeDescription": "The organization or individuals responsible for developing and supplying the model.", + "relevance": "required", + "weight": 0.9, + "rationale": "Accountability for a model's behaviour requires knowing who produced it. Expressed through the party model with the supplier role." + }, + { + "expression": "$..[?(@.type=='machine-learning-model')].licenses", + "nativeName": "License", + "nativeDescription": "The license under which the model, and by extension its weights, may be used.", + "relevance": "required", + "weight": 0.8, + "rationale": "Model cards conventionally state usage terms; license determines whether a given use is permitted at all." + }, + { + "expression": "$..[?(@.type=='machine-learning-model')].modelProperties.tasks", + "nativeName": "Supported Tasks", + "nativeDescription": "The machine learning tasks the model is designed to perform.", + "relevance": "required", + "weight": 0.9, + "rationale": "Tasks anchor the card: they determine the applicable inputs, outputs, and evaluation metrics." + }, + { + "expression": "$..[?(@.type=='machine-learning-model')].modelProperties.learningTypes", + "nativeName": "Learning Paradigms", + "nativeDescription": "The learning paradigms applied when training the model, such as supervised or reinforcement learning.", + "relevance": "recommended", + "weight": 0.5, + "rationale": "Helps readers judge what kinds of data and feedback shaped the model's behaviour." + }, + { + "expression": "$..[?(@.type=='machine-learning-model')].modelProperties.architecture", + "nativeName": "Model Architecture", + "nativeDescription": "The architecture family and structural characteristics of the model.", + "relevance": "recommended", + "weight": 0.7, + "rationale": "Architecture contextualizes capability and performance claims and supports reproducibility." + }, + { + "expression": "$..[?(@.type=='machine-learning-model')].modelProperties['inputs','outputs']", + "nativeName": "Input and Output Parameters", + "nativeDescription": "The modalities, formats, and constraints of the data the model consumes and produces.", + "relevance": "recommended", + "weight": 0.6, + "rationale": "Input and output specifications define the model's operational envelope and integration contract." + }, + { + "expression": "$..[?(@.type=='machine-learning-model')].modelProperties['parameterCount','quantization']", + "nativeName": "Model Size and Quantization", + "nativeDescription": "The parameter count of the model and any quantization applied to its weights.", + "relevance": "optional", + "weight": 0.3, + "rationale": "Size and quantization inform deployment cost and can affect accuracy relative to the unquantized model." + }, + { + "expression": "$..[?(@.type=='machine-learning-model')].modelProperties.training", + "nativeName": "Training Data and Procedure", + "nativeDescription": "How the model was trained: the training formula and the datasets used.", + "relevance": "recommended", + "weight": 0.8, + "rationale": "Training data provenance is central to assessing bias, capability boundaries, and data protection obligations." + }, + { + "expression": "$.formulation[*]", + "via": [ + { + "refs": "$..[?(@.type=='machine-learning-model')].modelProperties.training.formula" + } + ], + "nativeName": "Training Procedure", + "nativeDescription": "The formula describing how the model was trained: its workflows and tasks, such as pre-training, fine-tuning, and alignment stages, and the data each consumed.", + "relevance": "recommended", + "weight": 0.7, + "rationale": "Training stages and the datasets each consumed show how the data was used, which a description of the datasets alone cannot convey." + }, + { + "expression": "$..[?(@.type=='data')]['name','version','identifiers','licenses']", + "via": [ + { + "refs": "$..[?(@.type=='machine-learning-model')].modelProperties.training.datasets[*]" + } + ], + "nativeName": "Dataset Identity and Licensing", + "nativeDescription": "Which dataset was used, at which version, under which identifiers and licence terms.", + "relevance": "required", + "weight": 0.8, + "rationale": "A dataset a model card reader cannot identify or check licence terms for cannot be assessed; identity and licensing are the minimum the EU AI Act's data documentation expects." + }, + { + "expression": "$..[?(@.type=='data')].data[*]['type','description','contents']", + "via": [ + { + "refs": "$..[?(@.type=='machine-learning-model')].modelProperties.training.datasets[*]" + } + ], + "nativeName": "Dataset Description and Contents", + "nativeDescription": "What the dataset is: its general theme, a description of its size and role, and its contents or where they are held.", + "relevance": "recommended", + "weight": 0.6, + "rationale": "Describing composition and contents lets a reader judge coverage and representativeness of the training data." + }, + { + "expression": "$..[?(@.type=='data')].data[*]['classification','sensitiveData']", + "via": [ + { + "refs": "$..[?(@.type=='machine-learning-model')].modelProperties.training.datasets[*]" + } + ], + "nativeName": "Dataset Classification and Sensitive Data", + "nativeDescription": "The protection level the dataset requires and any sensitive or personal data it contains.", + "relevance": "recommended", + "weight": 0.7, + "rationale": "Sensitive-data declarations drive data protection obligations and are a primary concern of AI regulation." + }, + { + "expression": "$..[?(@.type=='data')].data[*].governance", + "via": [ + { + "refs": "$..[?(@.type=='machine-learning-model')].modelProperties.training.datasets[*]" + } + ], + "nativeName": "Dataset Governance", + "nativeDescription": "The parties accountable for the dataset through its lifecycle: owners, stewards, and custodians.", + "relevance": "recommended", + "weight": 0.5, + "rationale": "Governance names who is accountable for the data, which regulators and downstream users need to trace provenance and responsibility." + }, + { + "expression": "$..[?(@.type=='machine-learning-model')].modelProperties.evaluation", + "nativeName": "Quantitative Analysis", + "nativeDescription": "Evaluation results: performance metrics, per-slice measurements, confidence intervals, and supporting graphics.", + "relevance": "recommended", + "weight": 0.8, + "rationale": "Metrics, including slice-level results, substantiate capability claims and surface performance disparities between groups." + }, + { + "expression": "$..[?(@.type=='machine-learning-model')].modelProperties.useCases", + "nativeName": "Intended Use", + "nativeDescription": "References to the use cases the model is intended for.", + "relevance": "required", + "weight": 0.9, + "rationale": "Intended use separates in-scope application from misuse; it is the card section most consulted by adopters and assessors." + }, + { + "expression": "$.definitions.useCases[*]", + "via": [ + { + "refs": "$..[?(@.type=='machine-learning-model')].modelProperties.useCases[*]" + } + ], + "nativeName": "Use Case Definitions", + "nativeDescription": "The use case definitions that the model's intended-use references resolve to.", + "relevance": "recommended", + "weight": 0.6, + "rationale": "The model links to use cases by reference; the definitions carry the actual descriptions a card reader needs." + }, + { + "expression": "$..[?(@.type=='machine-learning-model')].parties[?(@.roles[?(@.role=='end-user')])]", + "nativeName": "Intended Users", + "nativeDescription": "The audiences the model is intended to be used by.", + "relevance": "recommended", + "weight": 0.5, + "rationale": "Stating who the model is for frames the expertise assumed of its operators. Expressed through the party model with the end-user role." + }, + { + "expression": "$..[?(@.type=='machine-learning-model')].modelProperties.limitations", + "nativeName": "Technical Limitations", + "nativeDescription": "Known technical limitations of the model, including constraints on accuracy, reasoning, scalability, and appropriate use, and relevant performance tradeoffs.", + "relevance": "required", + "weight": 0.9, + "rationale": "Limitations are the card's primary safeguard against use outside the model's competence." + }, + { + "expression": "$.risks.risks[?(@.domains[?(@.type=='ethical')])]", + "via": [ + { + "referrers": "$.risks.risks[*].affects[*]" + } + ], + "nativeName": "Ethical Considerations", + "nativeDescription": "Risks in the ethical domain associated with the model, including affected parties, benefits, harms, and mitigations.", + "relevance": "required", + "weight": 0.9, + "rationale": "Ethical considerations are expressed as entries in the document's risk model rather than as card-local prose, gaining structured likelihood, impact, and response data. Risks reference the models they affect through `affects`, so the referrers step selects, for a scoped model, exactly the risks that name it; unscoped, every matching risk is selected." + }, + { + "expression": "$.risks.risks[?(@.domains[?(@.type=='fairness')])]", + "via": [ + { + "referrers": "$.risks.risks[*].affects[*]" + } + ], + "nativeName": "Fairness Assessments", + "nativeDescription": "Risks in the fairness domain associated with the model, describing groups at risk and observed disparities.", + "relevance": "recommended", + "weight": 0.6, + "rationale": "Fairness assessments identify demographic or group-level performance disparities; slice-level evaluation metrics provide their quantitative backing. Fairness is selected as a risk domain, mirroring ethical considerations, so the mapping does not depend on how a methodology categorizes impact. Bias is a weakness kind rather than an impact category, reachable from a risk through `relatedWeaknesses`; selecting those weaknesses needs its own mapping, because a traversal step replaces the set of nodes the expression is evaluated against. Risks reference the models they affect through `affects`, so the referrers step selects, for a scoped model, exactly the risks that name it; unscoped, every matching risk is selected." + }, + { + "expression": "$..[?(@.type=='machine-learning-model')].modelProperties.environmental", + "nativeName": "Environmental Considerations", + "nativeDescription": "Energy consumption and carbon cost of model activities such as training and inference.", + "relevance": "recommended", + "weight": 0.5, + "rationale": "Environmental impact is an established model card section and increasingly a reporting obligation." + } + ] + } + ] +} diff --git a/schema/2.0/model/cyclonedx-perspective-2.0.schema.json b/schema/2.0/model/cyclonedx-perspective-2.0.schema.json index 1005f0772..deba72f33 100644 --- a/schema/2.0/model/cyclonedx-perspective-2.0.schema.json +++ b/schema/2.0/model/cyclonedx-perspective-2.0.schema.json @@ -3,7 +3,7 @@ "$id": "https://cyclonedx.org/schema/2.0/model/cyclonedx-perspective-2.0.schema.json", "type": "null", "title": "CycloneDX Perspective Model", - "$comment" : "OWASP CycloneDX is an Ecma International standard (ECMA-424) developed in collaboration between the OWASP Foundation and Ecma Technical Committee 54 (TC54). The standard is published under a royalty-free patent policy. This JSON schema is the reference implementation and is licensed under the Apache License 2.0.", + "$comment": "OWASP CycloneDX is an Ecma International standard (ECMA-424) developed in collaboration between the OWASP Foundation and Ecma Technical Committee 54 (TC54). The standard is published under a royalty-free patent policy. This JSON schema is the reference implementation and is licensed under the Apache License 2.0.", "$defs": { "perspectives": { "type": "array", @@ -16,16 +16,53 @@ "perspective": { "type": "object", "title": "Perspective", - "description": "A domain-specific view that identifies the types of data relevant to a particular audience and provides optional terminology mappings to facilitate interpretation. Perspectives enable tooling to generate filtered views, translate terminology, and validate document completeness against audience-specific requirements.", + "description": "A domain-specific view that identifies the types of data relevant to a particular audience and provides optional terminology mappings to facilitate interpretation. Perspectives enable tooling to generate filtered views, translate terminology, and validate document completeness against audience-specific requirements. A perspective is either a published pre-defined perspective applied by identity (`predefined`) or defined inline. It applies to the whole document unless it declares scopes, each of which applies it to a part of the document.", "additionalProperties": false, - "required": [ - "name", - "mappings" + "oneOf": [ + { + "$comment": "Reference form: the perspective is the published pre-defined perspective, incorporated by reference at a specific version. Both predefined and predefinedVersion are required; only bom-ref and scopes may otherwise accompany the pre-defined identity, so the published definition remains the single source of truth while the document decides where it applies.", + "properties": { + "predefined": true, + "predefinedVersion": true, + "scopes": true, + "name": false, + "description": false, + "domains": false, + "mappings": false, + "externalReferences": false, + "properties": false + }, + "required": [ + "predefined", + "predefinedVersion" + ] + }, + { + "$comment": "Inline form: the perspective is fully defined in the document and shall not declare a pre-defined identity or version.", + "properties": { + "predefined": false, + "predefinedVersion": false, + "name": true, + "mappings": true + }, + "required": [ + "name", + "mappings" + ] + } ], "properties": { "bom-ref": { "$ref": "cyclonedx-common-2.0.schema.json#/$defs/refType" }, + "predefined": { + "title": "Pre-Defined Perspective", + "description": "Identifies a well-known, pre-defined perspective, incorporating the published definition by reference and enabling tooling to recognize the perspective without matching on free-text names. Values use the reserved `cdx:perspectives:` namespace path and are drawn from the CycloneDX pre-defined perspectives registry (`perspectives-defs.schema.json`), which is maintained independently of the specification release cycle; the catalog document defining `cdx:perspectives:` is `perspectives/-perspective.json` in the CycloneDX specification repository. The specific published revision is selected by the sibling `predefinedVersion`. A perspective declaring a pre-defined identity shall provide `predefinedVersion` and shall not provide any inline content other than bom-ref and scopes, so the published definition remains the single source of truth; a perspective defining its own content shall omit both fields.", + "$ref": "../../perspectives-defs.schema.json#/definitions/preDefinedPerspectivesEnum" + }, + "predefinedVersion": { + "$ref": "#/$defs/preDefinedPerspectiveVersion" + }, "name": { "type": "string", "title": "Perspective Name", @@ -49,10 +86,13 @@ "$ref": "#/$defs/perspectiveDomainChoice" } }, + "scopes": { + "$ref": "#/$defs/perspectiveScopes" + }, "mappings": { "type": "array", "title": "Data Type Mappings", - "description": "An array of mappings that identify the types of data relevant to this perspective using JSON path expressions. Each mapping may include domain-specific terminology.", + "description": "An array of mappings that identify the types of data relevant to this perspective using JSON path expressions evaluated from the document root. Each mapping may include domain-specific terminology. When the perspective declares scopes, each mapping is restricted to the part of the document selected by the scope it is evaluated for.", "minItems": 1, "items": { "$ref": "#/$defs/perspectiveMapping" @@ -66,10 +106,96 @@ } } }, + "preDefinedPerspectiveVersion": { + "type": "integer", + "title": "Pre-Defined Perspective Version", + "description": "The published revision of the pre-defined perspective (identified by `predefined`) that this reference incorporates. This is the `version` of the catalog document that defines the perspective; like other CycloneDX version fields it is an integer incremented by 1 on each published revision. Pinning the version keeps a reference stable as the catalog perspective evolves.", + "minimum": 1 + }, + "perspectiveScopes": { + "type": "array", + "title": "Perspective Scopes", + "description": "The parts of the document this perspective applies to. Each scope is an independent application of the perspective: tooling generates one view and one completeness evaluation per scope. Omitting scopes applies the perspective to the whole document. A scope selects a set of nodes, resolved as follows: (1) evaluate each expression from the document root and select every node it identifies; (2) select the node identified by each target, resolving bom-ref values within this document; (3) for every reference entry within the subtrees of the nodes selected so far, that is, an object whose `ref` property identifies an object elsewhere in this document, also select the referenced node, once, without following references found in that node in turn; (4) the scope is the union of the subtrees of the selected nodes. A node is within a scope when its normalized path, as defined by JSONPath, starts with the normalized path of a selected node. BOM-Links to other documents are recorded but not followed. Scopes are applied at the perspective in the document, whether it applies a pre-defined perspective or is defined inline; a published pre-defined perspective declares no scopes of its own.", + "minItems": 1, + "items": { + "$ref": "#/$defs/perspectiveScope" + } + }, + "perspectiveScope": { + "type": "object", + "title": "Perspective Scope", + "description": "One part of the document a perspective applies to, selected by JSON path expressions, by references to objects, or both. Within a scope, expressions and targets are combined by union.", + "additionalProperties": false, + "anyOf": [ + { + "properties": { + "expressions": true + }, + "required": [ + "expressions" + ] + }, + { + "properties": { + "targets": true + }, + "required": [ + "targets" + ] + } + ], + "properties": { + "bom-ref": { + "$ref": "cyclonedx-common-2.0.schema.json#/$defs/refType" + }, + "name": { + "type": "string", + "title": "Scope Name", + "description": "The name of the scope, typically naming the subject it selects.", + "examples": [ + "Vision model", + "TLS stack" + ] + }, + "description": { + "type": "string", + "title": "Scope Description", + "description": "A description of the scope and why the perspective is applied to this part of the document." + }, + "expressions": { + "type": "array", + "title": "Path Expressions", + "description": "[JSONPath](https://datatracker.ietf.org/doc/html/rfc9535) expressions, evaluated from the document root, that identify the nodes whose subtrees form this scope.", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string" + }, + "examples": [ + [ + "$.definitions.components[?(@.type=='machine-learning-model' && @.group=='text')]" + ], + [ + "$.inventories[?(@['bom-ref']=='inv-vision')]" + ] + ] + }, + "targets": { + "type": "array", + "title": "Targets", + "description": "References using bom-link or bom-ref to the objects whose subtrees form this scope, such as a component or, where the document declares inventories, an inventory.", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "cyclonedx-common-2.0.schema.json#/$defs/elementLink" + } + } + } + }, "perspectiveMapping": { "type": "object", "title": "Perspective Mapping", - "description": "Maps a JSON path expression to domain-specific terminology, enabling audiences to interpret CycloneDX data using familiar nomenclature. Each mapping identifies a data type within the document structure and may provide alternative names and descriptions used by the target audience.", + "description": "Maps a JSON path expression to domain-specific terminology, enabling audiences to interpret CycloneDX data using familiar nomenclature. Each mapping identifies a data type within the document structure and may provide alternative names and descriptions used by the target audience. Expressions are evaluated from the document root; when the perspective is applied to a scope, the mapping selects only nodes within that scope, or within the nodes produced by its traversal steps.", "additionalProperties": false, "required": [ "expression" @@ -78,14 +204,24 @@ "expression": { "type": "string", "title": "Path Expression", - "description": "A [JSONPath](https://datatracker.ietf.org/doc/html/rfc9535) expression that identifies the types of data relevant to this perspective.", + "description": "A [JSONPath](https://datatracker.ietf.org/doc/html/rfc9535) expression, evaluated from the document root, that identifies the types of data relevant to this perspective. When the perspective is applied to a scope, only the identified nodes within the scope are selected; when the mapping declares traversal steps, only those within the nodes the steps produce.", "examples": [ - "$.components[*].pedigree", - "$.components[?(@.type=='machine-learning-model')].modelCard", - "$.components[?(@.type=='cryptographic-asset')]", + "$.inventories[*].components[*].pedigree", + "$.inventories[*].components[?(@.type=='machine-learning-model')].modelProperties", + "$..[?(@.type=='machine-learning-model')].modelProperties", + "$.definitions.components[?(@.type=='cryptographic-asset')]", "$.vulnerabilities" ] }, + "via": { + "type": "array", + "title": "Traversal Steps", + "description": "Steps that follow references from the scoped part of the document to related objects before the expression is evaluated, in order. Use this for data that is related to the scope rather than contained in it, such as the datasets a model was trained on, the risks that affect a model, or the algorithms a library provides.", + "minItems": 1, + "items": { + "$ref": "#/$defs/perspectiveTraversalStep" + } + }, "nativeName": { "type": "string", "title": "Native Name", @@ -119,9 +255,93 @@ } } }, + "perspectiveTraversalStep": { + "type": "object", + "title": "Perspective Traversal Step", + "description": "One step that transforms the set of nodes a mapping is evaluated against by following references, so that a mapping can select data related to the scoped part of the document rather than contained in it. A step either resolves reference values found at a path (`refs`), selects the objects that hold a reference to the current nodes (`referrers`), or walks the dependency graph (`dependencies`). Steps are applied in order, each to the result of the previous one; the first step is applied to the scope, or to the whole document when the perspective declares no scopes. The mapping's expression is then restricted to the subtrees of the nodes the last step produced.", + "additionalProperties": false, + "oneOf": [ + { + "properties": { + "refs": true, + "referrers": false, + "dependencies": false, + "transitive": false + }, + "required": [ + "refs" + ] + }, + { + "properties": { + "referrers": true, + "refs": false, + "dependencies": false, + "transitive": false + }, + "required": [ + "referrers" + ] + }, + { + "properties": { + "dependencies": true, + "refs": false, + "referrers": false + }, + "required": [ + "dependencies" + ] + } + ], + "properties": { + "refs": { + "type": "string", + "title": "Reference Path", + "description": "A [JSONPath](https://datatracker.ietf.org/doc/html/rfc9535) expression, evaluated from the document root and restricted to the current set of nodes, whose results are reference values (bom-ref or bom-link). The step produces the objects within this document those values identify; BOM-Links to other documents are not followed.", + "examples": [ + "$..[?(@.type=='machine-learning-model')].modelProperties.training.datasets[*]" + ] + }, + "referrers": { + "type": "string", + "title": "Referrer Path", + "description": "A [JSONPath](https://datatracker.ietf.org/doc/html/rfc9535) expression, evaluated from the document root, whose results are reference values (bom-ref or bom-link). The step produces the objects that hold such a value identifying a node in the current set: the object owning the property, or, when the value sits in an array, the object holding the array. This is the inverse of `refs`, for relationships that point towards the scoped nodes, such as a risk's `affects`. When the current set is the whole document the step produces the whole document, so an unscoped perspective is not narrowed to referenced nodes. Otherwise, the step replaces the current set with the matching referring objects; objects are not retained merely because they were already in the current set. For risk mappings using `affects`, scope to the affected model to select its risks.", + "examples": [ + "$.risks.risks[*].affects[*]" + ] + }, + "dependencies": { + "$ref": "#/$defs/perspectiveDependencyDirection" + }, + "transitive": { + "type": "boolean", + "title": "Transitive", + "description": "When true, the dependency graph is walked repeatedly in the given direction until no further objects are reached, and the step produces every object reached. When false or absent, only directly related objects are produced.", + "default": false + } + } + }, + "perspectiveDependencyDirection": { + "type": "string", + "title": "Dependency Direction", + "description": "The direction in which a traversal step walks the dependency graph, starting from every object within the current set of nodes that carries a bom-ref.", + "enum": [ + "depends-on", + "provides", + "dependents", + "provided-by" + ], + "meta:enum": { + "depends-on": "Produces the objects the current objects depend on, as declared by `dependsOn`.", + "provides": "Produces the objects the current objects provide or implement, as declared by `provides`.", + "dependents": "Produces the objects that depend on the current objects, the reverse of `dependsOn`.", + "provided-by": "Produces the objects that provide or implement the current objects, the reverse of `provides`." + } + }, "perspectiveRelevance": { "title": "Perspective Relevance", - "description": "Indicates the importance of a data type to a perspective.", + "description": "Indicates the importance of a data type to a perspective. Relevance and weight rank importance to the audience; they do not define a conformance rule. Within a completeness evaluation, a mapping is satisfied when it selects at least one node within the scope; declare one scope per subject for a verdict per subject. Whether a selected but empty collection is sufficient is left to the consumer's policy.", "enum": [ "required", "recommended", diff --git a/schema/perspectives-defs.json b/schema/perspectives-defs.json new file mode 100644 index 000000000..67c93c9fb --- /dev/null +++ b/schema/perspectives-defs.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://cyclonedx.org/schema/perspectives-defs.schema.json", + "lastUpdated": "2026-09-13T18:18:29Z", + "perspectives": [ + { + "predefined": "cdx:perspectives:model-card", + "file": "perspectives/model-card-perspective.json", + "name": "Model Card", + "description": "Defines the data elements of a machine learning model card, following the industry-standard model card structure: model details, intended use, training data and procedure, quantitative analysis, technical limitations, and ethical, fairness, and environmental considerations. The mappings project that structure onto the CycloneDX 2.0 decomposition of the former first-class modelCard entity: intrinsic technical characteristics live in modelProperties, which may only appear on components of type machine-learning-model; training datasets are components of type data; intended use cases are use case definitions referenced from the model; and ethical and fairness considerations are entries in the document's risk model. This perspective assumes the AI/ML model properties proposed in CycloneDX/specification#990. Model data is located by descendant search so the perspective applies whether models are declared at the document root, in inventories, or in definitions; related data such as training datasets and use cases is reached through traversal steps from the model's references.", + "versions": [ + { + "version": 1, + "sha256": "91ae19a351d746f349da3df4a99dfc04e739679b00e190910b7cf400cb23270a", + "commit": "4772726600b107d57c20ee69551019df32f74a0c", + "date": "2026-09-13T18:18:29Z" + } + ] + } + ] +} diff --git a/schema/perspectives-defs.schema.json b/schema/perspectives-defs.schema.json new file mode 100644 index 000000000..62ef1eeb9 --- /dev/null +++ b/schema/perspectives-defs.schema.json @@ -0,0 +1,125 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "http://cyclonedx.org/schema/perspectives-defs.schema.json", + "$comment": "2026-09-04T00:00:00Z", + "title": "CycloneDX Pre-Defined Perspectives Registry", + "description": "Governs the registry of well-known, pre-defined perspectives (perspectives-defs.json) and declares their reserved identities (preDefinedPerspectivesEnum, referenced by the specification's perspective schema). The identity enum is maintained by hand. The catalog document defining `cdx:perspectives:` is `perspectives/-perspective.json` in the repository, by naming convention; its `version` is the perspective version selected by `predefinedVersion`. The registry data is generated: it records every published version of each perspective with the sha256 of the catalog document's canonical JSON and the commit that introduced it. Once registered, a version's content is immutable; changing a catalog document requires incrementing its `version` by 1. The registry is maintained independently of the CycloneDX specification release cycle.", + "type": "object", + "additionalProperties": false, + "required": [ + "perspectives" + ], + "properties": { + "$schema": { + "type": "string" + }, + "lastUpdated": { + "type": "string", + "format": "date-time", + "title": "Last Updated", + "description": "The date and time (timestamp) of the most recently registered version." + }, + "perspectives": { + "type": "array", + "title": "Pre-Defined Perspectives", + "description": "The registered pre-defined perspectives, one entry per identity that has a published catalog document. Identities in the enum without an entry are reserved and not published yet.", + "items": { + "$ref": "#/definitions/registeredPerspective" + } + } + }, + "definitions": { + "preDefinedPerspectivesEnum": { + "type": "string", + "title": "Pre-Defined Perspective Identities", + "description": "The reserved identities of well-known, pre-defined perspectives, using the reserved cdx:perspectives: namespace path. The catalog document defining `cdx:perspectives:` is `perspectives/-perspective.json`. Referenced by the specification's perspective schema.", + "enum": [ + "cdx:perspectives:model-card", + "cdx:perspectives:pqc-readiness" + ], + "meta:enum": { + "cdx:perspectives:model-card": "A transparency view for machine learning models following the industry-standard model card structure, covering model details, intended use, training data and procedure, quantitative analysis, technical limitations, and ethical and environmental considerations.", + "cdx:perspectives:pqc-readiness": "A view for assessing readiness for the post-quantum cryptography migration, covering cryptographic inventory, quantum resistance of the cryptography in use, and cryptographic agility." + } + }, + "registeredPerspective": { + "type": "object", + "title": "Registered Pre-Defined Perspective", + "additionalProperties": false, + "required": [ + "predefined", + "file", + "versions" + ], + "properties": { + "predefined": { + "$ref": "#/definitions/preDefinedPerspectivesEnum", + "title": "Pre-Defined Perspective Identity", + "description": "The reserved identity of the perspective. Matches the `predefined` field of a perspective in a CycloneDX document." + }, + "file": { + "type": "string", + "title": "Catalog Document", + "description": "Path, relative to the repository root, of the catalog document defining this perspective, as given by the naming convention.", + "pattern": "^perspectives/[a-z0-9]+(-[a-z0-9]+)*-perspective\\.json$" + }, + "name": { + "type": "string", + "title": "Name", + "description": "The name of the perspective, as declared by its catalog document at the latest registered version." + }, + "description": { + "type": "string", + "title": "Description", + "description": "The description of the perspective, as declared by its catalog document at the latest registered version." + }, + "versions": { + "type": "array", + "title": "Registered Versions", + "description": "Every published version of the perspective, in ascending order, contiguous from 1.", + "minItems": 1, + "items": { + "$ref": "#/definitions/registeredVersion" + } + } + } + }, + "registeredVersion": { + "type": "object", + "title": "Registered Version", + "additionalProperties": false, + "required": [ + "version", + "sha256", + "commit", + "date" + ], + "properties": { + "version": { + "type": "integer", + "title": "Version", + "description": "The `version` of the catalog document that published this revision; the value a reference selects via `predefinedVersion`.", + "minimum": 1 + }, + "sha256": { + "type": "string", + "title": "Content Hash", + "description": "Lowercase hex SHA-256 of the catalog document's canonical JSON (object keys sorted, no insignificant whitespace) at this version. A catalog document whose hash differs from the one registered for its `version` has changed without a version bump.", + "pattern": "^[0-9a-f]{64}$" + }, + "commit": { + "type": "string", + "title": "Commit", + "description": "The commit that introduced this version of the catalog document, for provenance.", + "pattern": "^[0-9a-f]{40}$" + }, + "date": { + "type": "string", + "format": "date-time", + "title": "Date", + "description": "The date and time (timestamp) of that commit." + } + } + } + } +} diff --git a/tools/src/main/js/perspectives-registry/generate-perspectives-registry.js b/tools/src/main/js/perspectives-registry/generate-perspectives-registry.js new file mode 100644 index 000000000..a5b5dbdc6 --- /dev/null +++ b/tools/src/main/js/perspectives-registry/generate-perspectives-registry.js @@ -0,0 +1,127 @@ +#!/usr/bin/env node +"use strict"; + +/** + * Generate schema/perspectives-defs.json, the pre-defined perspectives registry. + * call the script via `node -- ` from anywhere inside the repository. + * + * Sources: the identity enum in schema/perspectives-defs.schema.json (hand-maintained) + * and the catalog documents under perspectives/ (hand-maintained, naming convention). + * Registers every catalog document version that is not registered yet, recording the + * sha256 of the document's canonical JSON and the commit that last touched the document. + * Never rewrites a registered version: a document whose content changed at an already + * registered version is an error, and so is any version that is not exactly the + * registered latest + 1 (or 1 for a new perspective). + * + * Exit code is the number of errors (0 = success), capped at 254. + */ + +import {execFile} from 'node:child_process' +import {readFile, writeFile} from 'node:fs/promises' +import {dirname, join} from 'node:path' +import {fileURLToPath} from 'node:url' +import {promisify} from 'node:util' + +import { + REGISTRY_DATA_FILE, REGISTRY_SCHEMA_FILE, State, OK_STATES, + assess, identitiesOf, readCatalogDocument, +} from './perspectives-registry.js' + +const _thisDir = dirname(fileURLToPath(import.meta.url)) +const repoRoot = join(_thisDir, '..', '..', '..', '..', '..') +const schemaFile = join(repoRoot, REGISTRY_SCHEMA_FILE) +const dataFile = join(repoRoot, REGISTRY_DATA_FILE) +const execFileP = promisify(execFile) + +/** + * @param {string} file path relative to repository root + * @return {Promise<{commit: string, date: string}>} last commit touching the file, date in UTC + */ +async function lastCommitOf(file) { + const {stdout} = await execFileP('git', ['log', '-1', '--format=%H%n%cI', '--', file], {cwd: repoRoot}) + const [commit, date] = stdout.trim().split('\n') + if (!/^[0-9a-f]{40}$/.test(commit ?? '')) { + throw new Error(`no commit found for ${file}; the document shall be committed before it is registered`) + } + return {commit, date: new Date(date).toISOString().replace(/\.\d{3}Z$/, 'Z')} +} + +const schema = JSON.parse(await readFile(schemaFile, 'utf-8')) +const identities = identitiesOf(schema) +const previous = await readFile(dataFile, 'utf-8').then(JSON.parse).catch(err => { + if (err.code === 'ENOENT') return {perspectives: []} + throw err +}) +const previousEntries = new Map((previous.perspectives ?? []).map(e => [e.predefined, e])) + +let errCnt = 0 +const entries = [] + +for (const [id] of previousEntries) { + if (!identities.includes(id)) { + ++errCnt + console.error(`!!! ERROR: registered identity ${id} is not in the enum of ${REGISTRY_SCHEMA_FILE}; registered versions are never dropped`) + } +} + +for (const identity of [...identities].sort()) { + const entry = previousEntries.get(identity) + let catalog + try { + catalog = await readCatalogDocument(repoRoot, identity) + } catch (err) { + ++errCnt + console.error(`!!! ERROR: ${identity}: ${err.message}`) + if (entry !== undefined) entries.push(entry) + continue + } + if (catalog.problems.length > 0) { + ++errCnt + console.error(`!!! ERROR: ${identity}: ${catalog.file}\n - ${catalog.problems.join('\n - ')}`) + if (entry !== undefined) entries.push(entry) + continue + } + const {state, detail} = assess(catalog, entry) + console.log(`${identity}: ${state} (${detail})`) + if (!OK_STATES.has(state)) { + ++errCnt + console.error(`!!! ERROR: ${identity}: ${detail}`) + if (entry !== undefined) entries.push(entry) + continue + } + if (state === State.RESERVED) { + continue + } + const versions = [...(entry?.versions ?? [])] + if (state === State.NEW || state === State.PENDING) { + const {commit, date} = await lastCommitOf(catalog.file) + versions.push({version: catalog.version, sha256: catalog.sha256, commit, date}) + console.log(` registering version ${catalog.version} from commit ${commit}`) + } + entries.push({ + predefined: identity, + file: catalog.file, + name: catalog.perspective.name, + description: catalog.perspective.description, + versions, + }) +} + +if (errCnt === 0) { + const lastUpdated = entries.flatMap(e => e.versions.map(v => v.date)).sort().at(-1) ?? previous.lastUpdated + const registry = { + $schema: 'http://cyclonedx.org/schema/perspectives-defs.schema.json', + ...(lastUpdated !== undefined ? {lastUpdated} : {}), + perspectives: entries, + } + const output = JSON.stringify(registry, null, 2) + '\n' + if (output === await readFile(dataFile, 'utf-8').catch(() => undefined)) { + console.log('\nregistry unchanged:', dataFile) + } else { + await writeFile(dataFile, output, 'utf-8') + console.log('\nregistry written:', dataFile) + } +} + +console.log('\n> found', errCnt, 'errors') +process.exitCode = Math.min(errCnt, 254) diff --git a/tools/src/main/js/perspectives-registry/package.json b/tools/src/main/js/perspectives-registry/package.json new file mode 100644 index 000000000..2f930540e --- /dev/null +++ b/tools/src/main/js/perspectives-registry/package.json @@ -0,0 +1,15 @@ +{ + "private": true, + "name": "@cyclonedx/perspectives-registry", + "version": "1.0.0", + "description": "Generate and check the CycloneDX pre-defined perspectives registry", + "type": "module", + "main": "perspectives-registry.js", + "engines": { + "node": ">=22.0" + }, + "scripts": { + "generate": "node -- generate-perspectives-registry.js" + }, + "license": "Apache-2.0" +} diff --git a/tools/src/main/js/perspectives-registry/perspectives-registry.js b/tools/src/main/js/perspectives-registry/perspectives-registry.js new file mode 100644 index 000000000..fb0e4e4f0 --- /dev/null +++ b/tools/src/main/js/perspectives-registry/perspectives-registry.js @@ -0,0 +1,233 @@ +"use strict"; + +/** + * Shared logic for the CycloneDX pre-defined perspectives registry. + * + * Used by the generator (tools/src/main/js/perspectives-registry) and by the + * schema-v2 test suite, so that both hash catalog documents and judge + * version states the same way. + * + * Conventions: + * - identities are `cdx:perspectives:` (the enum in + * schema/perspectives-defs.schema.json is the hand-maintained source) + * - the catalog document defining `cdx:perspectives:` is + * `perspectives/-perspective.json` (naming convention, no mapping) + * - a catalog document is a CycloneDX document defining exactly one inline + * perspective; its `version` is the perspective version referenced by + * `predefinedVersion` + * - the registry (schema/perspectives-defs.json) is GENERATED and records, + * per identity, every published version with the sha256 of the catalog + * document's canonical JSON and the commit that introduced it; once + * registered, a version's content is immutable + */ + +import {createHash} from 'node:crypto' +import {readFile, stat} from 'node:fs/promises' +import {join} from 'node:path' + +export const IDENTITY_PREFIX = 'cdx:perspectives:' +export const CATALOG_DIR = 'perspectives' +export const CATALOG_FILE_SUFFIX = '-perspective.json' +export const REGISTRY_SCHEMA_FILE = join('schema', 'perspectives-defs.schema.json') +export const REGISTRY_DATA_FILE = join('schema', 'perspectives-defs.json') +export const ENUM_POINTER = Object.freeze(['definitions', 'preDefinedPerspectivesEnum', 'enum']) + +/** + * Version states of a catalog document relative to the registry. + * @readonly + * @enum {string} + */ +export const State = Object.freeze({ + /** identity in the enum, no catalog document yet */ + RESERVED: 'reserved', + /** first version (1) of a not-yet-registered perspective; the generator will register it */ + NEW: 'new', + /** version equals the registered latest and the content is unchanged */ + UNCHANGED: 'unchanged', + /** version equals registered latest + 1; the generator will register it */ + PENDING: 'pending', + /** version equals the registered latest but the content differs: change without a version bump */ + MODIFIED: 'modified', + /** version is lower than the registered latest */ + REGRESSED: 'regressed', + /** version skips ahead of registered latest + 1 (or a new perspective not starting at 1) */ + SKIPPED: 'skipped', + /** a registered perspective whose catalog document no longer exists */ + REMOVED: 'removed', +}) + +/** states that are acceptable in a pull request / on the base branch */ +export const OK_STATES = Object.freeze(new Set([State.RESERVED, State.NEW, State.UNCHANGED, State.PENDING])) + +/** + * @param {string} identity + * @return {string} catalog document path relative to the repository root, using `/` separators + */ +export function catalogFileOf(identity) { + if (typeof identity !== 'string' || !identity.startsWith(IDENTITY_PREFIX)) { + throw new Error(`not a pre-defined perspective identity: ${identity}`) + } + const name = identity.slice(IDENTITY_PREFIX.length) + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) { + throw new Error(`identity name is not lowercase-kebab-case: ${identity}`) + } + return `${CATALOG_DIR}/${name}${CATALOG_FILE_SUFFIX}` +} + +/** + * Canonical JSON: object keys sorted, no insignificant whitespace. + * Formatting-only edits of a catalog document therefore do not change its hash. + * @param {*} value + * @return {string} + */ +export function canonicalize(value) { + if (Array.isArray(value)) { + return `[${value.map(canonicalize).join(',')}]` + } + if (value !== null && typeof value === 'object') { + return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${canonicalize(value[k])}`).join(',')}}` + } + return JSON.stringify(value) +} + +/** + * @param {*} doc parsed catalog document + * @return {string} lowercase hex sha256 of the canonical JSON + */ +export function sha256Of(doc) { + return createHash('sha256').update(canonicalize(doc), 'utf-8').digest('hex') +} + +/** + * @param {object} schema parsed registry governing schema + * @return {string[]} the identity enum + */ +export function identitiesOf(schema) { + const values = ENUM_POINTER.reduce((node, key) => node?.[key], schema) + if (!Array.isArray(values)) { + throw new Error(`missing enum at /${ENUM_POINTER.join('/')} in registry schema`) + } + return values +} + +/** + * @typedef {object} CatalogDocument + * @property {string} identity + * @property {string} file path relative to repository root + * @property {string} path absolute path + * @property {boolean} exists + * @property {*} [doc] parsed document (when it exists and is JSON) + * @property {number} [version] + * @property {object} [perspective] the single inline perspective + * @property {string} [sha256] + * @property {string[]} problems structural problems of an existing document + */ + +/** + * Read and structurally check the catalog document of an identity. + * @param {string} repoRoot + * @param {string} identity + * @return {Promise} + */ +export async function readCatalogDocument(repoRoot, identity) { + const file = catalogFileOf(identity) + const path = join(repoRoot, ...file.split('/')) + const result = {identity, file, path, exists: false, problems: []} + if (!await stat(path).then(s => s.isFile()).catch(() => false)) { + return result + } + result.exists = true + let doc + try { + doc = JSON.parse(await readFile(path, 'utf-8')) + } catch (err) { + result.problems.push(`not valid JSON: ${err}`) + return result + } + result.doc = doc + result.sha256 = sha256Of(doc) + if (!Number.isInteger(doc?.version) || doc.version < 1) { + result.problems.push('shall declare an integer `version` >= 1 (the value referenced by `predefinedVersion`)') + } else { + result.version = doc.version + } + const perspectives = Array.isArray(doc?.perspectives) ? doc.perspectives : [] + if (perspectives.length !== 1) { + result.problems.push(`shall define exactly one perspective, found ${perspectives.length}`) + } else { + const p = perspectives[0] + if (p?.predefined !== undefined || p?.predefinedVersion !== undefined) { + result.problems.push('shall define its perspective inline, not by pre-defined reference (`predefined` / `predefinedVersion`)') + } + result.perspective = p + } + return result +} + +/** + * @param {object[]|undefined} versions registered version records of the identity + * @return {number} registered latest version, 0 when unregistered + */ +export function latestOf(versions) { + return Array.isArray(versions) && versions.length > 0 ? Math.max(...versions.map(v => v.version)) : 0 +} + +/** + * Judge a catalog document against its registry entry. + * @param {CatalogDocument} catalog + * @param {object|undefined} entry registry entry of the identity + * @return {{state: State, latest: number, detail: string}} + */ +export function assess(catalog, entry) { + const latest = latestOf(entry?.versions) + if (!catalog.exists) { + return latest > 0 + ? {state: State.REMOVED, latest, detail: `registered up to version ${latest} but ${catalog.file} does not exist`} + : {state: State.RESERVED, latest, detail: `${catalog.file} does not exist (yet)`} + } + const {version, sha256} = catalog + if (latest === 0) { + return version === 1 + ? {state: State.NEW, latest, detail: 'version 1, not registered yet'} + : {state: State.SKIPPED, latest, detail: `not registered yet, so it shall start at version 1, found ${version}`} + } + if (version === latest) { + const registered = entry.versions.find(v => v.version === latest) + return registered.sha256 === sha256 + ? {state: State.UNCHANGED, latest, detail: `version ${version}, content matches the registry`} + : {state: State.MODIFIED, latest, detail: `content differs from registered version ${latest} (${registered.commit}); bump \`version\` to ${latest + 1}`} + } + if (version === latest + 1) { + return {state: State.PENDING, latest, detail: `version ${version} bumped from registered ${latest}, not registered yet`} + } + if (version < latest) { + return {state: State.REGRESSED, latest, detail: `version ${version} is below registered latest ${latest}`} + } + return {state: State.SKIPPED, latest, detail: `version ${version} skips ahead of registered latest ${latest}; next is ${latest + 1}`} +} + +/** + * Structural problems of a registry entry that the governing schema cannot express. + * @param {object} entry + * @return {string[]} + */ +export function entryProblems(entry) { + const problems = [] + let expectedFile + try { + expectedFile = catalogFileOf(entry.predefined) + } catch (err) { + problems.push(String(err.message)) + } + if (expectedFile !== undefined && entry.file !== expectedFile) { + problems.push(`\`file\` is ${entry.file}, naming convention expects ${expectedFile}`) + } + const numbers = (entry.versions ?? []).map(v => v.version) + for (let i = 0; i < numbers.length; ++i) { + if (numbers[i] !== i + 1) { + problems.push(`\`versions\` shall be contiguous from 1 in ascending order, found [${numbers.join(', ')}]`) + break + } + } + return problems +} diff --git a/tools/src/test/java/org/cyclonedx/schema/v2/AbstractJsonSchemaVerificationTest.java b/tools/src/test/java/org/cyclonedx/schema/v2/AbstractJsonSchemaVerificationTest.java index 5aa51b9a6..1d715e9be 100644 --- a/tools/src/test/java/org/cyclonedx/schema/v2/AbstractJsonSchemaVerificationTest.java +++ b/tools/src/test/java/org/cyclonedx/schema/v2/AbstractJsonSchemaVerificationTest.java @@ -61,6 +61,7 @@ abstract class AbstractJsonSchemaVerificationTest { private static final String SPDX_NAMESPACE = "cyclonedx.org/schema/spdx.schema.json"; private static final String CRYPTO_DEF_NAMESPACE = "cyclonedx.org/schema/cryptography-defs.schema.json"; private static final String BEHAVIOR_TAXONOMY_NAMESPACE = "cyclonedx.org/schema/behavior-taxonomy.schema.json"; + private static final String PERSPECTIVES_DEFS_NAMESPACE = "cyclonedx.org/schema/perspectives-defs.schema.json"; private final String version; @@ -107,6 +108,8 @@ public JsonMetaSchema getMetaSchema( .mapPrefix("http://" + CRYPTO_DEF_NAMESPACE, "classpath:cryptography-defs.schema.json") .mapPrefix("http://" + BEHAVIOR_TAXONOMY_NAMESPACE, "classpath:behavior-taxonomy.schema.json") .mapPrefix("https://" + BEHAVIOR_TAXONOMY_NAMESPACE, "classpath:behavior-taxonomy.schema.json") + .mapPrefix("http://" + PERSPECTIVES_DEFS_NAMESPACE, "classpath:perspectives-defs.schema.json") + .mapPrefix("https://" + PERSPECTIVES_DEFS_NAMESPACE, "classpath:perspectives-defs.schema.json") ).build(); SchemaValidatorsConfig config = SchemaValidatorsConfig.builder() // in 2020-12, "format" is annotation-only unless asserted diff --git a/tools/src/test/js/schema-v2/json-schema-functional-tests.js b/tools/src/test/js/schema-v2/json-schema-functional-tests.js index 63b4298c2..df03a8af7 100644 --- a/tools/src/test/js/schema-v2/json-schema-functional-tests.js +++ b/tools/src/test/js/schema-v2/json-schema-functional-tests.js @@ -58,10 +58,11 @@ console.debug('DEBUG | testdataDir = ', testdataDir); // region validator -const [spdxSchema, cryptoDefsSchema, behaviorTaxonomySchema, bomSchema, bomSchemaModules] = await Promise.all([ +const [spdxSchema, cryptoDefsSchema, behaviorTaxonomySchema, perspectivesDefsSchema, bomSchema, bomSchemaModules] = await Promise.all([ readFile(join(schemaRootDir, 'spdx.schema.json'), 'utf-8').then(JSON.parse), readFile(join(schemaRootDir, 'cryptography-defs.schema.json'), 'utf-8').then(JSON.parse), readFile(join(schemaRootDir, 'behavior-taxonomy.schema.json'), 'utf-8').then(JSON.parse), + readFile(join(schemaRootDir, 'perspectives-defs.schema.json'), 'utf-8').then(JSON.parse), readFile(schemaFile, 'utf-8').then(JSON.parse), glob(join(schemaModelDir, schemaGlob)).then(fs => Promise.all(fs.map( f => readFile(f, 'utf-8').then(s => [basename(f), JSON.parse(s)]) @@ -80,6 +81,7 @@ ajv.addMetaSchema(draft7MetaSchema); ajv.addSchema(spdxSchema, 'https://cyclonedx.org/schema/spdx.schema.json') ajv.addSchema(cryptoDefsSchema, 'https://cyclonedx.org/schema/cryptography-defs.schema.json') ajv.addSchema(behaviorTaxonomySchema, 'https://cyclonedx.org/schema/behavior-taxonomy.schema.json') +ajv.addSchema(perspectivesDefsSchema, 'https://cyclonedx.org/schema/perspectives-defs.schema.json') for (const [f, s] of bomSchemaModules) { ajv.addSchema(s, `https://cyclonedx.org/schema/${testschemaVersion}/model/${f}`) } diff --git a/tools/src/test/js/schema-v2/json-schema-validate-tests.js b/tools/src/test/js/schema-v2/json-schema-validate-tests.js index 9ad5c9d16..64cd892d6 100644 --- a/tools/src/test/js/schema-v2/json-schema-validate-tests.js +++ b/tools/src/test/js/schema-v2/json-schema-validate-tests.js @@ -56,10 +56,11 @@ console.debug('DEBUG | schemaModelDir = ', schemaModelDir); // endregion config -const [spdxSchema, cryptoDefsSchema, behaviorTaxonomySchema, schemas, schemaModules] = await Promise.all([ +const [spdxSchema, cryptoDefsSchema, behaviorTaxonomySchema, perspectivesDefsSchema, schemas, schemaModules] = await Promise.all([ readFile(join(schemaRootDir, 'spdx.schema.json'), 'utf-8').then(JSON.parse), readFile(join(schemaRootDir, 'cryptography-defs.schema.json'), 'utf-8').then(JSON.parse), readFile(join(schemaRootDir, 'behavior-taxonomy.schema.json'), 'utf-8').then(JSON.parse), + readFile(join(schemaRootDir, 'perspectives-defs.schema.json'), 'utf-8').then(JSON.parse), Promise.all(schemaFiles.map( f => readFile(f, 'utf-8').then(s => [f, JSON.parse(s)]) )), @@ -95,6 +96,7 @@ function getAjv(bundled) { ajv.addSchema(spdxSchema, 'https://cyclonedx.org/schema/spdx.schema.json') ajv.addSchema(cryptoDefsSchema, 'https://cyclonedx.org/schema/cryptography-defs.schema.json') ajv.addSchema(behaviorTaxonomySchema, 'https://cyclonedx.org/schema/behavior-taxonomy.schema.json') + ajv.addSchema(perspectivesDefsSchema, 'https://cyclonedx.org/schema/perspectives-defs.schema.json') if (!bundled) { for (const [f, s] of schemaModules) { ajv.addSchema(s, `https://cyclonedx.org/schema/${testschemaVersion}/model/${f}`) diff --git a/tools/src/test/js/schema-v2/package.json b/tools/src/test/js/schema-v2/package.json index 63b0b645f..bcffdb101 100644 --- a/tools/src/test/js/schema-v2/package.json +++ b/tools/src/test/js/schema-v2/package.json @@ -19,6 +19,7 @@ "test:v2.0": "run-s \"test:v2.0:*\"", "test:v2.0:t1-json-schema-validate": "node -- json-schema-validate-tests.js -v 2.0", "test:v2.0:t2-json-schema-semantic": "node -- json-schema-semantic-tests.js -v 2.0", - "test:v2.0:t3-json-schema-functional": "node -- json-schema-functional-tests.js -v 2.0" + "test:v2.0:t3-json-schema-functional": "node -- json-schema-functional-tests.js -v 2.0", + "test:v2.0:t4-perspectives-registry": "node -- perspectives-registry-tests.js" } } diff --git a/tools/src/test/js/schema-v2/perspectives-registry-tests.js b/tools/src/test/js/schema-v2/perspectives-registry-tests.js new file mode 100644 index 000000000..3f957550e --- /dev/null +++ b/tools/src/test/js/schema-v2/perspectives-registry-tests.js @@ -0,0 +1,178 @@ +"use strict"; + +/** + * validate the pre-defined perspectives registry. + * call the script via `node -- ` + * + * The registry is not tied to a CycloneDX version: it lives at the schema root + * (schema/perspectives-defs.json, generated) and is governed by + * schema/perspectives-defs.schema.json, whose hand-maintained `preDefinedPerspectivesEnum` + * is referenced by the versioned perspective schema. This test asserts: + * - the registry data validates against its governing schema + * - every registered identity is in the enum, without duplicates on either side; + * entries follow the naming convention and list contiguous versions from 1 + * - for every identity in the enum, the catalog document (perspectives/-perspective.json) + * is in an acceptable state relative to the registry: reserved (no document yet), new + * (version 1, not registered), unchanged (registered content), or pending (registered + * latest + 1). A document changed at an already registered version, a regressed or + * skipped version, or a removed registered document fails. + * Shared logic lives in tools/src/main/js/perspectives-registry/perspectives-registry.js. + */ + +import {readFile, stat} from 'node:fs/promises' +import {dirname, join} from 'node:path' +import {fileURLToPath} from 'node:url' + +import Ajv2020 from "ajv/dist/2020.js" +import draft7MetaSchema from "ajv/dist/refs/json-schema-draft-07.json" with {type: "json"}; +import addFormats from 'ajv-formats' + +import { + REGISTRY_DATA_FILE, REGISTRY_SCHEMA_FILE, State, OK_STATES, + assess, entryProblems, identitiesOf, readCatalogDocument, +} from '../../../main/js/perspectives-registry/perspectives-registry.js' + + +const _thisDir = dirname(fileURLToPath(import.meta.url)) + +// region config + +const repoRootDir = join(_thisDir, '..', '..', '..', '..', '..') +const registrySchemaFile = join(repoRootDir, REGISTRY_SCHEMA_FILE) +const registryDataFile = join(repoRootDir, REGISTRY_DATA_FILE) + +for (const file of [registrySchemaFile, registryDataFile]) { + if (!await stat(file).then(s => s.isFile()).catch(() => false)) { + throw new Error(`missing file: ${file}`); + } +} +console.debug('DEBUG | registrySchemaFile = ', registrySchemaFile); +console.debug('DEBUG | registryDataFile = ', registryDataFile); + +// endregion config + +const [registrySchema, registryData] = await Promise.all([ + readFile(registrySchemaFile, 'utf-8').then(JSON.parse), + readFile(registryDataFile, 'utf-8').then(JSON.parse), +]) + +let errCnt = 0 + +/** + * @param {string} message + * @param {...*} details + */ +function fail(message, ...details) { + ++errCnt + console.error('!!! ERROR:', message, ...details) +} + +// region schema conformance + +console.log('\n> validate registry data against its governing schema ...') +{ + // same strict setup as the schema validation tests + const ajv = new Ajv2020({ + verbose: true, + addUsedSchema: false, + keywords: ["meta:enum"], + strict: true, + strictSchema: true, + strictNumbers: true, + strictTypes: true, + strictTuples: true, + strictRequired: true, + validateFormats: true, + }); + // the registry schema is draft-07 + ajv.addMetaSchema(draft7MetaSchema); + addFormats(ajv) + let validate + try { + validate = ajv.compile(registrySchema) + } catch (err) { + fail('failed compiling registry schema', '\n in file:', `file://${registrySchemaFile}`, '\n error:', String(err)) + } + if (validate !== undefined) { + if (validate(registryData)) { + console.log('OK.') + } else { + fail('registry data does not conform to its governing schema', + '\n for file:', `file://${registryDataFile}`, + '\n error:', validate.errors) + } + } +} + +// endregion schema conformance + +// region registry entries + +console.log('\n> check registry entries against the enum and the naming convention ...') +const identities = identitiesOf(registrySchema) +const entries = Array.isArray(registryData.perspectives) ? registryData.perspectives : [] +const entryById = new Map() +{ + const dupEnum = identities.filter((v, i) => identities.indexOf(v) !== i) + if (dupEnum.length > 0) { + fail('duplicate values in enum', dupEnum, '\n in file:', `file://${registrySchemaFile}`) + } + let entryErrors = 0 + for (const entry of entries) { + if (entryById.has(entry?.predefined)) { + ++entryErrors + fail('duplicate registry entry for', entry.predefined) + continue + } + entryById.set(entry?.predefined, entry) + if (!identities.includes(entry?.predefined)) { + ++entryErrors + fail('registered identity is not in the enum:', entry?.predefined, '\n add it to', `file://${registrySchemaFile}`) + } + const problems = entryProblems(entry) + if (problems.length > 0) { + ++entryErrors + fail(`registry entry ${entry?.predefined}:`, '\n - ' + problems.join('\n - ')) + } + } + if (dupEnum.length === 0 && entryErrors === 0) { + console.log('OK.', entries.length, 'registered of', identities.length, 'identities') + } +} + +// endregion registry entries + +// region catalog documents + +console.log('\n> check catalog documents against the registry ...') +for (const identity of identities) { + let catalog + try { + catalog = await readCatalogDocument(repoRootDir, identity) + } catch (err) { + fail(`${identity}:`, String(err.message)) + continue + } + console.log('\ntest', identity, '->', catalog.file, '...') + if (catalog.problems.length > 0) { + fail(`catalog document of ${identity}:`, '\n file:', `file://${catalog.path}`, '\n - ' + catalog.problems.join('\n - ')) + continue + } + const {state, detail} = assess(catalog, entryById.get(identity)) + if (!OK_STATES.has(state)) { + fail(`${identity} is ${state}:`, detail, '\n file:', `file://${catalog.path}`) + } else if (state === State.RESERVED) { + console.warn(`WARNING: ${identity} is reserved:`, detail) + } else if (state === State.UNCHANGED) { + console.log('OK.', detail) + } else { + console.log(`OK (${state}).`, detail, '- the registry generator will register it after merge') + } +} + +// endregion catalog documents + +console.log('\n\n> found', errCnt, 'errors') +// Exit statuses should be in the range 0 to 254. +// The status 0 is used to terminate the program successfully. +process.exitCode = Math.min(errCnt, 254) diff --git a/tools/src/test/resources/2.0/invalid-perspective-predefined-2.0.json b/tools/src/test/resources/2.0/invalid-perspective-predefined-2.0.json new file mode 100644 index 000000000..b8608c05c --- /dev/null +++ b/tools/src/test/resources/2.0/invalid-perspective-predefined-2.0.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://cyclonedx.org/schema/2.0/cyclonedx-2.0.schema.json", + "specFormat": "CycloneDX", + "specVersion": "2.0", + "version": 1, + "metadata": { + "timestamp": "2026-08-29T12:00:00Z" + }, + "perspectives": [ + { + "bom-ref": "perspective-1", + "predefined": "cdx:perspectives:threat-model", + "predefinedVersion": 1 + } + ] +} diff --git a/tools/src/test/resources/2.0/invalid-perspective-predefined-annotated-2.0.json b/tools/src/test/resources/2.0/invalid-perspective-predefined-annotated-2.0.json new file mode 100644 index 000000000..5d16a613d --- /dev/null +++ b/tools/src/test/resources/2.0/invalid-perspective-predefined-annotated-2.0.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://cyclonedx.org/schema/2.0/cyclonedx-2.0.schema.json", + "specFormat": "CycloneDX", + "specVersion": "2.0", + "version": 1, + "metadata": { + "timestamp": "2026-08-29T12:00:00Z" + }, + "perspectives": [ + { + "bom-ref": "perspective-1", + "predefined": "cdx:perspectives:model-card", + "name": "Threat Model", + "domains": [ + "cryptographic-security" + ], + "predefinedVersion": 1 + } + ] +} diff --git a/tools/src/test/resources/2.0/invalid-perspective-predefined-mixed-2.0.json b/tools/src/test/resources/2.0/invalid-perspective-predefined-mixed-2.0.json new file mode 100644 index 000000000..353f77d26 --- /dev/null +++ b/tools/src/test/resources/2.0/invalid-perspective-predefined-mixed-2.0.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://cyclonedx.org/schema/2.0/cyclonedx-2.0.schema.json", + "specFormat": "CycloneDX", + "specVersion": "2.0", + "version": 1, + "metadata": { + "timestamp": "2026-08-29T12:00:00Z" + }, + "perspectives": [ + { + "bom-ref": "perspective-1", + "predefined": "cdx:perspectives:pqc-readiness", + "name": "PQC Readiness", + "mappings": [ + { + "expression": "$.components[?(@.type=='cryptographic-asset')]", + "nativeName": "Cryptographic Inventory", + "relevance": "required" + } + ], + "predefinedVersion": 1 + } + ] +} diff --git a/tools/src/test/resources/2.0/invalid-perspective-predefined-noversion-2.0.json b/tools/src/test/resources/2.0/invalid-perspective-predefined-noversion-2.0.json new file mode 100644 index 000000000..3a9a09e7a --- /dev/null +++ b/tools/src/test/resources/2.0/invalid-perspective-predefined-noversion-2.0.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://cyclonedx.org/schema/2.0/cyclonedx-2.0.schema.json", + "specFormat": "CycloneDX", + "specVersion": "2.0", + "version": 1, + "metadata": { + "timestamp": "2026-08-29T12:00:00Z" + }, + "perspectives": [ + { + "bom-ref": "perspective-1", + "predefined": "cdx:perspectives:model-card" + } + ] +} diff --git a/tools/src/test/resources/2.0/invalid-perspective-scope-empty-2.0.json b/tools/src/test/resources/2.0/invalid-perspective-scope-empty-2.0.json new file mode 100644 index 000000000..9420a734e --- /dev/null +++ b/tools/src/test/resources/2.0/invalid-perspective-scope-empty-2.0.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://cyclonedx.org/schema/2.0/cyclonedx-2.0.schema.json", + "specFormat": "CycloneDX", + "specVersion": "2.0", + "version": 1, + "metadata": { + "timestamp": "2026-09-04T12:00:00Z" + }, + "perspectives": [ + { + "bom-ref": "card-1", + "predefined": "cdx:perspectives:model-card", + "predefinedVersion": 1, + "scopes": [ + { + "name": "Selects nothing: a scope shall declare expressions or targets" + } + ] + } + ] +} diff --git a/tools/src/test/resources/2.0/invalid-perspective-via-direction-2.0.json b/tools/src/test/resources/2.0/invalid-perspective-via-direction-2.0.json new file mode 100644 index 000000000..a60632545 --- /dev/null +++ b/tools/src/test/resources/2.0/invalid-perspective-via-direction-2.0.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://cyclonedx.org/schema/2.0/cyclonedx-2.0.schema.json", + "specFormat": "CycloneDX", + "specVersion": "2.0", + "version": 1, + "metadata": { + "timestamp": "2026-09-04T12:00:00Z" + }, + "perspectives": [ + { + "bom-ref": "persp-1", + "name": "Unknown traversal direction", + "mappings": [ + { + "expression": "$.components[?(@.type=='cryptographic-asset')]", + "via": [ + { + "dependencies": "uses" + } + ] + } + ] + } + ] +} diff --git a/tools/src/test/resources/2.0/invalid-perspective-via-mixed-2.0.json b/tools/src/test/resources/2.0/invalid-perspective-via-mixed-2.0.json new file mode 100644 index 000000000..10b360323 --- /dev/null +++ b/tools/src/test/resources/2.0/invalid-perspective-via-mixed-2.0.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://cyclonedx.org/schema/2.0/cyclonedx-2.0.schema.json", + "specFormat": "CycloneDX", + "specVersion": "2.0", + "version": 1, + "metadata": { + "timestamp": "2026-09-04T12:00:00Z" + }, + "perspectives": [ + { + "bom-ref": "persp-1", + "name": "Mixed traversal step", + "mappings": [ + { + "expression": "$.components[?(@.type=='data')]", + "via": [ + { + "refs": "$.components[*].modelProperties.training.datasets[*]", + "dependencies": "depends-on" + } + ] + } + ] + } + ] +} diff --git a/tools/src/test/resources/2.0/invalid-perspective-via-referrers-mixed-2.0.json b/tools/src/test/resources/2.0/invalid-perspective-via-referrers-mixed-2.0.json new file mode 100644 index 000000000..2067f2473 --- /dev/null +++ b/tools/src/test/resources/2.0/invalid-perspective-via-referrers-mixed-2.0.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://cyclonedx.org/schema/2.0/cyclonedx-2.0.schema.json", + "specFormat": "CycloneDX", + "specVersion": "2.0", + "version": 1, + "metadata": { + "timestamp": "2026-09-08T12:00:00Z" + }, + "perspectives": [ + { + "bom-ref": "persp-1", + "name": "Step mixing referrers with refs", + "mappings": [ + { + "expression": "$.risks.risks[*]", + "via": [ + { + "referrers": "$.risks.risks[*].affects[*]", + "refs": "$.components[*].modelProperties.training.datasets[*]" + } + ] + } + ] + } + ] +} diff --git a/tools/src/test/resources/2.0/valid-perspective-predefined-2.0.json b/tools/src/test/resources/2.0/valid-perspective-predefined-2.0.json new file mode 100644 index 000000000..34cc61d66 --- /dev/null +++ b/tools/src/test/resources/2.0/valid-perspective-predefined-2.0.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://cyclonedx.org/schema/2.0/cyclonedx-2.0.schema.json", + "specFormat": "CycloneDX", + "specVersion": "2.0", + "serialNumber": "urn:uuid:9d2e4a1b-7c3f-4e8a-b1d6-2f5c8e9a0b3d", + "version": 1, + "metadata": { + "timestamp": "2026-08-29T12:00:00Z" + }, + "perspectives": [ + { + "bom-ref": "perspective-1", + "predefined": "cdx:perspectives:model-card", + "predefinedVersion": 1 + }, + { + "bom-ref": "perspective-2", + "name": "Crypto Inventory", + "description": "An inline perspective fully defined in the document.", + "domains": [ + "cryptographic-security" + ], + "mappings": [ + { + "expression": "$.components[?(@.type=='cryptographic-asset')]", + "nativeName": "Cryptographic Inventory", + "relevance": "required", + "weight": 1.0 + } + ] + } + ] +} diff --git a/tools/src/test/resources/2.0/valid-perspective-referrers-2.0.json b/tools/src/test/resources/2.0/valid-perspective-referrers-2.0.json new file mode 100644 index 000000000..3884d16b4 --- /dev/null +++ b/tools/src/test/resources/2.0/valid-perspective-referrers-2.0.json @@ -0,0 +1,109 @@ +{ + "$schema": "https://cyclonedx.org/schema/2.0/cyclonedx-2.0.schema.json", + "specFormat": "CycloneDX", + "specVersion": "2.0", + "version": 1, + "metadata": { + "timestamp": "2026-09-08T12:00:00Z" + }, + "components": [ + { + "bom-ref": "model-a", + "type": "machine-learning-model", + "name": "model-a", + "version": "1.0" + }, + { + "bom-ref": "model-b", + "type": "machine-learning-model", + "name": "model-b", + "version": "1.0" + } + ], + "risks": { + "risks": [ + { + "bom-ref": "risk-a", + "name": "Fairness risk A", + "statement": "Model A may perform differently across groups.", + "domains": [ + { + "type": "ethical" + } + ], + "affects": [ + "model-a" + ], + "inherentRisk": { + "impact": { + "level": "moderate", + "categories": [ + "fairness" + ] + } + } + }, + { + "bom-ref": "risk-b", + "name": "Fairness risk B", + "statement": "Model B may perform differently across groups.", + "domains": [ + { + "type": "ethical" + } + ], + "affects": [ + "model-b" + ], + "inherentRisk": { + "impact": { + "level": "moderate", + "categories": [ + "fairness" + ] + } + } + } + ] + }, + "perspectives": [ + { + "bom-ref": "card-a", + "predefined": "cdx:perspectives:model-card", + "predefinedVersion": 1, + "scopes": [ + { + "name": "Model A", + "targets": [ + "model-a" + ] + } + ] + }, + { + "bom-ref": "risks-per-model", + "name": "Risks per model", + "description": "An inline perspective selecting, for the scoped model, the risks that name it in their affects.", + "scopes": [ + { + "name": "Model B", + "targets": [ + "model-b" + ] + } + ], + "mappings": [ + { + "expression": "$.risks.risks[*]", + "via": [ + { + "referrers": "$.risks.risks[*].affects[*]" + } + ], + "nativeName": "Risks affecting this model", + "relevance": "required" + } + ] + } + ] +} diff --git a/tools/src/test/resources/2.0/valid-perspective-scopes-2.0.json b/tools/src/test/resources/2.0/valid-perspective-scopes-2.0.json new file mode 100644 index 000000000..79e98a6ad --- /dev/null +++ b/tools/src/test/resources/2.0/valid-perspective-scopes-2.0.json @@ -0,0 +1,170 @@ +{ + "$schema": "https://cyclonedx.org/schema/2.0/cyclonedx-2.0.schema.json", + "specFormat": "CycloneDX", + "specVersion": "2.0", + "serialNumber": "urn:uuid:3c1f7d2e-8a4b-4c9d-9e1f-5b6a7c8d9e0f", + "version": 1, + "metadata": { + "timestamp": "2026-09-04T12:00:00Z" + }, + "components": [ + { + "bom-ref": "model-vision", + "type": "machine-learning-model", + "name": "vision-v3", + "version": "3.0.0" + }, + { + "bom-ref": "model-text-a", + "type": "machine-learning-model", + "group": "text", + "name": "text-a", + "version": "1.2.0" + }, + { + "bom-ref": "model-text-b", + "type": "machine-learning-model", + "group": "text", + "name": "text-b", + "version": "2.0.0" + }, + { + "bom-ref": "ds-imagenet", + "type": "data", + "name": "ImageNet" + }, + { + "bom-ref": "lib-openssl", + "type": "library", + "name": "openssl", + "version": "3.3.0" + }, + { + "bom-ref": "alg-aes-256-gcm", + "type": "cryptographic-asset", + "name": "AES-256-GCM" + } + ], + "dependencies": [ + { + "ref": "lib-openssl", + "provides": [ + "alg-aes-256-gcm" + ] + }, + { + "ref": "model-vision", + "dependsOn": [ + "lib-openssl" + ] + } + ], + "perspectives": [ + { + "bom-ref": "card-vision", + "predefined": "cdx:perspectives:model-card", + "predefinedVersion": 1, + "scopes": [ + { + "bom-ref": "scope-vision", + "name": "Vision model", + "targets": [ + "model-vision" + ] + }, + { + "bom-ref": "scope-text", + "name": "Text models", + "description": "Both text models, evaluated as one card.", + "expressions": [ + "$.components[?(@.type=='machine-learning-model' && @.group=='text')]" + ] + } + ] + }, + { + "bom-ref": "crypto-in-use", + "name": "Cryptography in Use", + "description": "An inline perspective scoped to one component, reaching related data through traversal steps.", + "domains": [ + "cryptographic-security" + ], + "scopes": [ + { + "name": "Vision model stack", + "targets": [ + "model-vision" + ], + "expressions": [ + "$.components[?(@['bom-ref']=='lib-openssl')]" + ] + } + ], + "mappings": [ + { + "expression": "$.components[?(@.type=='cryptographic-asset')]", + "via": [ + { + "dependencies": "depends-on", + "transitive": true + }, + { + "dependencies": "provides" + } + ], + "nativeName": "Algorithms in Use", + "relevance": "required", + "weight": 1.0 + }, + { + "expression": "$.components[?(@.type=='data')]", + "via": [ + { + "refs": "$.components[?(@.type=='machine-learning-model')].modelProperties.training.datasets[*]" + } + ], + "nativeName": "Training Data", + "relevance": "recommended" + } + ] + }, + { + "bom-ref": "crypto-per-model", + "name": "Cryptography in Use", + "description": "An inline perspective applied at two scopes, one view and one completeness evaluation per scope.", + "domains": [ + "cryptographic-security" + ], + "scopes": [ + { + "name": "Vision model", + "targets": [ + "model-vision" + ] + }, + { + "name": "Text models", + "expressions": [ + "$.components[?(@.type=='machine-learning-model' && @.group=='text')]" + ] + } + ], + "mappings": [ + { + "expression": "$.components[?(@.type=='cryptographic-asset')]", + "via": [ + { + "dependencies": "depends-on", + "transitive": true + }, + { + "dependencies": "provides" + } + ], + "nativeName": "Algorithms in Use", + "relevance": "required" + } + ] + } + ] +} From ff08f3b4fe4cfdf7bbc146f77a163bf1e7e5ae5b Mon Sep 17 00:00:00 2001 From: Pavel Shukhman Date: Sat, 10 Oct 2026 10:02:26 -0400 Subject: [PATCH 02/12] fix: evaluation to measurements on model-card Co-authored-by: JunHwanKwon Signed-off-by: Pavel Shukhman --- perspectives/model-card-perspective.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/perspectives/model-card-perspective.json b/perspectives/model-card-perspective.json index 0b5626903..7c7f7c9b0 100644 --- a/perspectives/model-card-perspective.json +++ b/perspectives/model-card-perspective.json @@ -164,7 +164,7 @@ "rationale": "Governance names who is accountable for the data, which regulators and downstream users need to trace provenance and responsibility." }, { - "expression": "$..[?(@.type=='machine-learning-model')].modelProperties.evaluation", + "expression": "$..[?(@.type=='machine-learning-model')].measurements[?(@.type)]", "nativeName": "Quantitative Analysis", "nativeDescription": "Evaluation results: performance metrics, per-slice measurements, confidence intervals, and supporting graphics.", "relevance": "recommended", From 338675b323a3b81b606e304bd9bd6aab3336785a Mon Sep 17 00:00:00 2001 From: Pavel Shukhman Date: Sat, 10 Oct 2026 10:03:55 -0400 Subject: [PATCH 03/12] fix: environmental to measurements change in model card Co-authored-by: JunHwanKwon Signed-off-by: Pavel Shukhman --- perspectives/model-card-perspective.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/perspectives/model-card-perspective.json b/perspectives/model-card-perspective.json index 7c7f7c9b0..1a5402eac 100644 --- a/perspectives/model-card-perspective.json +++ b/perspectives/model-card-perspective.json @@ -235,7 +235,7 @@ "rationale": "Fairness assessments identify demographic or group-level performance disparities; slice-level evaluation metrics provide their quantitative backing. Fairness is selected as a risk domain, mirroring ethical considerations, so the mapping does not depend on how a methodology categorizes impact. Bias is a weakness kind rather than an impact category, reachable from a risk through `relatedWeaknesses`; selecting those weaknesses needs its own mapping, because a traversal step replaces the set of nodes the expression is evaluated against. Risks reference the models they affect through `affects`, so the referrers step selects, for a scoped model, exactly the risks that name it; unscoped, every matching risk is selected." }, { - "expression": "$..[?(@.type=='machine-learning-model')].modelProperties.environmental", + "expression": "$..[?(@.type=='machine-learning-model')].measurements[?(@.activity)]", "nativeName": "Environmental Considerations", "nativeDescription": "Energy consumption and carbon cost of model activities such as training and inference.", "relevance": "recommended", From 95376a14a9c9b771cf1415bf3507ae2dc2908c04 Mon Sep 17 00:00:00 2001 From: Pavel Shukhman Date: Sat, 10 Oct 2026 10:05:50 -0400 Subject: [PATCH 04/12] fix: apply model card changes per updated schema Co-authored-by: JunHwanKwon Signed-off-by: Pavel Shukhman --- perspectives/model-card-perspective.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/perspectives/model-card-perspective.json b/perspectives/model-card-perspective.json index 1a5402eac..577d4fc21 100644 --- a/perspectives/model-card-perspective.json +++ b/perspectives/model-card-perspective.json @@ -172,18 +172,18 @@ "rationale": "Metrics, including slice-level results, substantiate capability claims and surface performance disparities between groups." }, { - "expression": "$..[?(@.type=='machine-learning-model')].modelProperties.useCases", + "expression": "$..[?(@.type=='machine-learning-model')].useCaseAssertions[?(@.assertionType=='implements')].useCaseRefs", "nativeName": "Intended Use", - "nativeDescription": "References to the use cases the model is intended for.", + "nativeDescription": "References to use cases the model explicitly asserts that it implements.", "relevance": "required", "weight": 0.9, - "rationale": "Intended use separates in-scope application from misuse; it is the card section most consulted by adopters and assessors." + "rationale": "This mapping selects only implements assertions, whose relationship identifies the model as providing the use case's primary capability. Other assertion types retain their distinct meanings in the BOM and are not inferred to declare intended use by this mapping." }, { "expression": "$.definitions.useCases[*]", "via": [ { - "refs": "$..[?(@.type=='machine-learning-model')].modelProperties.useCases[*]" + "refs": "$..[?(@.type=='machine-learning-model')].useCaseAssertions[?(@.assertionType=='implements')].useCaseRefs[*]" } ], "nativeName": "Use Case Definitions", From 165ad905f993eac3cdc2545302def33a3b32945d Mon Sep 17 00:00:00 2001 From: Pavel Shukhman Date: Sat, 10 Oct 2026 11:05:17 -0400 Subject: [PATCH 05/12] fix: align model card texts with updated schema and re-register model card v1 Signed-off-by: Pavel Shukhman --- perspectives/model-card-perspective.json | 4 ++-- schema/perspectives-defs.json | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/perspectives/model-card-perspective.json b/perspectives/model-card-perspective.json index 577d4fc21..11f82b1b3 100644 --- a/perspectives/model-card-perspective.json +++ b/perspectives/model-card-perspective.json @@ -10,7 +10,7 @@ { "bom-ref": "perspective-model-card", "name": "Model Card", - "description": "Defines the data elements of a machine learning model card, following the industry-standard model card structure: model details, intended use, training data and procedure, quantitative analysis, technical limitations, and ethical, fairness, and environmental considerations. The mappings project that structure onto the CycloneDX 2.0 decomposition of the former first-class modelCard entity: intrinsic technical characteristics live in modelProperties, which may only appear on components of type machine-learning-model; training datasets are components of type data; intended use cases are use case definitions referenced from the model; and ethical and fairness considerations are entries in the document's risk model. This perspective assumes the AI/ML model properties proposed in CycloneDX/specification#990. Model data is located by descendant search so the perspective applies whether models are declared at the document root, in inventories, or in definitions; related data such as training datasets and use cases is reached through traversal steps from the model's references.", + "description": "Defines the data elements of a machine learning model card, following the industry-standard model card structure: model details, intended use, training data and procedure, quantitative analysis, technical limitations, and ethical, fairness, and environmental considerations. The mappings project that structure onto the CycloneDX 2.0 decomposition of the former first-class modelCard entity: intrinsic technical characteristics live in modelProperties, which may only appear on components of type machine-learning-model; performance metrics and energy consumption are measurements of the model component; training datasets are components of type data; intended use cases are use case definitions that the model asserts it implements; and ethical and fairness considerations are entries in the document's risk model. This perspective assumes the AI/ML model properties proposed in CycloneDX/specification#990. Model data is located by descendant search so the perspective applies whether models are declared at the document root, in inventories, or in definitions; related data such as training datasets and use cases is reached through traversal steps from the model's references.", "domains": [ "machine-learning", "artificial-intelligence", @@ -177,7 +177,7 @@ "nativeDescription": "References to use cases the model explicitly asserts that it implements.", "relevance": "required", "weight": 0.9, - "rationale": "This mapping selects only implements assertions, whose relationship identifies the model as providing the use case's primary capability. Other assertion types retain their distinct meanings in the BOM and are not inferred to declare intended use by this mapping." + "rationale": "Intended use separates in-scope application from misuse; it is the card section most consulted by adopters and assessors. This mapping selects only implements assertions, whose relationship identifies the model as providing the use case's primary capability. Other assertion types retain their distinct meanings in the BOM and are not inferred to declare intended use by this mapping." }, { "expression": "$.definitions.useCases[*]", diff --git a/schema/perspectives-defs.json b/schema/perspectives-defs.json index 67c93c9fb..54f7971e2 100644 --- a/schema/perspectives-defs.json +++ b/schema/perspectives-defs.json @@ -1,18 +1,18 @@ { "$schema": "http://cyclonedx.org/schema/perspectives-defs.schema.json", - "lastUpdated": "2026-09-13T18:18:29Z", + "lastUpdated": "2026-10-10T14:09:56Z", "perspectives": [ { "predefined": "cdx:perspectives:model-card", "file": "perspectives/model-card-perspective.json", "name": "Model Card", - "description": "Defines the data elements of a machine learning model card, following the industry-standard model card structure: model details, intended use, training data and procedure, quantitative analysis, technical limitations, and ethical, fairness, and environmental considerations. The mappings project that structure onto the CycloneDX 2.0 decomposition of the former first-class modelCard entity: intrinsic technical characteristics live in modelProperties, which may only appear on components of type machine-learning-model; training datasets are components of type data; intended use cases are use case definitions referenced from the model; and ethical and fairness considerations are entries in the document's risk model. This perspective assumes the AI/ML model properties proposed in CycloneDX/specification#990. Model data is located by descendant search so the perspective applies whether models are declared at the document root, in inventories, or in definitions; related data such as training datasets and use cases is reached through traversal steps from the model's references.", + "description": "Defines the data elements of a machine learning model card, following the industry-standard model card structure: model details, intended use, training data and procedure, quantitative analysis, technical limitations, and ethical, fairness, and environmental considerations. The mappings project that structure onto the CycloneDX 2.0 decomposition of the former first-class modelCard entity: intrinsic technical characteristics live in modelProperties, which may only appear on components of type machine-learning-model; performance metrics and energy consumption are measurements of the model component; training datasets are components of type data; intended use cases are use case definitions that the model asserts it implements; and ethical and fairness considerations are entries in the document's risk model. This perspective assumes the AI/ML model properties proposed in CycloneDX/specification#990. Model data is located by descendant search so the perspective applies whether models are declared at the document root, in inventories, or in definitions; related data such as training datasets and use cases is reached through traversal steps from the model's references.", "versions": [ { "version": 1, - "sha256": "91ae19a351d746f349da3df4a99dfc04e739679b00e190910b7cf400cb23270a", - "commit": "4772726600b107d57c20ee69551019df32f74a0c", - "date": "2026-09-13T18:18:29Z" + "sha256": "07ea8089a1cc0f0613b6207df6bcdb76d253d69b9ee77d69d65adfb79d45819a", + "commit": "9808804af292a805ca49f56428dd2907e01a02a7", + "date": "2026-10-10T14:09:56Z" } ] } From f753dc0d4546c9ced2df724239880a6c00cf347c Mon Sep 17 00:00:00 2001 From: Pavel Shukhman Date: Sat, 10 Oct 2026 11:06:14 -0400 Subject: [PATCH 06/12] chore: re-register model card v1 Signed-off-by: Pavel Shukhman --- schema/perspectives-defs.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/schema/perspectives-defs.json b/schema/perspectives-defs.json index 54f7971e2..54bd07eaf 100644 --- a/schema/perspectives-defs.json +++ b/schema/perspectives-defs.json @@ -1,6 +1,6 @@ { "$schema": "http://cyclonedx.org/schema/perspectives-defs.schema.json", - "lastUpdated": "2026-10-10T14:09:56Z", + "lastUpdated": "2026-10-10T15:05:17Z", "perspectives": [ { "predefined": "cdx:perspectives:model-card", @@ -11,8 +11,8 @@ { "version": 1, "sha256": "07ea8089a1cc0f0613b6207df6bcdb76d253d69b9ee77d69d65adfb79d45819a", - "commit": "9808804af292a805ca49f56428dd2907e01a02a7", - "date": "2026-10-10T14:09:56Z" + "commit": "165ad905f993eac3cdc2545302def33a3b32945d", + "date": "2026-10-10T15:05:17Z" } ] } From ec39ef214180f65a3d2f9ca9d9f4f34c4842fa2a Mon Sep 17 00:00:00 2001 From: Pavel Shukhman Date: Sat, 10 Oct 2026 11:15:15 -0400 Subject: [PATCH 07/12] chore: sync bundle_2.0_schemas.yml from 2.0-dev Signed-off-by: Pavel Shukhman --- .github/workflows/bundle_2.0_schemas.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/bundle_2.0_schemas.yml b/.github/workflows/bundle_2.0_schemas.yml index 4db3dab11..87a8265b9 100644 --- a/.github/workflows/bundle_2.0_schemas.yml +++ b/.github/workflows/bundle_2.0_schemas.yml @@ -28,7 +28,7 @@ jobs: permissions: contents: write # Required to push changes env: - MODEL_DIR: schema/2.0/model + MODULES_DIR: schema/2.0/modules SOURCE_FILE: schema/2.0/cyclonedx-2.0.schema.json BUNDLED_FILE: schema/2.0/cyclonedx-2.0-bundled.schema.json MINIFIED_FILE: schema/2.0/cyclonedx-2.0-bundled.min.schema.json @@ -50,7 +50,7 @@ jobs: run: | set -eux node tools/src/main/js/bundler/bundle-schemas.js \ - "$MODEL_DIR" "$SOURCE_FILE" + "$MODULES_DIR" "$SOURCE_FILE" - name: Check for changes and commit env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 99ebeafd67ab8077ebd2edc8cd20fddbe2ba8b7d Mon Sep 17 00:00:00 2001 From: Pavel Shukhman Date: Thu, 15 Oct 2026 09:14:58 -0400 Subject: [PATCH 08/12] chore: include predefined perspective script in dependabot logic Signed-off-by: Pavel Shukhman --- .github/dependabot.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b538ee713..265ee0c50 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -72,6 +72,23 @@ updates: all-deps: # we have a lockfile - we want all deps updates in one PR applies-to: 'version-updates' patterns: [ '*' ] + - # tools JS: perspectives registry + package-ecosystem: 'npm' + directory: '/tools/src/main/js/perspectives-registry' + schedule: + interval: 'weekly' + day: 'saturday' + cooldown: + default-days: 7 + labels: [ 'dependencies' ] + commit-message: + prefix: 'chore' ## prefix maximum string length of 15 + include: 'scope' + open-pull-requests-limit: 999 + groups: + all-deps: # we have a lockfile - we want all deps updates in one PR + applies-to: 'version-updates' + patterns: [ '*' ] - # tools JS: linter package-ecosystem: 'npm' directory: '/tools/src/main/js/linter' From 9c8bde6885dec49df1015afb17d3adb50c5de825 Mon Sep 17 00:00:00 2001 From: Pavel Shukhman Date: Thu, 15 Oct 2026 09:15:48 -0400 Subject: [PATCH 09/12] chore: bump perspectives-defs schema to draft 2020-12 Signed-off-by: Pavel Shukhman --- .../2.0/modules/cyclonedx-perspective-2.0.schema.json | 6 +++--- schema/perspectives-defs.schema.json | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/schema/2.0/modules/cyclonedx-perspective-2.0.schema.json b/schema/2.0/modules/cyclonedx-perspective-2.0.schema.json index cbb9f68f5..887968143 100644 --- a/schema/2.0/modules/cyclonedx-perspective-2.0.schema.json +++ b/schema/2.0/modules/cyclonedx-perspective-2.0.schema.json @@ -58,7 +58,7 @@ "predefined": { "title": "Pre-Defined Perspective", "description": "Identifies a well-known, pre-defined perspective, incorporating the published definition by reference and enabling tooling to recognize the perspective without matching on free-text names. Values use the reserved `cdx:perspectives:` namespace path and are drawn from the CycloneDX pre-defined perspectives registry (`perspectives-defs.schema.json`), which is maintained independently of the specification release cycle; the catalog document defining `cdx:perspectives:` is `perspectives/-perspective.json` in the CycloneDX specification repository. The specific published revision is selected by the sibling `predefinedVersion`. A perspective declaring a pre-defined identity shall provide `predefinedVersion` and shall not provide any inline content other than bom-ref and scopes, so the published definition remains the single source of truth; a perspective defining its own content shall omit both fields.", - "$ref": "../../perspectives-defs.schema.json#/definitions/preDefinedPerspectivesEnum" + "$ref": "../../perspectives-defs.schema.json#/$defs/preDefinedPerspectivesEnum" }, "predefinedVersion": { "$ref": "#/$defs/preDefinedPerspectiveVersion" @@ -165,7 +165,7 @@ "expressions": { "type": "array", "title": "Path Expressions", - "description": "[JSONPath](https://datatracker.ietf.org/doc/html/rfc9535) expressions, evaluated from the document root, that identify the nodes whose subtrees form this scope.", + "description": "[JSONPath](https://datatracker.ietf.org/doc/html/rfc9535) expressions, evaluated from the document root, that identify the nodes whose subtrees form this scope. Every expression is evaluated, and the scope includes the nodes selected by each of them.", "minItems": 1, "uniqueItems": true, "items": { @@ -183,7 +183,7 @@ "targets": { "type": "array", "title": "Targets", - "description": "References using bom-link or bom-ref to the objects whose subtrees form this scope, such as a component or, where the document declares inventories, an inventory.", + "description": "References using bom-link or bom-ref to the objects whose subtrees form this scope, such as a component or, where the document declares inventories, an inventory. Every target is resolved, and the scope includes each referenced object, together with the nodes selected by any expressions.", "minItems": 1, "uniqueItems": true, "items": { diff --git a/schema/perspectives-defs.schema.json b/schema/perspectives-defs.schema.json index 62ef1eeb9..4cb329232 100644 --- a/schema/perspectives-defs.schema.json +++ b/schema/perspectives-defs.schema.json @@ -1,5 +1,5 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "http://cyclonedx.org/schema/perspectives-defs.schema.json", "$comment": "2026-09-04T00:00:00Z", "title": "CycloneDX Pre-Defined Perspectives Registry", @@ -24,11 +24,11 @@ "title": "Pre-Defined Perspectives", "description": "The registered pre-defined perspectives, one entry per identity that has a published catalog document. Identities in the enum without an entry are reserved and not published yet.", "items": { - "$ref": "#/definitions/registeredPerspective" + "$ref": "#/$defs/registeredPerspective" } } }, - "definitions": { + "$defs": { "preDefinedPerspectivesEnum": { "type": "string", "title": "Pre-Defined Perspective Identities", @@ -53,7 +53,7 @@ ], "properties": { "predefined": { - "$ref": "#/definitions/preDefinedPerspectivesEnum", + "$ref": "#/$defs/preDefinedPerspectivesEnum", "title": "Pre-Defined Perspective Identity", "description": "The reserved identity of the perspective. Matches the `predefined` field of a perspective in a CycloneDX document." }, @@ -79,7 +79,7 @@ "description": "Every published version of the perspective, in ascending order, contiguous from 1.", "minItems": 1, "items": { - "$ref": "#/definitions/registeredVersion" + "$ref": "#/$defs/registeredVersion" } } } From f1ebfa92eedb68eac54b34f6d20022df93b03862 Mon Sep 17 00:00:00 2001 From: Pavel Shukhman Date: Thu, 15 Oct 2026 09:19:12 -0400 Subject: [PATCH 10/12] fix(perspective-tool): remove main script, remove out of scope tests, lockfile, readme Signed-off-by: Pavel Shukhman --- .../main/js/perspectives-registry/.gitignore | 145 +++++++++++++++ .../main/js/perspectives-registry/README.md | 54 ++++++ .../generate-perspectives-registry.js | 12 +- .../perspectives-registry/package-lock.json | 96 ++++++++++ .../js/perspectives-registry/package.json | 10 +- .../perspectives-registry.js | 6 +- .../main/js/perspectives-registry/test/run.js | 174 ++++++++++++++++++ tools/src/test/js/schema-v2/package.json | 3 +- 8 files changed, 485 insertions(+), 15 deletions(-) create mode 100644 tools/src/main/js/perspectives-registry/.gitignore create mode 100644 tools/src/main/js/perspectives-registry/README.md create mode 100644 tools/src/main/js/perspectives-registry/package-lock.json create mode 100644 tools/src/main/js/perspectives-registry/test/run.js diff --git a/tools/src/main/js/perspectives-registry/.gitignore b/tools/src/main/js/perspectives-registry/.gitignore new file mode 100644 index 000000000..484b6b198 --- /dev/null +++ b/tools/src/main/js/perspectives-registry/.gitignore @@ -0,0 +1,145 @@ +## https://github.com/github/gitignore/blob/main/Node.gitignore + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.* +!.env.example + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist +.output + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp directory +.temp + +# Sveltekit cache directory +.svelte-kit/ + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# Firebase cache directory +.firebase/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# pnpm +.pnpm-store + +# yarn v3 +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Vite files +vite.config.js.timestamp-* +vite.config.ts.timestamp-* +.vite/ \ No newline at end of file diff --git a/tools/src/main/js/perspectives-registry/README.md b/tools/src/main/js/perspectives-registry/README.md new file mode 100644 index 000000000..c8c3983d3 --- /dev/null +++ b/tools/src/main/js/perspectives-registry/README.md @@ -0,0 +1,54 @@ +# Pre-defined perspectives registry + +Generates and checks `schema/perspectives-defs.json`, the registry of +pre-defined CycloneDX perspectives. + +A document can apply a published perspective by identity instead of defining +its mappings inline: + +```json +{ "predefined": "cdx:perspectives:model-card", "predefinedVersion": 1 } +``` + +The registry records which identities exist and which versions of each have +been published. It is maintained separately from the specification release +cycle, so a perspective can be published or revised without a new CycloneDX +version. + +## Files + +| File | Maintained | Content | +|------|------------|---------| +| `schema/perspectives-defs.schema.json` | by hand | The identity enum (`preDefinedPerspectivesEnum`, referenced by the perspective schema) and the schema of the registry data | +| `perspectives/-perspective.json` | by hand | The catalog document defining `cdx:perspectives:`: a CycloneDX document with one inline perspective, whose `version` is the perspective version | +| `schema/perspectives-defs.json` | generated | Every published version of each perspective, with the sha256 of the catalog document's canonical JSON and the commit that introduced it | + +The catalog document of an identity is found by naming convention +(`cdx:perspectives:` → `perspectives/-perspective.json`), not by a +mapping. An identity can be reserved in the enum before its catalog document +exists. + +## Versioning + +A published version is immutable. To change a catalog document, increment its +`version` by 1. The generator registers the new version and never rewrites a +registered one. The check fails when: + +- a catalog document changed but kept a registered version, +- a version goes backwards or skips a number, +- a registered catalog document was removed, +- a registered identity is not in the enum. + +## Usage + +Run from this directory: + +```bash +npm ci +npm test # check the registry against the enum and the catalog documents +npm run generate # register catalog document versions that are not registered yet +``` + +On pushes to the main branches, the `generate_perspectives_registry.yml` +workflow runs the generator and opens a pull request with the updated registry. +Contributors change the enum and the catalog documents, not the registry file. diff --git a/tools/src/main/js/perspectives-registry/generate-perspectives-registry.js b/tools/src/main/js/perspectives-registry/generate-perspectives-registry.js index a5b5dbdc6..05240b4e6 100644 --- a/tools/src/main/js/perspectives-registry/generate-perspectives-registry.js +++ b/tools/src/main/js/perspectives-registry/generate-perspectives-registry.js @@ -16,11 +16,10 @@ * Exit code is the number of errors (0 = success), capped at 254. */ -import {execFile} from 'node:child_process' +import {execFileSync} from 'node:child_process' import {readFile, writeFile} from 'node:fs/promises' import {dirname, join} from 'node:path' import {fileURLToPath} from 'node:url' -import {promisify} from 'node:util' import { REGISTRY_DATA_FILE, REGISTRY_SCHEMA_FILE, State, OK_STATES, @@ -31,14 +30,13 @@ const _thisDir = dirname(fileURLToPath(import.meta.url)) const repoRoot = join(_thisDir, '..', '..', '..', '..', '..') const schemaFile = join(repoRoot, REGISTRY_SCHEMA_FILE) const dataFile = join(repoRoot, REGISTRY_DATA_FILE) -const execFileP = promisify(execFile) /** * @param {string} file path relative to repository root - * @return {Promise<{commit: string, date: string}>} last commit touching the file, date in UTC + * @return {{commit: string, date: string}} last commit touching the file, date in UTC */ -async function lastCommitOf(file) { - const {stdout} = await execFileP('git', ['log', '-1', '--format=%H%n%cI', '--', file], {cwd: repoRoot}) +function lastCommitOf(file) { + const stdout = execFileSync('git', ['log', '-1', '--format=%H%n%cI', '--', file], {cwd: repoRoot, encoding: 'utf-8'}) const [commit, date] = stdout.trim().split('\n') if (!/^[0-9a-f]{40}$/.test(commit ?? '')) { throw new Error(`no commit found for ${file}; the document shall be committed before it is registered`) @@ -94,7 +92,7 @@ for (const identity of [...identities].sort()) { } const versions = [...(entry?.versions ?? [])] if (state === State.NEW || state === State.PENDING) { - const {commit, date} = await lastCommitOf(catalog.file) + const {commit, date} = lastCommitOf(catalog.file) versions.push({version: catalog.version, sha256: catalog.sha256, commit, date}) console.log(` registering version ${catalog.version} from commit ${commit}`) } diff --git a/tools/src/main/js/perspectives-registry/package-lock.json b/tools/src/main/js/perspectives-registry/package-lock.json new file mode 100644 index 000000000..0166598ec --- /dev/null +++ b/tools/src/main/js/perspectives-registry/package-lock.json @@ -0,0 +1,96 @@ +{ + "name": "@cyclonedx/perspectives-registry", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@cyclonedx/perspectives-registry", + "version": "1.0.0", + "license": "Apache-2.0", + "devDependencies": { + "ajv": "^8.20", + "ajv-formats": "^3.0.1" + }, + "engines": { + "node": ">=22.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + } + } +} diff --git a/tools/src/main/js/perspectives-registry/package.json b/tools/src/main/js/perspectives-registry/package.json index 2f930540e..54d2d3bbe 100644 --- a/tools/src/main/js/perspectives-registry/package.json +++ b/tools/src/main/js/perspectives-registry/package.json @@ -4,12 +4,16 @@ "version": "1.0.0", "description": "Generate and check the CycloneDX pre-defined perspectives registry", "type": "module", - "main": "perspectives-registry.js", "engines": { "node": ">=22.0" }, "scripts": { - "generate": "node -- generate-perspectives-registry.js" + "generate": "node -- generate-perspectives-registry.js", + "test": "node -- test/run.js" }, - "license": "Apache-2.0" + "license": "Apache-2.0", + "devDependencies": { + "ajv": "^8.20", + "ajv-formats": "^3.0.1" + } } diff --git a/tools/src/main/js/perspectives-registry/perspectives-registry.js b/tools/src/main/js/perspectives-registry/perspectives-registry.js index fb0e4e4f0..2fabed4ca 100644 --- a/tools/src/main/js/perspectives-registry/perspectives-registry.js +++ b/tools/src/main/js/perspectives-registry/perspectives-registry.js @@ -3,8 +3,8 @@ /** * Shared logic for the CycloneDX pre-defined perspectives registry. * - * Used by the generator (tools/src/main/js/perspectives-registry) and by the - * schema-v2 test suite, so that both hash catalog documents and judge + * Used by the generator (generate-perspectives-registry.js) and by the + * registry check (test/run.js), so that both hash catalog documents and judge * version states the same way. * * Conventions: @@ -30,7 +30,7 @@ export const CATALOG_DIR = 'perspectives' export const CATALOG_FILE_SUFFIX = '-perspective.json' export const REGISTRY_SCHEMA_FILE = join('schema', 'perspectives-defs.schema.json') export const REGISTRY_DATA_FILE = join('schema', 'perspectives-defs.json') -export const ENUM_POINTER = Object.freeze(['definitions', 'preDefinedPerspectivesEnum', 'enum']) +export const ENUM_POINTER = Object.freeze(['$defs', 'preDefinedPerspectivesEnum', 'enum']) /** * Version states of a catalog document relative to the registry. diff --git a/tools/src/main/js/perspectives-registry/test/run.js b/tools/src/main/js/perspectives-registry/test/run.js new file mode 100644 index 000000000..aa873c5d8 --- /dev/null +++ b/tools/src/main/js/perspectives-registry/test/run.js @@ -0,0 +1,174 @@ +"use strict"; + +/** + * Check the pre-defined perspectives registry: `npm test` in this tool's directory. + * + * The registry is not tied to a CycloneDX version: it lives at the schema root + * (schema/perspectives-defs.json, generated) and is governed by + * schema/perspectives-defs.schema.json, whose hand-maintained `preDefinedPerspectivesEnum` + * is referenced by the versioned perspective schema. This test asserts: + * - the registry data validates against its governing schema + * - every registered identity is in the enum, without duplicates on either side; + * entries follow the naming convention and list contiguous versions from 1 + * - for every identity in the enum, the catalog document (perspectives/-perspective.json) + * is in an acceptable state relative to the registry: reserved (no document yet), new + * (version 1, not registered), unchanged (registered content), or pending (registered + * latest + 1). A document changed at an already registered version, a regressed or + * skipped version, or a removed registered document fails. + * Shared logic with the generator lives in ../perspectives-registry.js. + */ + +import {readFile, stat} from 'node:fs/promises' +import {dirname, join} from 'node:path' +import {fileURLToPath} from 'node:url' + +import Ajv2020 from "ajv/dist/2020.js" +import addFormats from 'ajv-formats' + +import { + REGISTRY_DATA_FILE, REGISTRY_SCHEMA_FILE, State, OK_STATES, + assess, entryProblems, identitiesOf, readCatalogDocument, +} from '../perspectives-registry.js' + + +const _thisDir = dirname(fileURLToPath(import.meta.url)) + +// region config + +const repoRootDir = join(_thisDir, '..', '..', '..', '..', '..', '..') +const registrySchemaFile = join(repoRootDir, REGISTRY_SCHEMA_FILE) +const registryDataFile = join(repoRootDir, REGISTRY_DATA_FILE) + +for (const file of [registrySchemaFile, registryDataFile]) { + if (!await stat(file).then(s => s.isFile()).catch(() => false)) { + throw new Error(`missing file: ${file}`); + } +} +console.debug('DEBUG | registrySchemaFile = ', registrySchemaFile); +console.debug('DEBUG | registryDataFile = ', registryDataFile); + +// endregion config + +const [registrySchema, registryData] = await Promise.all([ + readFile(registrySchemaFile, 'utf-8').then(JSON.parse), + readFile(registryDataFile, 'utf-8').then(JSON.parse), +]) + +let errCnt = 0 + +/** + * @param {string} message + * @param {...*} details + */ +function fail(message, ...details) { + ++errCnt + console.error('!!! ERROR:', message, ...details) +} + +// region schema conformance + +console.log('\n> validate registry data against its governing schema ...') +{ + // same strict setup as the schema validation tests + const ajv = new Ajv2020({ + verbose: true, + addUsedSchema: false, + keywords: ["meta:enum"], + strict: true, + strictSchema: true, + strictNumbers: true, + strictTypes: true, + strictTuples: true, + strictRequired: true, + validateFormats: true, + }); + addFormats(ajv) + let validate + try { + validate = ajv.compile(registrySchema) + } catch (err) { + fail('failed compiling registry schema', '\n in file:', `file://${registrySchemaFile}`, '\n error:', String(err)) + } + if (validate !== undefined) { + if (validate(registryData)) { + console.log('OK.') + } else { + fail('registry data does not conform to its governing schema', + '\n for file:', `file://${registryDataFile}`, + '\n error:', validate.errors) + } + } +} + +// endregion schema conformance + +// region registry entries + +console.log('\n> check registry entries against the enum and the naming convention ...') +const identities = identitiesOf(registrySchema) +const entries = Array.isArray(registryData.perspectives) ? registryData.perspectives : [] +const entryById = new Map() +{ + const dupEnum = identities.filter((v, i) => identities.indexOf(v) !== i) + if (dupEnum.length > 0) { + fail('duplicate values in enum', dupEnum, '\n in file:', `file://${registrySchemaFile}`) + } + let entryErrors = 0 + for (const entry of entries) { + if (entryById.has(entry?.predefined)) { + ++entryErrors + fail('duplicate registry entry for', entry.predefined) + continue + } + entryById.set(entry?.predefined, entry) + if (!identities.includes(entry?.predefined)) { + ++entryErrors + fail('registered identity is not in the enum:', entry?.predefined, '\n add it to', `file://${registrySchemaFile}`) + } + const problems = entryProblems(entry) + if (problems.length > 0) { + ++entryErrors + fail(`registry entry ${entry?.predefined}:`, '\n - ' + problems.join('\n - ')) + } + } + if (dupEnum.length === 0 && entryErrors === 0) { + console.log('OK.', entries.length, 'registered of', identities.length, 'identities') + } +} + +// endregion registry entries + +// region catalog documents + +console.log('\n> check catalog documents against the registry ...') +for (const identity of identities) { + let catalog + try { + catalog = await readCatalogDocument(repoRootDir, identity) + } catch (err) { + fail(`${identity}:`, String(err.message)) + continue + } + console.log('\ntest', identity, '->', catalog.file, '...') + if (catalog.problems.length > 0) { + fail(`catalog document of ${identity}:`, '\n file:', `file://${catalog.path}`, '\n - ' + catalog.problems.join('\n - ')) + continue + } + const {state, detail} = assess(catalog, entryById.get(identity)) + if (!OK_STATES.has(state)) { + fail(`${identity} is ${state}:`, detail, '\n file:', `file://${catalog.path}`) + } else if (state === State.RESERVED) { + console.warn(`WARNING: ${identity} is reserved:`, detail) + } else if (state === State.UNCHANGED) { + console.log('OK.', detail) + } else { + console.log(`OK (${state}).`, detail, '- the registry generator will register it after merge') + } +} + +// endregion catalog documents + +console.log('\n\n> found', errCnt, 'errors') +// Exit statuses should be in the range 0 to 254. +// The status 0 is used to terminate the program successfully. +process.exitCode = Math.min(errCnt, 254) diff --git a/tools/src/test/js/schema-v2/package.json b/tools/src/test/js/schema-v2/package.json index bcffdb101..63b0b645f 100644 --- a/tools/src/test/js/schema-v2/package.json +++ b/tools/src/test/js/schema-v2/package.json @@ -19,7 +19,6 @@ "test:v2.0": "run-s \"test:v2.0:*\"", "test:v2.0:t1-json-schema-validate": "node -- json-schema-validate-tests.js -v 2.0", "test:v2.0:t2-json-schema-semantic": "node -- json-schema-semantic-tests.js -v 2.0", - "test:v2.0:t3-json-schema-functional": "node -- json-schema-functional-tests.js -v 2.0", - "test:v2.0:t4-perspectives-registry": "node -- perspectives-registry-tests.js" + "test:v2.0:t3-json-schema-functional": "node -- json-schema-functional-tests.js -v 2.0" } } From f76f392b97615b93e046f8c864c7e58b5880b814 Mon Sep 17 00:00:00 2001 From: Pavel Shukhman Date: Thu, 15 Oct 2026 09:54:22 -0400 Subject: [PATCH 11/12] chore: delete legacy perspectives-registry-tests Signed-off-by: Pavel Shukhman --- .../schema-v2/perspectives-registry-tests.js | 178 ------------------ 1 file changed, 178 deletions(-) delete mode 100644 tools/src/test/js/schema-v2/perspectives-registry-tests.js diff --git a/tools/src/test/js/schema-v2/perspectives-registry-tests.js b/tools/src/test/js/schema-v2/perspectives-registry-tests.js deleted file mode 100644 index 3f957550e..000000000 --- a/tools/src/test/js/schema-v2/perspectives-registry-tests.js +++ /dev/null @@ -1,178 +0,0 @@ -"use strict"; - -/** - * validate the pre-defined perspectives registry. - * call the script via `node -- ` - * - * The registry is not tied to a CycloneDX version: it lives at the schema root - * (schema/perspectives-defs.json, generated) and is governed by - * schema/perspectives-defs.schema.json, whose hand-maintained `preDefinedPerspectivesEnum` - * is referenced by the versioned perspective schema. This test asserts: - * - the registry data validates against its governing schema - * - every registered identity is in the enum, without duplicates on either side; - * entries follow the naming convention and list contiguous versions from 1 - * - for every identity in the enum, the catalog document (perspectives/-perspective.json) - * is in an acceptable state relative to the registry: reserved (no document yet), new - * (version 1, not registered), unchanged (registered content), or pending (registered - * latest + 1). A document changed at an already registered version, a regressed or - * skipped version, or a removed registered document fails. - * Shared logic lives in tools/src/main/js/perspectives-registry/perspectives-registry.js. - */ - -import {readFile, stat} from 'node:fs/promises' -import {dirname, join} from 'node:path' -import {fileURLToPath} from 'node:url' - -import Ajv2020 from "ajv/dist/2020.js" -import draft7MetaSchema from "ajv/dist/refs/json-schema-draft-07.json" with {type: "json"}; -import addFormats from 'ajv-formats' - -import { - REGISTRY_DATA_FILE, REGISTRY_SCHEMA_FILE, State, OK_STATES, - assess, entryProblems, identitiesOf, readCatalogDocument, -} from '../../../main/js/perspectives-registry/perspectives-registry.js' - - -const _thisDir = dirname(fileURLToPath(import.meta.url)) - -// region config - -const repoRootDir = join(_thisDir, '..', '..', '..', '..', '..') -const registrySchemaFile = join(repoRootDir, REGISTRY_SCHEMA_FILE) -const registryDataFile = join(repoRootDir, REGISTRY_DATA_FILE) - -for (const file of [registrySchemaFile, registryDataFile]) { - if (!await stat(file).then(s => s.isFile()).catch(() => false)) { - throw new Error(`missing file: ${file}`); - } -} -console.debug('DEBUG | registrySchemaFile = ', registrySchemaFile); -console.debug('DEBUG | registryDataFile = ', registryDataFile); - -// endregion config - -const [registrySchema, registryData] = await Promise.all([ - readFile(registrySchemaFile, 'utf-8').then(JSON.parse), - readFile(registryDataFile, 'utf-8').then(JSON.parse), -]) - -let errCnt = 0 - -/** - * @param {string} message - * @param {...*} details - */ -function fail(message, ...details) { - ++errCnt - console.error('!!! ERROR:', message, ...details) -} - -// region schema conformance - -console.log('\n> validate registry data against its governing schema ...') -{ - // same strict setup as the schema validation tests - const ajv = new Ajv2020({ - verbose: true, - addUsedSchema: false, - keywords: ["meta:enum"], - strict: true, - strictSchema: true, - strictNumbers: true, - strictTypes: true, - strictTuples: true, - strictRequired: true, - validateFormats: true, - }); - // the registry schema is draft-07 - ajv.addMetaSchema(draft7MetaSchema); - addFormats(ajv) - let validate - try { - validate = ajv.compile(registrySchema) - } catch (err) { - fail('failed compiling registry schema', '\n in file:', `file://${registrySchemaFile}`, '\n error:', String(err)) - } - if (validate !== undefined) { - if (validate(registryData)) { - console.log('OK.') - } else { - fail('registry data does not conform to its governing schema', - '\n for file:', `file://${registryDataFile}`, - '\n error:', validate.errors) - } - } -} - -// endregion schema conformance - -// region registry entries - -console.log('\n> check registry entries against the enum and the naming convention ...') -const identities = identitiesOf(registrySchema) -const entries = Array.isArray(registryData.perspectives) ? registryData.perspectives : [] -const entryById = new Map() -{ - const dupEnum = identities.filter((v, i) => identities.indexOf(v) !== i) - if (dupEnum.length > 0) { - fail('duplicate values in enum', dupEnum, '\n in file:', `file://${registrySchemaFile}`) - } - let entryErrors = 0 - for (const entry of entries) { - if (entryById.has(entry?.predefined)) { - ++entryErrors - fail('duplicate registry entry for', entry.predefined) - continue - } - entryById.set(entry?.predefined, entry) - if (!identities.includes(entry?.predefined)) { - ++entryErrors - fail('registered identity is not in the enum:', entry?.predefined, '\n add it to', `file://${registrySchemaFile}`) - } - const problems = entryProblems(entry) - if (problems.length > 0) { - ++entryErrors - fail(`registry entry ${entry?.predefined}:`, '\n - ' + problems.join('\n - ')) - } - } - if (dupEnum.length === 0 && entryErrors === 0) { - console.log('OK.', entries.length, 'registered of', identities.length, 'identities') - } -} - -// endregion registry entries - -// region catalog documents - -console.log('\n> check catalog documents against the registry ...') -for (const identity of identities) { - let catalog - try { - catalog = await readCatalogDocument(repoRootDir, identity) - } catch (err) { - fail(`${identity}:`, String(err.message)) - continue - } - console.log('\ntest', identity, '->', catalog.file, '...') - if (catalog.problems.length > 0) { - fail(`catalog document of ${identity}:`, '\n file:', `file://${catalog.path}`, '\n - ' + catalog.problems.join('\n - ')) - continue - } - const {state, detail} = assess(catalog, entryById.get(identity)) - if (!OK_STATES.has(state)) { - fail(`${identity} is ${state}:`, detail, '\n file:', `file://${catalog.path}`) - } else if (state === State.RESERVED) { - console.warn(`WARNING: ${identity} is reserved:`, detail) - } else if (state === State.UNCHANGED) { - console.log('OK.', detail) - } else { - console.log(`OK (${state}).`, detail, '- the registry generator will register it after merge') - } -} - -// endregion catalog documents - -console.log('\n\n> found', errCnt, 'errors') -// Exit statuses should be in the range 0 to 254. -// The status 0 is used to terminate the program successfully. -process.exitCode = Math.min(errCnt, 254) From 739a4e523601dcb49cdf20f21c76e1ccfa512467 Mon Sep 17 00:00:00 2001 From: Pavel Shukhman Date: Thu, 15 Oct 2026 09:56:22 -0400 Subject: [PATCH 12/12] feat: keep older predefined perspective versions in archives and remove commits from hashes Signed-off-by: Pavel Shukhman --- .../cyclonedx-perspective-2.0.schema.json | 2 +- schema/perspectives-defs.json | 1 - schema/perspectives-defs.schema.json | 15 +- .../main/js/perspectives-registry/README.md | 59 +++++-- .../generate-perspectives-registry.js | 38 ++--- .../js/perspectives-registry/package.json | 2 +- .../perspectives-registry.js | 133 ++++++++++++++-- .../test/archives.test.js | 149 ++++++++++++++++++ .../main/js/perspectives-registry/test/run.js | 15 +- 9 files changed, 361 insertions(+), 53 deletions(-) create mode 100644 tools/src/main/js/perspectives-registry/test/archives.test.js diff --git a/schema/2.0/modules/cyclonedx-perspective-2.0.schema.json b/schema/2.0/modules/cyclonedx-perspective-2.0.schema.json index 887968143..0d78679d7 100644 --- a/schema/2.0/modules/cyclonedx-perspective-2.0.schema.json +++ b/schema/2.0/modules/cyclonedx-perspective-2.0.schema.json @@ -57,7 +57,7 @@ }, "predefined": { "title": "Pre-Defined Perspective", - "description": "Identifies a well-known, pre-defined perspective, incorporating the published definition by reference and enabling tooling to recognize the perspective without matching on free-text names. Values use the reserved `cdx:perspectives:` namespace path and are drawn from the CycloneDX pre-defined perspectives registry (`perspectives-defs.schema.json`), which is maintained independently of the specification release cycle; the catalog document defining `cdx:perspectives:` is `perspectives/-perspective.json` in the CycloneDX specification repository. The specific published revision is selected by the sibling `predefinedVersion`. A perspective declaring a pre-defined identity shall provide `predefinedVersion` and shall not provide any inline content other than bom-ref and scopes, so the published definition remains the single source of truth; a perspective defining its own content shall omit both fields.", + "description": "Identifies a well-known, pre-defined perspective, incorporating the published definition by reference and enabling tooling to recognize the perspective without matching on free-text names. Values use the reserved `cdx:perspectives:` namespace path and are drawn from the CycloneDX pre-defined perspectives registry (`perspectives-defs.schema.json`), which is maintained independently of the specification release cycle; the catalog document defining `cdx:perspectives:` is `perspectives/-perspective.json` in the CycloneDX specification repository, and earlier revisions are kept as `perspectives/archives/-perspective/-perspective-.json`. The specific published revision is selected by the sibling `predefinedVersion`. A perspective declaring a pre-defined identity shall provide `predefinedVersion` and shall not provide any inline content other than bom-ref and scopes, so the published definition remains the single source of truth; a perspective defining its own content shall omit both fields.", "$ref": "../../perspectives-defs.schema.json#/$defs/preDefinedPerspectivesEnum" }, "predefinedVersion": { diff --git a/schema/perspectives-defs.json b/schema/perspectives-defs.json index 54bd07eaf..0678c863d 100644 --- a/schema/perspectives-defs.json +++ b/schema/perspectives-defs.json @@ -11,7 +11,6 @@ { "version": 1, "sha256": "07ea8089a1cc0f0613b6207df6bcdb76d253d69b9ee77d69d65adfb79d45819a", - "commit": "165ad905f993eac3cdc2545302def33a3b32945d", "date": "2026-10-10T15:05:17Z" } ] diff --git a/schema/perspectives-defs.schema.json b/schema/perspectives-defs.schema.json index 4cb329232..50f6a5c82 100644 --- a/schema/perspectives-defs.schema.json +++ b/schema/perspectives-defs.schema.json @@ -3,7 +3,7 @@ "$id": "http://cyclonedx.org/schema/perspectives-defs.schema.json", "$comment": "2026-09-04T00:00:00Z", "title": "CycloneDX Pre-Defined Perspectives Registry", - "description": "Governs the registry of well-known, pre-defined perspectives (perspectives-defs.json) and declares their reserved identities (preDefinedPerspectivesEnum, referenced by the specification's perspective schema). The identity enum is maintained by hand. The catalog document defining `cdx:perspectives:` is `perspectives/-perspective.json` in the repository, by naming convention; its `version` is the perspective version selected by `predefinedVersion`. The registry data is generated: it records every published version of each perspective with the sha256 of the catalog document's canonical JSON and the commit that introduced it. Once registered, a version's content is immutable; changing a catalog document requires incrementing its `version` by 1. The registry is maintained independently of the CycloneDX specification release cycle.", + "description": "Governs the registry of well-known, pre-defined perspectives (perspectives-defs.json) and declares their reserved identities (preDefinedPerspectivesEnum, referenced by the specification's perspective schema). The identity enum is maintained by hand. The catalog document defining `cdx:perspectives:` is `perspectives/-perspective.json` in the repository, by naming convention, and holds the latest version; its `version` is the perspective version selected by `predefinedVersion`. Every earlier version is kept, unchanged, as `perspectives/archives/-perspective/-perspective-.json`. The registry data is generated: it records every published version of each perspective with the sha256 of the catalog document's canonical JSON and the date it was registered. Once registered, a version's content is immutable; changing a catalog document requires incrementing its `version` by 1 and archiving the previous version. The registry is maintained independently of the CycloneDX specification release cycle.", "type": "object", "additionalProperties": false, "required": [ @@ -60,7 +60,7 @@ "file": { "type": "string", "title": "Catalog Document", - "description": "Path, relative to the repository root, of the catalog document defining this perspective, as given by the naming convention.", + "description": "Path, relative to the repository root, of the catalog document defining this perspective at its latest version, as given by the naming convention. Earlier versions are archived under `perspectives/archives/`, also by naming convention.", "pattern": "^perspectives/[a-z0-9]+(-[a-z0-9]+)*-perspective\\.json$" }, "name": { @@ -91,7 +91,6 @@ "required": [ "version", "sha256", - "commit", "date" ], "properties": { @@ -104,20 +103,14 @@ "sha256": { "type": "string", "title": "Content Hash", - "description": "Lowercase hex SHA-256 of the catalog document's canonical JSON (object keys sorted, no insignificant whitespace) at this version. A catalog document whose hash differs from the one registered for its `version` has changed without a version bump.", + "description": "Lowercase hex SHA-256 of the catalog document's canonical JSON (object keys sorted, no insignificant whitespace) at this version. A catalog document, current or archived, whose hash differs from the one registered for its `version` has changed after publication.", "pattern": "^[0-9a-f]{64}$" }, - "commit": { - "type": "string", - "title": "Commit", - "description": "The commit that introduced this version of the catalog document, for provenance.", - "pattern": "^[0-9a-f]{40}$" - }, "date": { "type": "string", "format": "date-time", "title": "Date", - "description": "The date and time (timestamp) of that commit." + "description": "The date and time (timestamp) this version was registered." } } } diff --git a/tools/src/main/js/perspectives-registry/README.md b/tools/src/main/js/perspectives-registry/README.md index c8c3983d3..163d4834f 100644 --- a/tools/src/main/js/perspectives-registry/README.md +++ b/tools/src/main/js/perspectives-registry/README.md @@ -20,32 +20,71 @@ version. | File | Maintained | Content | |------|------------|---------| | `schema/perspectives-defs.schema.json` | by hand | The identity enum (`preDefinedPerspectivesEnum`, referenced by the perspective schema) and the schema of the registry data | -| `perspectives/-perspective.json` | by hand | The catalog document defining `cdx:perspectives:`: a CycloneDX document with one inline perspective, whose `version` is the perspective version | -| `schema/perspectives-defs.json` | generated | Every published version of each perspective, with the sha256 of the catalog document's canonical JSON and the commit that introduced it | +| `perspectives/-perspective.json` | by hand | The catalog document defining `cdx:perspectives:` at its latest version: a CycloneDX document with one inline perspective, whose `version` is the perspective version | +| `perspectives/archives/-perspective/-perspective-.json` | by hand, copied once | Each earlier version of that catalog document, unchanged | +| `schema/perspectives-defs.json` | generated | Every published version of each perspective, with the sha256 of the catalog document's canonical JSON and the date it was registered | -The catalog document of an identity is found by naming convention -(`cdx:perspectives:` → `perspectives/-perspective.json`), not by a -mapping. An identity can be reserved in the enum before its catalog document -exists. +## Naming convention + +Files are found by naming convention, not by a mapping. For +`cdx:perspectives:`: + +- the latest version is `perspectives/-perspective.json`; +- earlier versions are in the directory `perspectives/archives/-perspective/`, + named after the catalog document without `.json`; each file is that name + followed by `-.json`. + +For example, `cdx:perspectives:model-card`: + +```text +perspectives/ + model-card-perspective.json latest version, e.g. 3 + archives/ + model-card-perspective/ + model-card-perspective-1.json + model-card-perspective-2.json +``` + +`` is lowercase kebab-case. An identity can be reserved in the enum +before its catalog document exists. The archives directory holds only earlier +versions of identities in the enum. ## Versioning -A published version is immutable. To change a catalog document, increment its -`version` by 1. The generator registers the new version and never rewrites a -registered one. The check fails when: +A published version is immutable. Its content is identified by the sha256 of +its canonical JSON (object keys sorted, no insignificant whitespace), so +reformatting a file does not change it, but any change to its content does, +including its `metadata`. The registration date is recorded in the registry, +not in the catalog document, and is not part of the hash. + +To publish a new version of a perspective: + +1. Copy the current catalog document, unchanged, to + `perspectives/archives/-perspective/-perspective-.json`, + where `` is its current `version`. +2. Edit `perspectives/-perspective.json` and increment its `version` by 1. + +Pull requests then show the revision as a diff of the catalog document. After +merge, the generator registers the new version and never rewrites a registered +one. The check fails when: - a catalog document changed but kept a registered version, - a version goes backwards or skips a number, +- a registered earlier version is not archived, or its archived copy changed, +- the archives directory holds anything other than earlier versions, - a registered catalog document was removed, - a registered identity is not in the enum. +The registry and the files under `perspectives/` are all a consumer needs to +read any published version; nothing depends on the history of the repository. + ## Usage Run from this directory: ```bash npm ci -npm test # check the registry against the enum and the catalog documents +npm test # unit tests, then check the registry against the enum and the catalog documents npm run generate # register catalog document versions that are not registered yet ``` diff --git a/tools/src/main/js/perspectives-registry/generate-perspectives-registry.js b/tools/src/main/js/perspectives-registry/generate-perspectives-registry.js index 05240b4e6..57be31ca2 100644 --- a/tools/src/main/js/perspectives-registry/generate-perspectives-registry.js +++ b/tools/src/main/js/perspectives-registry/generate-perspectives-registry.js @@ -8,22 +8,22 @@ * Sources: the identity enum in schema/perspectives-defs.schema.json (hand-maintained) * and the catalog documents under perspectives/ (hand-maintained, naming convention). * Registers every catalog document version that is not registered yet, recording the - * sha256 of the document's canonical JSON and the commit that last touched the document. + * sha256 of the document's canonical JSON and the date of registration. * Never rewrites a registered version: a document whose content changed at an already * registered version is an error, and so is any version that is not exactly the - * registered latest + 1 (or 1 for a new perspective). + * registered latest + 1 (or 1 for a new perspective), and any earlier version that is + * not archived unchanged under perspectives/archives/. * * Exit code is the number of errors (0 = success), capped at 254. */ -import {execFileSync} from 'node:child_process' import {readFile, writeFile} from 'node:fs/promises' import {dirname, join} from 'node:path' import {fileURLToPath} from 'node:url' import { REGISTRY_DATA_FILE, REGISTRY_SCHEMA_FILE, State, OK_STATES, - assess, identitiesOf, readCatalogDocument, + archiveProblems, assess, identitiesOf, readCatalogDocument, strayArchiveProblems, } from './perspectives-registry.js' const _thisDir = dirname(fileURLToPath(import.meta.url)) @@ -31,18 +31,8 @@ const repoRoot = join(_thisDir, '..', '..', '..', '..', '..') const schemaFile = join(repoRoot, REGISTRY_SCHEMA_FILE) const dataFile = join(repoRoot, REGISTRY_DATA_FILE) -/** - * @param {string} file path relative to repository root - * @return {{commit: string, date: string}} last commit touching the file, date in UTC - */ -function lastCommitOf(file) { - const stdout = execFileSync('git', ['log', '-1', '--format=%H%n%cI', '--', file], {cwd: repoRoot, encoding: 'utf-8'}) - const [commit, date] = stdout.trim().split('\n') - if (!/^[0-9a-f]{40}$/.test(commit ?? '')) { - throw new Error(`no commit found for ${file}; the document shall be committed before it is registered`) - } - return {commit, date: new Date(date).toISOString().replace(/\.\d{3}Z$/, 'Z')} -} +/** registration date: now, in UTC, to the second */ +const registrationDate = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z') const schema = JSON.parse(await readFile(schemaFile, 'utf-8')) const identities = identitiesOf(schema) @@ -61,6 +51,10 @@ for (const [id] of previousEntries) { console.error(`!!! ERROR: registered identity ${id} is not in the enum of ${REGISTRY_SCHEMA_FILE}; registered versions are never dropped`) } } +for (const problem of await strayArchiveProblems(repoRoot, identities)) { + ++errCnt + console.error(`!!! ERROR: ${problem}`) +} for (const identity of [...identities].sort()) { const entry = previousEntries.get(identity) @@ -90,11 +84,17 @@ for (const identity of [...identities].sort()) { if (state === State.RESERVED) { continue } + const archived = await archiveProblems(repoRoot, catalog, entry) + if (archived.length > 0) { + ++errCnt + console.error(`!!! ERROR: ${identity}: archived versions\n - ${archived.join('\n - ')}`) + if (entry !== undefined) entries.push(entry) + continue + } const versions = [...(entry?.versions ?? [])] if (state === State.NEW || state === State.PENDING) { - const {commit, date} = lastCommitOf(catalog.file) - versions.push({version: catalog.version, sha256: catalog.sha256, commit, date}) - console.log(` registering version ${catalog.version} from commit ${commit}`) + versions.push({version: catalog.version, sha256: catalog.sha256, date: registrationDate}) + console.log(` registering version ${catalog.version}`) } entries.push({ predefined: identity, diff --git a/tools/src/main/js/perspectives-registry/package.json b/tools/src/main/js/perspectives-registry/package.json index 54d2d3bbe..3a393ce02 100644 --- a/tools/src/main/js/perspectives-registry/package.json +++ b/tools/src/main/js/perspectives-registry/package.json @@ -9,7 +9,7 @@ }, "scripts": { "generate": "node -- generate-perspectives-registry.js", - "test": "node -- test/run.js" + "test": "node --test test/archives.test.js && node -- test/run.js" }, "license": "Apache-2.0", "devDependencies": { diff --git a/tools/src/main/js/perspectives-registry/perspectives-registry.js b/tools/src/main/js/perspectives-registry/perspectives-registry.js index 2fabed4ca..5671fe8e9 100644 --- a/tools/src/main/js/perspectives-registry/perspectives-registry.js +++ b/tools/src/main/js/perspectives-registry/perspectives-registry.js @@ -11,23 +11,28 @@ * - identities are `cdx:perspectives:` (the enum in * schema/perspectives-defs.schema.json is the hand-maintained source) * - the catalog document defining `cdx:perspectives:` is - * `perspectives/-perspective.json` (naming convention, no mapping) + * `perspectives/-perspective.json` (naming convention, no mapping); + * it holds the latest version + * - every earlier version is kept, unchanged, as + * `perspectives/archives/-perspective/-perspective-.json` * - a catalog document is a CycloneDX document defining exactly one inline * perspective; its `version` is the perspective version referenced by * `predefinedVersion` * - the registry (schema/perspectives-defs.json) is GENERATED and records, * per identity, every published version with the sha256 of the catalog - * document's canonical JSON and the commit that introduced it; once - * registered, a version's content is immutable + * document's canonical JSON and the date it was registered; once + * registered, a version's content is immutable, which the recorded hash + * enforces for the current document and for every archived one */ import {createHash} from 'node:crypto' -import {readFile, stat} from 'node:fs/promises' +import {readdir, readFile, stat} from 'node:fs/promises' import {join} from 'node:path' export const IDENTITY_PREFIX = 'cdx:perspectives:' export const CATALOG_DIR = 'perspectives' export const CATALOG_FILE_SUFFIX = '-perspective.json' +export const ARCHIVES_DIR = `${CATALOG_DIR}/archives` export const REGISTRY_SCHEMA_FILE = join('schema', 'perspectives-defs.schema.json') export const REGISTRY_DATA_FILE = join('schema', 'perspectives-defs.json') export const ENUM_POINTER = Object.freeze(['$defs', 'preDefinedPerspectivesEnum', 'enum']) @@ -61,9 +66,9 @@ export const OK_STATES = Object.freeze(new Set([State.RESERVED, State.NEW, State /** * @param {string} identity - * @return {string} catalog document path relative to the repository root, using `/` separators + * @return {string} the base name of the identity's catalog files, `-perspective` */ -export function catalogFileOf(identity) { +function baseNameOf(identity) { if (typeof identity !== 'string' || !identity.startsWith(IDENTITY_PREFIX)) { throw new Error(`not a pre-defined perspective identity: ${identity}`) } @@ -71,7 +76,32 @@ export function catalogFileOf(identity) { if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) { throw new Error(`identity name is not lowercase-kebab-case: ${identity}`) } - return `${CATALOG_DIR}/${name}${CATALOG_FILE_SUFFIX}` + return `${name}${CATALOG_FILE_SUFFIX.replace(/\.json$/, '')}` +} + +/** + * @param {string} identity + * @return {string} catalog document path relative to the repository root, using `/` separators + */ +export function catalogFileOf(identity) { + return `${CATALOG_DIR}/${baseNameOf(identity)}.json` +} + +/** + * @param {string} identity + * @return {string} directory holding the identity's earlier versions, relative to the repository root + */ +export function archiveDirOf(identity) { + return `${ARCHIVES_DIR}/${baseNameOf(identity)}` +} + +/** + * @param {string} identity + * @param {number} version + * @return {string} path of the archived catalog document of that version, relative to the repository root + */ +export function archiveFileOf(identity, version) { + return `${archiveDirOf(identity)}/${baseNameOf(identity)}-${version}.json` } /** @@ -130,7 +160,17 @@ export function identitiesOf(schema) { * @return {Promise} */ export async function readCatalogDocument(repoRoot, identity) { - const file = catalogFileOf(identity) + return readDocument(repoRoot, identity, catalogFileOf(identity)) +} + +/** + * Read and structurally check a catalog document file. + * @param {string} repoRoot + * @param {string} identity + * @param {string} file path relative to repository root + * @return {Promise} + */ +async function readDocument(repoRoot, identity, file) { const path = join(repoRoot, ...file.split('/')) const result = {identity, file, path, exists: false, problems: []} if (!await stat(path).then(s => s.isFile()).catch(() => false)) { @@ -195,7 +235,7 @@ export function assess(catalog, entry) { const registered = entry.versions.find(v => v.version === latest) return registered.sha256 === sha256 ? {state: State.UNCHANGED, latest, detail: `version ${version}, content matches the registry`} - : {state: State.MODIFIED, latest, detail: `content differs from registered version ${latest} (${registered.commit}); bump \`version\` to ${latest + 1}`} + : {state: State.MODIFIED, latest, detail: `content differs from registered version ${latest}; bump \`version\` to ${latest + 1} and archive version ${latest} as ${archiveFileOf(catalog.identity, latest)}`} } if (version === latest + 1) { return {state: State.PENDING, latest, detail: `version ${version} bumped from registered ${latest}, not registered yet`} @@ -231,3 +271,78 @@ export function entryProblems(entry) { } return problems } + +/** + * Check the archived earlier versions of an identity against the registry. + * + * Every registered version below the current catalog document's version shall + * be archived at its conventional path, unchanged: its content hash equals the + * registered one, and its own `version` matches. The archive directory holds + * nothing else. + * + * @param {string} repoRoot + * @param {CatalogDocument} catalog the identity's current catalog document + * @param {object|undefined} entry registry entry of the identity + * @return {Promise} problems, empty when the archive is consistent + */ +export async function archiveProblems(repoRoot, catalog, entry) { + const identity = catalog.identity + const registered = Array.isArray(entry?.versions) ? entry.versions : [] + const below = catalog.version ?? latestOf(registered) + const expected = new Map(registered + .filter(v => v.version < below) + .map(v => [archiveFileOf(identity, v.version), v])) + const problems = [] + for (const [file, v] of expected) { + const archived = await readDocument(repoRoot, identity, file) + if (!archived.exists) { + problems.push(`registered version ${v.version} is not archived: copy it, unchanged, to ${file}`) + } else if (archived.problems.length > 0) { + problems.push(`${file}: ${archived.problems.join('; ')}`) + } else if (archived.version !== v.version) { + problems.push(`${file} declares \`version\` ${archived.version}, its file name says ${v.version}`) + } else if (archived.sha256 !== v.sha256) { + problems.push(`${file} differs from registered version ${v.version}; archived versions shall not change`) + } + } + const dir = archiveDirOf(identity) + for (const name of await listDir(repoRoot, dir)) { + if (!expected.has(`${dir}/${name}`)) { + problems.push(`${dir}/${name} is not an archived earlier version of ${identity}`) + } + } + return problems +} + +/** + * @param {string} repoRoot + * @param {string[]} identities the identity enum + * @return {Promise} problems for entries of the archives directory that belong to no identity + */ +export async function strayArchiveProblems(repoRoot, identities) { + const known = new Set() + for (const identity of identities) { + try { + known.add(archiveDirOf(identity)) + } catch { + // an invalid identity is reported elsewhere + } + } + return (await listDir(repoRoot, ARCHIVES_DIR)) + .filter(name => !known.has(`${ARCHIVES_DIR}/${name}`)) + .map(name => `${ARCHIVES_DIR}/${name} does not belong to any identity in the enum`) +} + +/** + * @param {string} repoRoot + * @param {string} dir path relative to repository root + * @return {Promise} entry names, sorted; empty when the directory does not exist + */ +async function listDir(repoRoot, dir) { + try { + return (await readdir(join(repoRoot, ...dir.split('/')))).sort() + } catch (err) { + if (err.code === 'ENOENT') return [] + throw err + } +} diff --git a/tools/src/main/js/perspectives-registry/test/archives.test.js b/tools/src/main/js/perspectives-registry/test/archives.test.js new file mode 100644 index 000000000..27dd59e03 --- /dev/null +++ b/tools/src/main/js/perspectives-registry/test/archives.test.js @@ -0,0 +1,149 @@ +"use strict"; + +/** + * Unit tests for the archive of earlier perspective versions. + * Each test builds a small repository layout in a temporary directory. + * Run via `npm test` in this tool's directory. + */ + +import assert from 'node:assert/strict' +import {mkdir, mkdtemp, rm, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {dirname, join} from 'node:path' +import {afterEach, beforeEach, test} from 'node:test' + +import { + State, archiveFileOf, archiveProblems, assess, catalogFileOf, readCatalogDocument, sha256Of, strayArchiveProblems, +} from '../perspectives-registry.js' + +const ID = 'cdx:perspectives:example' + +/** + * @param {number} version + * @param {string} [name] + * @return {object} a minimal catalog document + */ +function catalog(version, name = `Example v${version}`) { + return { + specFormat: 'CycloneDX', + specVersion: '2.0', + version, + perspectives: [{name, mappings: [{expression: '$.components[*]'}]}], + } +} + +/** + * @param {...object} docs registered catalog documents, version 1 first + * @return {object} registry entry + */ +function entryOf(...docs) { + return { + predefined: ID, + file: catalogFileOf(ID), + versions: docs.map(d => ({version: d.version, sha256: sha256Of(d), date: '2026-10-15T00:00:00Z'})), + } +} + +let root + +/** + * @param {string} file path relative to the repository root + * @param {object|string} content JSON value, or raw text + */ +async function put(file, content) { + const path = join(root, ...file.split('/')) + await mkdir(dirname(path), {recursive: true}) + await writeFile(path, typeof content === 'string' ? content : JSON.stringify(content, null, 2)) +} + +async function check(entry) { + const current = await readCatalogDocument(root, ID) + return {state: assess(current, entry).state, problems: await archiveProblems(root, current, entry)} +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'perspectives-registry-')) +}) + +afterEach(async () => { + await rm(root, {recursive: true, force: true}) +}) + +test('first version needs no archive', async () => { + const v1 = catalog(1) + await put(catalogFileOf(ID), v1) + assert.deepEqual(await check(entryOf(v1)), {state: State.UNCHANGED, problems: []}) +}) + +test('new version with the previous one archived is pending', async () => { + const v1 = catalog(1) + await put(catalogFileOf(ID), catalog(2)) + await put(archiveFileOf(ID, 1), v1) + assert.deepEqual(await check(entryOf(v1)), {state: State.PENDING, problems: []}) +}) + +test('registered latest with all earlier versions archived is unchanged', async () => { + const [v1, v2, v3] = [catalog(1), catalog(2), catalog(3)] + await put(catalogFileOf(ID), v3) + await put(archiveFileOf(ID, 1), v1) + await put(archiveFileOf(ID, 2), v2) + assert.deepEqual(await check(entryOf(v1, v2, v3)), {state: State.UNCHANGED, problems: []}) +}) + +test('new version without the previous one archived fails', async () => { + await put(catalogFileOf(ID), catalog(2)) + const {problems} = await check(entryOf(catalog(1))) + assert.equal(problems.length, 1) + assert.match(problems[0], /registered version 1 is not archived/) + assert.match(problems[0], /perspectives\/archives\/example-perspective\/example-perspective-1\.json/) +}) + +test('an archived version that changed fails', async () => { + await put(catalogFileOf(ID), catalog(2)) + await put(archiveFileOf(ID, 1), catalog(1, 'edited after publication')) + const {problems} = await check(entryOf(catalog(1))) + assert.equal(problems.length, 1) + assert.match(problems[0], /differs from registered version 1/) +}) + +test('formatting of an archived version does not matter', async () => { + const v1 = catalog(1) + await put(catalogFileOf(ID), catalog(2)) + await put(archiveFileOf(ID, 1), JSON.stringify({perspectives: v1.perspectives, version: 1, specVersion: '2.0', specFormat: 'CycloneDX'})) + assert.deepEqual((await check(entryOf(v1))).problems, []) +}) + +test('an archived file whose version does not match its name fails', async () => { + await put(catalogFileOf(ID), catalog(3)) + await put(archiveFileOf(ID, 1), catalog(2)) + await put(archiveFileOf(ID, 2), catalog(2)) + const {problems} = await check(entryOf(catalog(1), catalog(2))) + assert.equal(problems.length, 1) + assert.match(problems[0], /declares `version` 2, its file name says 1/) +}) + +test('files in the archive directory other than earlier versions fail', async () => { + const v1 = catalog(1) + await put(catalogFileOf(ID), v1) + await put(archiveFileOf(ID, 1), v1) + await put('perspectives/archives/example-perspective/notes.txt', 'not a version') + const {problems} = await check(entryOf(v1)) + assert.equal(problems.length, 2) + assert.ok(problems.every(p => /is not an archived earlier version/.test(p))) +}) + +test('a change at a registered version names the archive to create', async () => { + await put(catalogFileOf(ID), catalog(1, 'edited')) + const current = await readCatalogDocument(root, ID) + const {state, detail} = assess(current, entryOf(catalog(1))) + assert.equal(state, State.MODIFIED) + assert.match(detail, /bump `version` to 2 and archive version 1 as perspectives\/archives\/example-perspective\/example-perspective-1\.json/) +}) + +test('archive directories that belong to no identity fail', async () => { + await put('perspectives/archives/example-perspective/example-perspective-1.json', catalog(1)) + await put('perspectives/archives/unknown-perspective/unknown-perspective-1.json', catalog(1)) + assert.deepEqual(await strayArchiveProblems(root, [ID]), [ + 'perspectives/archives/unknown-perspective does not belong to any identity in the enum', + ]) +}) diff --git a/tools/src/main/js/perspectives-registry/test/run.js b/tools/src/main/js/perspectives-registry/test/run.js index aa873c5d8..378c599ca 100644 --- a/tools/src/main/js/perspectives-registry/test/run.js +++ b/tools/src/main/js/perspectives-registry/test/run.js @@ -15,6 +15,9 @@ * (version 1, not registered), unchanged (registered content), or pending (registered * latest + 1). A document changed at an already registered version, a regressed or * skipped version, or a removed registered document fails. + * - every registered version below the current document's is archived, unchanged, at + * perspectives/archives/-perspective/-perspective-.json, and the + * archives directory holds nothing else. * Shared logic with the generator lives in ../perspectives-registry.js. */ @@ -27,7 +30,7 @@ import addFormats from 'ajv-formats' import { REGISTRY_DATA_FILE, REGISTRY_SCHEMA_FILE, State, OK_STATES, - assess, entryProblems, identitiesOf, readCatalogDocument, + archiveProblems, assess, entryProblems, identitiesOf, readCatalogDocument, strayArchiveProblems, } from '../perspectives-registry.js' @@ -155,6 +158,12 @@ for (const identity of identities) { continue } const {state, detail} = assess(catalog, entryById.get(identity)) + if (state !== State.RESERVED) { + const archived = await archiveProblems(repoRootDir, catalog, entryById.get(identity)) + if (archived.length > 0) { + fail(`archived versions of ${identity}:`, '\n - ' + archived.join('\n - ')) + } + } if (!OK_STATES.has(state)) { fail(`${identity} is ${state}:`, detail, '\n file:', `file://${catalog.path}`) } else if (state === State.RESERVED) { @@ -166,6 +175,10 @@ for (const identity of identities) { } } +for (const problem of await strayArchiveProblems(repoRootDir, identities)) { + fail(problem) +} + // endregion catalog documents console.log('\n\n> found', errCnt, 'errors')