diff --git a/.changeset/schema-validator-cyclonedx-import.md b/.changeset/schema-validator-cyclonedx-import.md
new file mode 100644
index 0000000..59bc1c3
--- /dev/null
+++ b/.changeset/schema-validator-cyclonedx-import.md
@@ -0,0 +1,12 @@
+---
+"@euconform/core": minor
+"@euconform/cli": minor
+---
+
+Add schema validation, CycloneDX SBOM import, and AI BOM v1.1 schema revision
+
+- Introduce `euconform.aibom.v1.1` schema with optional import provenance metadata
+- Add `validate` command for checking EuConform JSON documents against published schemas
+- Add `import` command for mapping CycloneDX JSON SBOMs into the EuConform AI BOM layer
+- Add PURL parser with graceful handling of malformed percent-encoding
+- Remove `validate` and `importCycloneDx` from core barrel export (available via `@euconform/core/validation` and `@euconform/core/sbom` subpath exports)
diff --git a/README.md b/README.md
index 764c320..47e1eb0 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,12 @@
EuConform
- ๐ช๐บ Open-Source EU AI Act Compliance Tool
+ ๐ช๐บ Open-Source Evidence Toolkit For AI Compliance
- Classify risk levels โข Detect algorithmic bias โข Generate compliance reports
- 100% offline โข GDPR-by-design โข WCAG 2.2 AA accessible
+ Open evidence format โข Local bias evaluation โข Schema validation โข CycloneDX interoperability
+ Offline-first โข Privacy-preserving โข Reusable artifacts โข WCAG 2.2 AA accessible
@@ -34,6 +34,10 @@
+
+ EuConform defines an open evidence format for AI compliance and provides the tools to produce, validate, and empirically evaluate it โ offline and vendor-independent.
+
+
---
> [!IMPORTANT]
@@ -59,17 +63,29 @@
| Feature | Description |
|---------|-------------|
-| ๐ฏ **Risk Classification** | Interactive quiz implementing EU AI Act Article 5 (prohibited), Article 6 + Annex III (high-risk) |
-| ๐ **Bias Detection** | CrowS-Pairs methodology with log-probability analysis for scientific bias measurement |
-| ๐ **PDF Reports** | Generate Annex IV-compliant technical documentation entirely in-browser |
+| ๐งพ **Open Evidence Format** | Produce portable `report`, `aibom`, `ci`, and `bundle` artifacts as inspectable JSON documents |
+| ๐งช **Local Bias Evaluation** | CrowS-Pairs-based model evaluation with log-probability and latency fallback โ reproducible, offline, no vendor dependency |
+| โ
**Schema Validation** | Validate EuConform JSON documents against the published schemas with `euconform validate` |
+| ๐ฆ **Bundle Verification** | Verify manifest, directory, or ZIP bundle integrity before handing artifacts to CI, reviewers, or auditors |
| ๐ฆ **Compliance CI Gate** | Turn `euconform scan` into GitHub-native annotations, CI summaries, and machine-readable artifacts |
-| ๐ **100% Offline** | All processing happens client-side using transformers.js (WebGPU) |
-| ๐ **Privacy-First** | Zero tracking, no cookies, no external fonts โ your data never leaves your browser |
-| ๐ค **Custom Test Suites** | Upload your own CSV/JSON test cases for domain-specific bias evaluation |
+| ๐ฏ **Risk Classification** | Interactive quiz implementing EU AI Act Article 5 (prohibited), Article 6 + Annex III (high-risk) |
+| ๐ **CycloneDX Interoperability** | Import external CycloneDX SBOMs into the EuConform AI BOM layer as an interoperability bridge |
+| ๐ **Offline-First** | Core evidence workflows stay local and inspectable instead of depending on vendor dashboards |
+| ๐ **Privacy-Preserving** | Zero tracking, no cookies, no external fonts โ your data stays under your control |
| ๐ **Dark Mode** | Beautiful glassmorphism design with full dark mode support |
| โฟ **Accessible** | WCAG 2.2 AA compliant with full keyboard navigation |
| ๐ **Multilingual** | English and German interface |
+## ๐งฐ CLI At A Glance
+
+| Command | Primary output | Use case |
+|---------|----------------|----------|
+| `scan` | Native EuConform artifacts | Generate structured evidence from a real repository |
+| `bias` | Bias report JSON and/or Markdown | Run reproducible local model evaluation with Ollama โ EuConform's distinctive empirical layer |
+| `validate` | Valid/invalid status per JSON file | Check EuConform JSON files against published schemas |
+| `verify` | Bundle integrity status | Check a manifest, extracted bundle, or ZIP archive |
+| `import` | `euconform.aibom.json` | Map an external CycloneDX SBOM into the EuConform AI BOM layer |
+
## ๐ Quick Start
> **Want to try it without installation?** Click the [**๐ Deploy**](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FHiepler%2FEuConform&project-name=euconform&repository-name=euconform) link above to start your own instance on Vercel.
@@ -97,15 +113,20 @@ pnpm dev
# Open http://localhost:3001
```
-### CLI Scanner
+### Build The CLI
-The local scanner turns EuConform into a reproducible evidence tool for real repositories:
+The repo-local examples below use the built CLI directly:
```bash
# Build the CLI
pnpm --filter @euconform/cli build
+```
+
+### Workflow 1: Scan A Repository
+
+Generate native EuConform artifacts from a real codebase:
-# Scan the current project
+```bash
node packages/cli/dist/index.js scan . --scope production
```
@@ -115,6 +136,11 @@ This writes:
- `.euconform/euconform.summary.md`
- `.euconform/euconform.bundle.json`
+Typical use:
+- evidence collection for local OSS or internal AI projects
+- CI gating and reviewer handoff
+- portable artifact generation without a vendor platform
+
For CI usage, add GitHub-native annotations and fail thresholds:
```bash
@@ -127,12 +153,65 @@ For portable artifact exchange, create a bundle archive:
node packages/cli/dist/index.js scan . --scope production --zip true
```
+### Workflow 2: Validate And Verify Existing Artifacts
+
+Validate individual EuConform JSON documents against the published schemas:
+
+```bash
+node packages/cli/dist/index.js validate .euconform
+```
+
+Typical output:
+- one line per file such as `euconform.aibom.json โ valid (euconform.aibom.v1)`
+- exit code `0` for fully valid input, `1` for schema errors, `2` when no EuConform JSON files are found
+
Verify a bundle manifest, extracted bundle directory, or ZIP archive:
```bash
node packages/cli/dist/index.js verify .euconform/euconform.bundle.json
```
+Typical use:
+- reviewer-side schema checking before manual analysis
+- CI sanity checks for artifact sets already produced elsewhere
+- portability checks before sharing bundles with downstream tools
+
+### Workflow 3: Evaluate Model Bias Locally
+
+Run a reproducible CrowS-Pairs bias evaluation against a local Ollama model:
+
+```bash
+node packages/cli/dist/index.js bias llama3.2 --lang de --output all
+```
+
+This is EuConform's distinctive empirical evidence layer. It produces model-behavior data that no other open-source compliance tool currently offers โ completely offline, reproducible, and independent of any vendor API.
+
+Typical use:
+- empirical model-behavior evidence for Art. 10 bias/fairness documentation
+- reproducible local evaluation before and after model updates
+- adding a behavioral evidence layer on top of structural evidence from `scan`
+
+### Workflow 4: Import An External CycloneDX SBOM
+
+Map an external CycloneDX JSON file into the EuConform AI BOM layer:
+
+```bash
+node packages/cli/dist/index.js import ./third-party.cdx.json \
+ --scope production \
+ --output /tmp/euconform-import
+
+node packages/cli/dist/index.js validate /tmp/euconform-import/euconform.aibom.json
+```
+
+This writes:
+- `/tmp/euconform-import/euconform.aibom.json`
+
+Important notes:
+- `import` accepts CycloneDX JSON and maps only the AI-relevant subset into `euconform.aibom.v1.1`
+- `--scope production` excludes `optional` and `excluded` components
+- the importer is intentionally conservative and does **not** infer compliance capabilities from an SBOM
+- project naming may come from BOM metadata or the source filename, depending on the input
+
### Try The Format In 10 Minutes
If you want to evaluate the current adoption path as an OSS builder, use one of the
@@ -193,10 +272,14 @@ Supports Llama, Mistral, and Qwen variants with automatic log-probability detect
### CLI Scanner & CI
-`euconform scan` is designed to complement the web wizard:
-- The **scanner** gathers technical evidence from a real codebase.
-- The **web app** remains the place for role and risk classification with human context.
-- The **bias evaluation tooling** adds empirical model-behavior evidence on top.
+EuConform's CLI is designed as reusable evidence infrastructure:
+- `euconform scan` produces native EuConform artifacts from a repository.
+- `euconform bias` provides EuConform's distinctive empirical model-behavior evidence layer.
+- `euconform validate` checks individual EuConform JSON files against the published schemas.
+- `euconform verify` checks artifact-set integrity for manifests, directories, and ZIP bundles.
+- `euconform import` bridges external CycloneDX JSON into the EuConform AI BOM layer.
+
+The **web app** remains the place for role and risk classification with human context.
#### GitHub Actions Example
@@ -215,13 +298,19 @@ In GitHub Actions, EuConform emits:
### EuConform Evidence Format
-The scanner artifacts are defined as the **EuConform Evidence Format**, an open specification for offline AI Act evidence exchange.
+EuConform implements the **EuConform Evidence Format**, an open specification for portable, machine-readable AI compliance evidence.
- `euconform.report.v1` captures compliance evidence, gaps, and open questions
- `euconform.aibom.v1` is the AI Bill of Materials (AI BOM) inventory layer
- `euconform.ci.v1` captures CI thresholds, status, and top findings
- `euconform.bundle.v1` binds artifact sets into a portable, integrity-aware manifest
+Current workflow boundaries:
+- `scan` **produces** native EuConform artifacts from source repositories
+- `import` can **ingest** external CycloneDX JSON into the current AIBOM layer for interoperability workflows
+- `validate` makes the schema and compatibility story inspectable at the JSON-document level
+- `verify` checks portable bundle integrity before exchange or automation
+
Stage 1 documentation, schemas, and example artifacts live in [docs/spec/README.md](docs/spec/README.md).
Reference source projects for OSS builders live in [examples/README.md](examples/README.md).
diff --git a/apps/docs/public/schemas/spec/aibom-v1.1.schema.json b/apps/docs/public/schemas/spec/aibom-v1.1.schema.json
new file mode 100644
index 0000000..cd04204
--- /dev/null
+++ b/apps/docs/public/schemas/spec/aibom-v1.1.schema.json
@@ -0,0 +1,92 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://euconform.eu/schemas/spec/aibom-v1.1.schema.json",
+ "title": "EuConform Evidence Format AI BOM v1.1",
+ "description": "Stage 1 inventory document for AI-relevant components and compliance capabilities. Adds optional import provenance metadata.",
+ "type": "object",
+ "required": ["schemaVersion", "generatedAt", "project", "components", "complianceCapabilities"],
+ "additionalProperties": false,
+ "properties": {
+ "schemaVersion": {
+ "const": "euconform.aibom.v1.1"
+ },
+ "generatedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "project": {
+ "type": "object",
+ "required": ["name", "rootPath"],
+ "additionalProperties": false,
+ "properties": {
+ "name": { "type": "string" },
+ "rootPath": { "type": "string" }
+ }
+ },
+ "components": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["id", "kind", "name", "source"],
+ "additionalProperties": false,
+ "properties": {
+ "id": { "type": "string" },
+ "kind": {
+ "enum": [
+ "framework",
+ "runtime",
+ "inference-provider",
+ "ai-framework",
+ "model",
+ "vector-store",
+ "embedding",
+ "dataset",
+ "tool"
+ ]
+ },
+ "name": { "type": "string" },
+ "version": { "type": "string" },
+ "source": {
+ "enum": ["package.json", "lock-file", "code", "config", "docs", "sbom-import"]
+ }
+ }
+ }
+ },
+ "complianceCapabilities": {
+ "type": "object",
+ "required": [
+ "biasEvaluation",
+ "jsonExport",
+ "pdfExport",
+ "loggingInfrastructure",
+ "humanReviewFlow",
+ "incidentHandling"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "biasEvaluation": { "type": "boolean" },
+ "jsonExport": { "type": "boolean" },
+ "pdfExport": { "type": "boolean" },
+ "loggingInfrastructure": { "type": "boolean" },
+ "humanReviewFlow": { "type": "boolean" },
+ "incidentHandling": { "type": "boolean" }
+ }
+ },
+ "metadata": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "importSource": {
+ "enum": ["cyclonedx", "spdx", "manual"]
+ },
+ "importTool": {
+ "type": "string"
+ },
+ "originalTimestamp": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ }
+ }
+}
diff --git a/apps/docs/public/schemas/spec/aibom-v1.schema.json b/apps/docs/public/schemas/spec/aibom-v1.schema.json
index 507ba67..08e3a57 100644
--- a/apps/docs/public/schemas/spec/aibom-v1.schema.json
+++ b/apps/docs/public/schemas/spec/aibom-v1.schema.json
@@ -47,7 +47,7 @@
"name": { "type": "string" },
"version": { "type": "string" },
"source": {
- "enum": ["package.json", "lock-file", "code", "config", "docs"]
+ "enum": ["package.json", "lock-file", "code", "config", "docs", "sbom-import"]
}
}
}
diff --git a/docs/spec/README.md b/docs/spec/README.md
index 8871952..f48c6e1 100644
--- a/docs/spec/README.md
+++ b/docs/spec/README.md
@@ -1,6 +1,6 @@
# EuConform Evidence Format
-The **EuConform Evidence Format** is the open specification behind the scanner artifacts produced by EuConform.
+EuConform implements the **EuConform Evidence Format**, an open specification for portable, machine-readable AI compliance evidence.
## Document types
@@ -10,6 +10,7 @@ The **EuConform Evidence Format** is the open specification behind the scanner a
|----------|---------|
| `euconform.report.v1` | Compliance-oriented evidence, open questions, gaps, and recommendations |
| `euconform.aibom.v1` | AI Bill of Materials (AI BOM) inventory for runtimes, providers, models, and supporting components |
+| `euconform.aibom.v1.1` | AI BOM v1 plus optional import provenance metadata |
| `euconform.ci.v1` | CI gate status, fail threshold, gap counts, and top findings |
### Stage 2 โ Available
@@ -23,18 +24,34 @@ The **EuConform Evidence Format** is the open specification behind the scanner a
- the format is currently documented as an **open specification**
- `AI BOM` is a **sub-specification** inside the format, not the umbrella name
+## Interoperability
+
+EuConform supports native artifact generation, empirical model evaluation, and external ingestion:
+
+- `scan` generates native EuConform artifacts from a repository
+- `bias` produces reproducible model-behavior evidence via local CrowS-Pairs evaluation โ EuConform's distinctive empirical layer
+- `validate` checks EuConform JSON documents against the published schemas
+- `verify` checks bundle integrity for manifests, extracted directories, and ZIP archives
+- `import` maps external CycloneDX JSON into the AIBOM layer (emits `euconform.aibom.v1.1`) as an interoperability bridge
+
+Important boundaries:
+- `bias` is independent of `scan` and can be used standalone for model evaluation
+- `import` does **not** replace a full native EuConform scan of a repository
+- `validate` and `verify` complement each other: schema checks for individual documents, integrity checks for artifact sets
+
## Versioning and compatibility
- `schemaVersion` is the compatibility boundary for every document
- Schemas enforce `additionalProperties: false` โ all fields must be explicitly defined
-- Patch releases must not change document shape
-- Adding new optional fields requires a new schema revision (e.g. `report-v1.1.schema.json`) because strict schemas reject unknown properties
+- Published schema revisions should avoid changing document shape in place
+- When new fields affect document shape or compatibility expectations, prefer a new schema revision (e.g. `report-v1.1.schema.json`) because strict schemas reject unknown properties
- Major schema changes must use a new `schemaVersion` (e.g. `euconform.report.v2`)
## Schemas
- [Report schema](./schemas/report-v1.schema.json)
- [AI BOM schema](./schemas/aibom-v1.schema.json)
+- [AI BOM v1.1 schema](./schemas/aibom-v1.1.schema.json)
- [CI schema](./schemas/ci-v1.schema.json)
- [Bundle schema](./schemas/bundle-v1.schema.json)
@@ -55,7 +72,9 @@ The **EuConform Evidence Format** is the open specification behind the scanner a
## Verification flow
- Build the CLI with `pnpm --filter @euconform/cli build`
-- Generate artifacts with `node packages/cli/dist/index.js scan .`
+- Generate native artifacts with `node packages/cli/dist/index.js scan .`
+- Validate EuConform JSON documents with `node packages/cli/dist/index.js validate `
+- Optionally import a CycloneDX JSON file with `node packages/cli/dist/index.js import `
- Optionally create a transport archive with `node packages/cli/dist/index.js scan . --zip true`
- Verify a manifest, bundle directory, or ZIP archive with `node packages/cli/dist/index.js verify `
- Hash and metadata mismatches are warnings by default and become errors in strict mode
diff --git a/docs/spec/schemas/aibom-v1.1.schema.json b/docs/spec/schemas/aibom-v1.1.schema.json
new file mode 100644
index 0000000..cd04204
--- /dev/null
+++ b/docs/spec/schemas/aibom-v1.1.schema.json
@@ -0,0 +1,92 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://euconform.eu/schemas/spec/aibom-v1.1.schema.json",
+ "title": "EuConform Evidence Format AI BOM v1.1",
+ "description": "Stage 1 inventory document for AI-relevant components and compliance capabilities. Adds optional import provenance metadata.",
+ "type": "object",
+ "required": ["schemaVersion", "generatedAt", "project", "components", "complianceCapabilities"],
+ "additionalProperties": false,
+ "properties": {
+ "schemaVersion": {
+ "const": "euconform.aibom.v1.1"
+ },
+ "generatedAt": {
+ "type": "string",
+ "format": "date-time"
+ },
+ "project": {
+ "type": "object",
+ "required": ["name", "rootPath"],
+ "additionalProperties": false,
+ "properties": {
+ "name": { "type": "string" },
+ "rootPath": { "type": "string" }
+ }
+ },
+ "components": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["id", "kind", "name", "source"],
+ "additionalProperties": false,
+ "properties": {
+ "id": { "type": "string" },
+ "kind": {
+ "enum": [
+ "framework",
+ "runtime",
+ "inference-provider",
+ "ai-framework",
+ "model",
+ "vector-store",
+ "embedding",
+ "dataset",
+ "tool"
+ ]
+ },
+ "name": { "type": "string" },
+ "version": { "type": "string" },
+ "source": {
+ "enum": ["package.json", "lock-file", "code", "config", "docs", "sbom-import"]
+ }
+ }
+ }
+ },
+ "complianceCapabilities": {
+ "type": "object",
+ "required": [
+ "biasEvaluation",
+ "jsonExport",
+ "pdfExport",
+ "loggingInfrastructure",
+ "humanReviewFlow",
+ "incidentHandling"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "biasEvaluation": { "type": "boolean" },
+ "jsonExport": { "type": "boolean" },
+ "pdfExport": { "type": "boolean" },
+ "loggingInfrastructure": { "type": "boolean" },
+ "humanReviewFlow": { "type": "boolean" },
+ "incidentHandling": { "type": "boolean" }
+ }
+ },
+ "metadata": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "importSource": {
+ "enum": ["cyclonedx", "spdx", "manual"]
+ },
+ "importTool": {
+ "type": "string"
+ },
+ "originalTimestamp": {
+ "type": "string",
+ "format": "date-time"
+ }
+ }
+ }
+ }
+}
diff --git a/docs/spec/schemas/aibom-v1.schema.json b/docs/spec/schemas/aibom-v1.schema.json
index 507ba67..08e3a57 100644
--- a/docs/spec/schemas/aibom-v1.schema.json
+++ b/docs/spec/schemas/aibom-v1.schema.json
@@ -47,7 +47,7 @@
"name": { "type": "string" },
"version": { "type": "string" },
"source": {
- "enum": ["package.json", "lock-file", "code", "config", "docs"]
+ "enum": ["package.json", "lock-file", "code", "config", "docs", "sbom-import"]
}
}
}
diff --git a/packages/cli/README.md b/packages/cli/README.md
index c6a9309..367462d 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -2,14 +2,17 @@
`@euconform/cli` is the command-line interface for **EuConform**, an offline-first evidence engine for European AI systems.
-It scans real project directories, produces machine-readable AI Act evidence, and verifies evidence bundles before they are handed to CI, reviewers, auditors, or downstream tools.
+It scans real project directories, runs reproducible local bias evaluation against Ollama models, validates and verifies machine-readable evidence, and can import external CycloneDX SBOMs into the EuConform AI BOM layer.
The CLI is also the first public producer and consumer of the **EuConform Evidence Format**.
## What it does
- `scan` inspects a codebase and generates structured evidence artifacts
+- `bias` runs a reproducible local CrowS-Pairs evaluation against an Ollama model โ EuConform's distinctive empirical layer
+- `validate` checks EuConform JSON files against the published schemas
- `verify` validates an EuConform Evidence Format bundle manifest, directory, or ZIP archive
+- `import` maps a CycloneDX JSON SBOM into `euconform.aibom.v1.1` as an interoperability bridge
- outputs stay local and can be reviewed in the EuConform web viewer
This package is designed for teams that want **technical evidence for human review**, not automated legal verdicts.
@@ -29,6 +32,16 @@ npm install -g @euconform/cli
euconform scan .
```
+## Command Overview
+
+| Command | Input | Output | Primary use case |
+|---------|-------|--------|------------------|
+| `scan ` | Repository directory | EuConform artifact set in `.euconform/` | Native evidence generation from source code |
+| `bias ` | Ollama model name | Bias report JSON and/or Markdown | Reproducible local model evaluation โ EuConform's distinctive empirical layer |
+| `validate ` | EuConform JSON file or directory | Valid/invalid status per file | Schema checks in CI, review, or local QA |
+| `verify ` | Bundle manifest, extracted bundle dir, or ZIP | Integrity status | Artifact exchange and transport verification |
+| `import ` | CycloneDX JSON SBOM | `euconform.aibom.json` | Interoperability bridge from external SBOM ecosystems |
+
## Commands
### `euconform scan `
@@ -55,6 +68,31 @@ Optional CI mode:
euconform scan . --ci github --fail-on high
```
+### `euconform validate `
+
+Validates EuConform JSON files against the published schemas.
+
+Accepted inputs:
+- a single `.json` file
+- a directory containing files named like `euconform.*.json`
+
+Example:
+
+```bash
+euconform validate .euconform
+```
+
+Machine-readable output:
+
+```bash
+euconform validate .euconform --json
+```
+
+Exit codes:
+- `0` all matched files are valid
+- `1` one or more files failed schema validation
+- `2` no matching EuConform JSON files were found
+
### `euconform verify `
Verifies an EuConform Evidence Format bundle in one of three forms:
@@ -81,9 +119,72 @@ Machine-readable output:
euconform verify .euconform/euconform.bundle.json --json
```
+### `euconform bias `
+
+Runs a reproducible CrowS-Pairs bias evaluation against a local Ollama model.
+
+This is EuConform's distinctive empirical layer โ no other open-source compliance tool currently offers reproducible, offline model-behavior evaluation for AI Act documentation.
+
+Example:
+
+```bash
+euconform bias llama3.2 --lang de --output all
+```
+
+What it does:
+- evaluates model bias locally via CrowS-Pairs methodology with log-probability or latency fallback
+- produces structured bias reports as JSON and/or Markdown
+- runs completely offline against any Ollama-compatible model
+- supports German and English evaluation datasets
+
+Typical use:
+- Art. 10 bias/fairness documentation with empirical evidence
+- reproducible evaluation before and after model updates
+- behavioral evidence layer on top of structural evidence from `scan`
+
+### `euconform import `
+
+Imports a CycloneDX JSON SBOM and writes an EuConform AI BOM.
+
+Example:
+
+```bash
+euconform import third-party.cdx.json --scope production
+```
+
+What it does:
+- accepts CycloneDX JSON as input
+- maps AI-relevant components into `euconform.aibom.v1.1`
+- writes `euconform.aibom.json` to `.euconform/` by default
+- excludes `optional` and `excluded` components when `--scope production` is used
+- can derive the project name from BOM metadata or the source filename
+- keeps `complianceCapabilities` conservative instead of inferring them from the SBOM
+
+Machine-readable output:
+
+```bash
+euconform import third-party.cdx.json --scope production --json
+```
+
+## Interoperability Workflow
+
+When you already have a third-party SBOM and want to bring it into the EuConform workflow:
+
+```bash
+euconform import third-party.cdx.json --scope production
+euconform validate .euconform/euconform.aibom.json
+# If you also have a EuConform bundle manifest, directory, or ZIP:
+euconform verify path/to/euconform.bundle.json
+```
+
+Notes:
+- `import` is an interoperability bridge into the current AIBOM layer
+- `validate` checks EuConform document shape
+- `verify` is only needed when you are working with a bundle manifest, directory, or ZIP archive
+
## What is the EuConform Evidence Format?
-The **EuConform Evidence Format** is an open specification for structured, offline AI Act evidence exchange.
+EuConform implements the **EuConform Evidence Format**, an open specification for portable, machine-readable AI compliance evidence.
Today the CLI works with these document types:
@@ -105,9 +206,12 @@ Most AI compliance workflows still depend on PDFs, screenshots, checklists, or v
`@euconform/cli` takes a different approach:
- scan implementation evidence instead of asking only questionnaires
+- evaluate model bias locally and reproducibly instead of relying on third-party APIs
+- validate inspectable JSON instead of relying on opaque one-off exports
- produce versionable artifacts instead of one-off documents
- verify bundle integrity before evidence is shared
-- keep outputs portable and inspectable outside one product UI
+- bridge external SBOM ecosystems into an open AI evidence layer
+- keep review portable and inspectable outside one product UI or vendor workflow
## Scope
diff --git a/packages/cli/src/commands/import.ts b/packages/cli/src/commands/import.ts
new file mode 100644
index 0000000..97f4043
--- /dev/null
+++ b/packages/cli/src/commands/import.ts
@@ -0,0 +1,126 @@
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { basename, join, resolve } from "node:path";
+import { importCycloneDx } from "@euconform/core/sbom";
+import { defineCommand } from "citty";
+import consola from "consola";
+
+export default defineCommand({
+ meta: {
+ name: "import",
+ description:
+ "Import a CycloneDX SBOM and extract AI-relevant components into an EuConform AI BOM",
+ },
+ args: {
+ path: {
+ type: "positional",
+ required: true,
+ description: "Path to a CycloneDX JSON SBOM file",
+ },
+ scope: {
+ type: "string",
+ default: "all",
+ description:
+ 'Component scope filter: "all" includes everything, "production" excludes optional and excluded-scope components',
+ },
+ json: {
+ type: "boolean",
+ default: false,
+ description: "Output results as JSON to stdout",
+ },
+ output: {
+ type: "string",
+ alias: "o",
+ default: ".euconform",
+ description: "Output directory for the generated aibom file",
+ },
+ },
+ async run({ args }) {
+ const inputPath = resolve(args.path as string);
+ const outputDir = resolve(args.output as string);
+ const scope = (args.scope as string) === "production" ? "production" : "all";
+
+ let data: unknown;
+ try {
+ const content = await readFile(inputPath, "utf-8");
+ data = JSON.parse(content);
+ } catch {
+ consola.error(`Failed to read or parse file: ${inputPath}`);
+ process.exit(2);
+ }
+
+ let result: ReturnType;
+ try {
+ result = importCycloneDx(data, { scope, sourcePath: inputPath });
+ } catch (error) {
+ consola.error(error instanceof Error ? error.message : String(error));
+ process.exit(1);
+ }
+
+ if (args.json) {
+ process.stdout.write(
+ `${JSON.stringify(
+ {
+ aibom: result.aibom,
+ summary: result.summary,
+ validation: { valid: result.validation.valid, errors: result.validation.errors },
+ },
+ null,
+ 2
+ )}\n`
+ );
+ return;
+ }
+
+ // Human-readable output
+ const fileName = basename(inputPath);
+ const src = result.summary.source;
+ const sourceLabel = src.importTool
+ ? `${fileName} (CycloneDX ${src.specVersion}, ${src.importTool})`
+ : `${fileName} (CycloneDX ${src.specVersion})`;
+
+ consola.log("");
+ consola.log("CycloneDX Import");
+ consola.log("\u2500".repeat(40));
+ consola.log(`Source: ${sourceLabel}`);
+ consola.log(`Project: ${result.aibom.project.name}`);
+
+ let componentsLine = `Components: ${result.summary.totalComponents} total \u2192 ${result.summary.aiRelevant} AI-relevant, ${result.summary.skipped} skipped`;
+ if (result.summary.filteredByScope > 0) {
+ componentsLine += `, ${result.summary.filteredByScope} scope-filtered`;
+ }
+ if (result.summary.duplicatesRemoved > 0) {
+ componentsLine += `, ${result.summary.duplicatesRemoved} duplicates removed`;
+ }
+ consola.log(componentsLine);
+ consola.log("");
+
+ for (const [kind, count] of Object.entries(result.summary.byKind).sort((a, b) => b[1] - a[1])) {
+ const names = result.aibom.components
+ .filter((c) => c.kind === kind)
+ .map((c) => c.name)
+ .join(", ");
+ consola.log(` ${kind.padEnd(20)} ${String(count).padStart(2)} (${names})`);
+ }
+
+ if (result.summary.warnings.length > 0) {
+ consola.log("");
+ for (const w of result.summary.warnings) {
+ consola.warn(`${w.component}: ${w.message}`);
+ }
+ }
+
+ // Write output
+ await mkdir(outputDir, { recursive: true });
+ const aibomPath = join(outputDir, "euconform.aibom.json");
+ await writeFile(aibomPath, JSON.stringify(result.aibom, null, 2), "utf-8");
+ consola.log("");
+ consola.success(`${aibomPath} ${result.validation.valid ? "(valid)" : "(validation errors!)"}`);
+
+ if (!result.validation.valid) {
+ for (const err of result.validation.errors) {
+ consola.error(` ${err.path || "/"} \u2014 ${err.message}`);
+ }
+ process.exit(1);
+ }
+ },
+});
diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts
new file mode 100644
index 0000000..95ddc32
--- /dev/null
+++ b/packages/cli/src/commands/validate.ts
@@ -0,0 +1,117 @@
+import { readFile, readdir, stat } from "node:fs/promises";
+import { basename, join, resolve } from "node:path";
+import { validate } from "@euconform/core/validation";
+import type { ValidationResult } from "@euconform/core/validation";
+import { defineCommand } from "citty";
+import consola from "consola";
+
+interface FileResult {
+ file: string;
+ result: ValidationResult;
+}
+
+async function validateFile(file: string): Promise {
+ try {
+ const content = await readFile(file, "utf-8");
+ const data: unknown = JSON.parse(content);
+ return { file, result: validate(data) };
+ } catch {
+ return {
+ file,
+ result: {
+ valid: false,
+ schemaType: "report.v1",
+ errors: [{ path: "", message: `Failed to read or parse file: ${file}`, keyword: "parse" }],
+ },
+ };
+ }
+}
+
+function printJsonResults(results: FileResult[]): void {
+ process.stdout.write(
+ `${JSON.stringify(
+ results.map((r) => ({
+ file: r.file,
+ valid: r.result.valid,
+ schemaType: r.result.schemaType,
+ errors: r.result.errors,
+ })),
+ null,
+ 2
+ )}\n`
+ );
+}
+
+function printHumanResults(results: FileResult[]): void {
+ for (const { file, result } of results) {
+ const shortName = basename(file);
+ if (result.valid) {
+ consola.success(`${shortName} \u2014 valid (euconform.${result.schemaType})`);
+ } else {
+ consola.error(
+ `${shortName} \u2014 ${result.errors.length} error(s) (euconform.${result.schemaType})`
+ );
+ for (const err of result.errors) {
+ consola.log(` ${err.path || "/"} \u2014 ${err.message}`);
+ }
+ }
+ }
+}
+
+export default defineCommand({
+ meta: {
+ name: "validate",
+ description: "Validate EuConform JSON files against published schemas",
+ },
+ args: {
+ path: {
+ type: "positional",
+ required: true,
+ description: "Path to a EuConform JSON file or directory containing EuConform JSON files",
+ },
+ json: {
+ type: "boolean",
+ default: false,
+ description: "Output results as JSON",
+ },
+ },
+ async run({ args }) {
+ const inputPath = resolve(args.path as string);
+ const files = await resolveInputFiles(inputPath);
+
+ if (files.length === 0) {
+ consola.error("No EuConform JSON files found at the given path.");
+ process.exit(2);
+ }
+
+ const results = await Promise.all(files.map(validateFile));
+
+ if (args.json) {
+ printJsonResults(results);
+ } else {
+ printHumanResults(results);
+ }
+
+ const hasErrors = results.some((r) => !r.result.valid);
+ process.exit(hasErrors ? 1 : 0);
+ },
+});
+
+async function resolveInputFiles(inputPath: string): Promise {
+ const info = await stat(inputPath).catch(() => null);
+ if (!info) return [];
+
+ if (info.isFile()) {
+ return inputPath.endsWith(".json") ? [inputPath] : [];
+ }
+
+ if (info.isDirectory()) {
+ const entries = await readdir(inputPath);
+ return entries
+ .filter((name) => name.startsWith("euconform.") && name.endsWith(".json"))
+ .map((name) => join(inputPath, name))
+ .sort();
+ }
+
+ return [];
+}
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 1d70b2c..81eda93 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -2,7 +2,9 @@
import { defineCommand, runMain } from "citty";
import biasCommand from "./commands/bias";
+import importCommand from "./commands/import";
import scanCommand from "./commands/scan";
+import validateCommand from "./commands/validate";
import verifyCommand from "./commands/verify";
const main = defineCommand({
@@ -12,8 +14,10 @@ const main = defineCommand({
},
subCommands: {
scan: scanCommand,
+ validate: validateCommand,
verify: verifyCommand,
bias: biasCommand,
+ import: importCommand,
},
});
diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts
index e8426c4..7926fe4 100644
--- a/packages/cli/tsup.config.ts
+++ b/packages/cli/tsup.config.ts
@@ -1,5 +1,10 @@
+import { copyFile, mkdir, readdir } from "node:fs/promises";
+import { join, resolve } from "node:path";
import { defineConfig } from "tsup";
+const schemasSource = resolve(__dirname, "../../docs/spec/schemas");
+const schemasDest = resolve(__dirname, "dist/schemas");
+
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
@@ -8,4 +13,13 @@ export default defineConfig({
clean: true,
noExternal: ["@euconform/core"],
external: ["sharp", "@xenova/transformers", "onnxruntime-node"],
+ async onSuccess() {
+ await mkdir(schemasDest, { recursive: true });
+ const files = await readdir(schemasSource);
+ for (const file of files) {
+ if (file.endsWith(".json")) {
+ await copyFile(join(schemasSource, file), join(schemasDest, file));
+ }
+ }
+ },
});
diff --git a/packages/core/package.json b/packages/core/package.json
index 7ee6e94..549e926 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -23,7 +23,9 @@
"./types": "./src/types.ts",
"./scanner": "./src/scanner/index.ts",
"./evidence": "./src/evidence/index.ts",
- "./datasets": "./src/datasets/crows-pairs.ts"
+ "./datasets": "./src/datasets/crows-pairs.ts",
+ "./validation": "./src/validation/index.ts",
+ "./sbom": "./src/sbom/index.ts"
},
"scripts": {
"build": "tsc",
@@ -40,6 +42,8 @@
},
"dependencies": {
"@xenova/transformers": "^2.17.2",
+ "ajv": "^8.18.0",
+ "ajv-formats": "^3.0.1",
"ignore": "^7.0.3"
}
}
diff --git a/packages/core/src/evidence/output.ts b/packages/core/src/evidence/output.ts
index d432aff..c359d81 100644
--- a/packages/core/src/evidence/output.ts
+++ b/packages/core/src/evidence/output.ts
@@ -81,6 +81,7 @@ const SOURCE_PRIORITY: Record = {
config: 4,
code: 3,
"lock-file": 2,
+ "sbom-import": 2,
docs: 1,
};
diff --git a/packages/core/src/evidence/types.ts b/packages/core/src/evidence/types.ts
index 280c9af..5e54ca9 100644
--- a/packages/core/src/evidence/types.ts
+++ b/packages/core/src/evidence/types.ts
@@ -232,11 +232,17 @@ export interface BomComponent {
kind: BomComponentKind;
name: string;
version?: string;
- source: "package.json" | "lock-file" | "code" | "config" | "docs";
+ source: "package.json" | "lock-file" | "code" | "config" | "docs" | "sbom-import";
+}
+
+export interface AibomMetadata {
+ importSource: "cyclonedx" | "spdx" | "manual";
+ importTool?: string;
+ originalTimestamp?: string;
}
export interface AiBillOfMaterials {
- schemaVersion: "euconform.aibom.v1";
+ schemaVersion: "euconform.aibom.v1" | "euconform.aibom.v1.1";
generatedAt: string;
project: {
name: string;
@@ -251,6 +257,7 @@ export interface AiBillOfMaterials {
humanReviewFlow: boolean;
incidentHandling: boolean;
};
+ metadata?: AibomMetadata;
}
// ---------------------------------------------------------------------------
diff --git a/packages/core/src/evidence/validate.ts b/packages/core/src/evidence/validate.ts
index 0c79338..63768d1 100644
--- a/packages/core/src/evidence/validate.ts
+++ b/packages/core/src/evidence/validate.ts
@@ -7,10 +7,16 @@ function assertObject(data: unknown, label: string): Record {
return data as Record;
}
-function requireSchemaVersion(obj: Record, expected: string, label: string): void {
- if (obj.schemaVersion !== expected) {
+function requireSchemaVersion(
+ obj: Record,
+ expected: string | string[],
+ label: string
+): void {
+ const allowed = Array.isArray(expected) ? expected : [expected];
+ if (!allowed.includes(obj.schemaVersion as string)) {
+ const quoted = allowed.map((v) => `"${v}"`).join(" or ");
throw new Error(
- `Invalid ${label} schema version: expected "${expected}", got "${String(obj.schemaVersion)}"`
+ `Invalid ${label} schema version: expected ${quoted}, got "${String(obj.schemaVersion)}"`
);
}
}
@@ -102,7 +108,7 @@ export function validateScanReport(data: unknown): ScanReport {
export function validateAiBillOfMaterials(data: unknown): AiBillOfMaterials {
const obj = assertObject(data, "AIBOM");
- requireSchemaVersion(obj, "euconform.aibom.v1", "AIBOM");
+ requireSchemaVersion(obj, ["euconform.aibom.v1", "euconform.aibom.v1.1"], "AIBOM");
requireField(obj, "generatedAt", "string", "AIBOM");
const project = requireField(obj, "project", "object", "AIBOM") as Record;
@@ -289,6 +295,7 @@ export function validateEcefJsonDocument(
case "euconform.report.v1":
return validateScanReport(data);
case "euconform.aibom.v1":
+ case "euconform.aibom.v1.1":
return validateAiBillOfMaterials(data);
case "euconform.ci.v1":
return validateCiReport(data);
diff --git a/packages/core/src/sbom/component-mapper.ts b/packages/core/src/sbom/component-mapper.ts
new file mode 100644
index 0000000..4a99f40
--- /dev/null
+++ b/packages/core/src/sbom/component-mapper.ts
@@ -0,0 +1,107 @@
+import type { BomComponentKind } from "../evidence/types";
+import { lookupKnownPackage } from "./known-packages";
+import { parsePurl } from "./purl";
+import type { CycloneDxComponent } from "./types";
+
+export interface ComponentMapping {
+ kind: BomComponentKind;
+ source: "sbom-import";
+ confidence: "high" | "medium";
+ purlVersion?: string;
+}
+
+const CYCLONEDX_TYPE_MAP: Record<
+ string,
+ { kind: BomComponentKind; confidence: "high" | "medium" }
+> = {
+ "machine-learning-model": { kind: "model", confidence: "high" },
+ data: { kind: "dataset", confidence: "medium" },
+ platform: { kind: "inference-provider", confidence: "medium" },
+ service: { kind: "inference-provider", confidence: "medium" },
+};
+
+/** Tiers 7-8: Try to find a kind via purl namespace/name and name-only lookups. */
+function lookupViaPurl(
+ parsed: ReturnType
+): { kind: BomComponentKind; confidence: "high" | "medium" } | null {
+ if (!parsed) return null;
+
+ // Tier 7: namespace/name (e.g. @langchain/core)
+ if (parsed.namespace) {
+ const scopedName = `${parsed.namespace}/${parsed.name}`;
+ const scopedKind = lookupKnownPackage(scopedName);
+ if (scopedKind) return { kind: scopedKind, confidence: "high" };
+ }
+
+ // Tier 8: purl name only
+ const purlKind = lookupKnownPackage(parsed.name);
+ if (purlKind) return { kind: purlKind, confidence: "medium" };
+
+ return null;
+}
+
+/**
+ * Map a CycloneDX component to an EuConform aibom component kind.
+ *
+ * 8-tier detection (descending priority):
+ * 1. ML-BOM extensions (modelCard, data)
+ * 2. CycloneDX component type mapping
+ * 3. Exact package name in registry
+ * 4. Explicit scoped name in registry (@langchain/core)
+ * 5. Scope mapping via KNOWN_AI_SCOPES (@langchain -> tool)
+ * 6. Scope-stripped name in registry (@huggingface/transformers -> transformers)
+ * 7. Purl namespace/name against registry
+ * 8. Purl name-only against registry
+ *
+ * Steps 3-6 are handled by lookupKnownPackage().
+ * Returns null if the component is not AI-relevant or has empty name.
+ */
+export function mapComponent(component: CycloneDxComponent): ComponentMapping | null {
+ // Name validation
+ if (!component.name || component.name.trim() === "") {
+ return null;
+ }
+
+ // Extract purl info (used for fallback and version)
+ const parsed = component.purl ? parsePurl(component.purl) : null;
+ const purlVersion = parsed?.version;
+
+ // Tier 1: ML-BOM extensions
+ if (component.modelCard != null) {
+ return { kind: "model", source: "sbom-import", confidence: "high", purlVersion };
+ }
+
+ if (Array.isArray(component.data) && component.data.some((d) => d.type === "dataset")) {
+ return { kind: "dataset", source: "sbom-import", confidence: "high", purlVersion };
+ }
+
+ // Tier 2: CycloneDX component type
+ const typeMapping = CYCLONEDX_TYPE_MAP[component.type];
+ if (typeMapping) {
+ return {
+ kind: typeMapping.kind,
+ source: "sbom-import",
+ confidence: typeMapping.confidence,
+ purlVersion,
+ };
+ }
+
+ // Tiers 3-6: Known-package registry (handles exact, scoped, scope-fallback, scope-stripped)
+ const knownKind = lookupKnownPackage(component.name);
+ if (knownKind) {
+ return { kind: knownKind, source: "sbom-import", confidence: "high", purlVersion };
+ }
+
+ // Tiers 7-8: Purl fallback
+ const purlMatch = lookupViaPurl(parsed);
+ if (purlMatch) {
+ return {
+ kind: purlMatch.kind,
+ source: "sbom-import",
+ confidence: purlMatch.confidence,
+ purlVersion,
+ };
+ }
+
+ return null;
+}
diff --git a/packages/core/src/sbom/cyclonedx-import.ts b/packages/core/src/sbom/cyclonedx-import.ts
new file mode 100644
index 0000000..51f3d2e
--- /dev/null
+++ b/packages/core/src/sbom/cyclonedx-import.ts
@@ -0,0 +1,198 @@
+import { basename, extname } from "node:path";
+import type { AiBillOfMaterials, AibomMetadata, BomComponent } from "../evidence/types";
+import type { ValidationResult } from "../validation/schema-validator";
+import { validate } from "../validation/schema-validator";
+import { mapComponent } from "./component-mapper";
+import type { CycloneDxBom } from "./types";
+import { SUPPORTED_SPEC_VERSIONS } from "./types";
+
+export interface ImportWarning {
+ component: string;
+ message: string;
+}
+
+export interface ImportSourceInfo {
+ bomFormat: "CycloneDX";
+ specVersion: string;
+ projectNameSource: "metadata.component.name" | "sourcePath" | "fallback";
+ importTool?: string;
+ originalTimestamp?: string;
+}
+
+export interface ImportSummary {
+ totalComponents: number;
+ filteredByScope: number;
+ aiRelevant: number;
+ skipped: number;
+ duplicatesRemoved: number;
+ byKind: Record;
+ warnings: ImportWarning[];
+ source: ImportSourceInfo;
+}
+
+export interface ImportOptions {
+ scope?: "all" | "production";
+ sourcePath?: string;
+}
+
+export interface CycloneDxImportResult {
+ aibom: AiBillOfMaterials;
+ summary: ImportSummary;
+ validation: ValidationResult;
+}
+
+export function importCycloneDx(sbom: unknown, options?: ImportOptions): CycloneDxImportResult {
+ const bom = parseBom(sbom);
+ const scope = options?.scope ?? "all";
+
+ const allComponents = bom.components ?? [];
+ const warnings: ImportWarning[] = [];
+ const totalComponents = allComponents.length;
+
+ const inScope =
+ scope === "production"
+ ? allComponents.filter((c) => c.scope !== "optional" && c.scope !== "excluded")
+ : allComponents;
+ const filteredByScope = totalComponents - inScope.length;
+
+ const mapped: BomComponent[] = [];
+ for (const comp of inScope) {
+ const mapping = mapComponent(comp);
+ if (!mapping) {
+ if (!comp.name || comp.name.trim() === "") {
+ warnings.push({ component: "(empty)", message: "Skipped: missing or empty name" });
+ }
+ continue;
+ }
+
+ const version = comp.version || mapping.purlVersion;
+ if (!version) {
+ warnings.push({ component: comp.name, message: "Missing version field" });
+ }
+
+ mapped.push({
+ id: version ? `${mapping.kind}:${comp.name}:${version}` : `${mapping.kind}:${comp.name}`,
+ kind: mapping.kind,
+ name: comp.name,
+ ...(version ? { version } : {}),
+ source: "sbom-import",
+ });
+ }
+
+ const seen = new Set();
+ let duplicatesRemoved = 0;
+ const deduped: BomComponent[] = [];
+ for (const comp of mapped) {
+ if (seen.has(comp.id)) {
+ duplicatesRemoved++;
+ continue;
+ }
+ seen.add(comp.id);
+ deduped.push(comp);
+ }
+
+ const byKind: Record = {};
+ for (const comp of deduped) {
+ byKind[comp.kind] = (byKind[comp.kind] ?? 0) + 1;
+ }
+
+ const { projectName, projectNameSource } = resolveProjectName(bom, options?.sourcePath);
+ const importTool = extractToolString(bom);
+ const originalTimestamp = bom.metadata?.timestamp;
+
+ const aibom: AiBillOfMaterials = {
+ schemaVersion: "euconform.aibom.v1.1",
+ generatedAt: new Date().toISOString(),
+ project: { name: projectName, rootPath: "." },
+ components: deduped,
+ complianceCapabilities: {
+ biasEvaluation: false,
+ jsonExport: false,
+ pdfExport: false,
+ loggingInfrastructure: false,
+ humanReviewFlow: false,
+ incidentHandling: false,
+ },
+ metadata: buildMetadata(importTool, originalTimestamp),
+ };
+
+ const validation = validate(aibom);
+
+ return {
+ aibom,
+ summary: {
+ totalComponents,
+ filteredByScope,
+ aiRelevant: deduped.length,
+ skipped: inScope.length - mapped.length,
+ duplicatesRemoved,
+ byKind,
+ warnings,
+ source: buildSourceInfo(bom.specVersion, projectNameSource, importTool, originalTimestamp),
+ },
+ validation,
+ };
+}
+
+function resolveProjectName(
+ bom: CycloneDxBom,
+ sourcePath?: string
+): { projectName: string; projectNameSource: ImportSourceInfo["projectNameSource"] } {
+ const metaName = bom.metadata?.component?.name;
+ if (metaName) {
+ return { projectName: metaName, projectNameSource: "metadata.component.name" };
+ }
+
+ if (sourcePath) {
+ const name = basename(sourcePath, extname(sourcePath));
+ return { projectName: name, projectNameSource: "sourcePath" };
+ }
+
+ return { projectName: "sbom-import", projectNameSource: "fallback" };
+}
+
+function extractToolString(bom: CycloneDxBom): string | undefined {
+ const tool = bom.metadata?.tools?.[0];
+ if (!tool?.name) return undefined;
+ return tool.version ? `${tool.name} ${tool.version}` : tool.name;
+}
+
+function buildMetadata(importTool?: string, originalTimestamp?: string): AibomMetadata {
+ const meta: AibomMetadata = { importSource: "cyclonedx" };
+ if (importTool) meta.importTool = importTool;
+ if (originalTimestamp) meta.originalTimestamp = originalTimestamp;
+ return meta;
+}
+
+function buildSourceInfo(
+ specVersion: string,
+ projectNameSource: ImportSourceInfo["projectNameSource"],
+ importTool?: string,
+ originalTimestamp?: string
+): ImportSourceInfo {
+ const info: ImportSourceInfo = { bomFormat: "CycloneDX", specVersion, projectNameSource };
+ if (importTool) info.importTool = importTool;
+ if (originalTimestamp) info.originalTimestamp = originalTimestamp;
+ return info;
+}
+
+function parseBom(sbom: unknown): CycloneDxBom {
+ if (typeof sbom !== "object" || sbom === null) {
+ throw new Error("Input must be a JSON object");
+ }
+
+ const record = sbom as Record;
+
+ if (record.bomFormat !== "CycloneDX") {
+ throw new Error(`Invalid bomFormat: expected "CycloneDX", got "${String(record.bomFormat)}"`);
+ }
+
+ const specVersion = String(record.specVersion ?? "");
+ if (!SUPPORTED_SPEC_VERSIONS.includes(specVersion as (typeof SUPPORTED_SPEC_VERSIONS)[number])) {
+ throw new Error(
+ `Unsupported specVersion "${specVersion}". Supported: ${SUPPORTED_SPEC_VERSIONS.join(", ")}`
+ );
+ }
+
+ return sbom as CycloneDxBom;
+}
diff --git a/packages/core/src/sbom/index.ts b/packages/core/src/sbom/index.ts
new file mode 100644
index 0000000..1ddc326
--- /dev/null
+++ b/packages/core/src/sbom/index.ts
@@ -0,0 +1,12 @@
+export {
+ importCycloneDx,
+ type CycloneDxImportResult,
+ type ImportSummary,
+ type ImportWarning,
+ type ImportOptions,
+ type ImportSourceInfo,
+} from "./cyclonedx-import";
+export { mapComponent, type ComponentMapping } from "./component-mapper";
+export { lookupKnownPackage, KNOWN_AI_PACKAGES, KNOWN_AI_SCOPES } from "./known-packages";
+export { parsePurl, type ParsedPurl } from "./purl";
+export type { CycloneDxBom, CycloneDxComponent } from "./types";
diff --git a/packages/core/src/sbom/known-packages.ts b/packages/core/src/sbom/known-packages.ts
new file mode 100644
index 0000000..e136472
--- /dev/null
+++ b/packages/core/src/sbom/known-packages.ts
@@ -0,0 +1,142 @@
+import type { BomComponentKind } from "../evidence/types";
+
+export const KNOWN_AI_PACKAGES: Record = {
+ // Models / model runtimes
+ "llama.cpp": "model",
+ "llama-cpp-python": "model",
+ "whisper.cpp": "model",
+ ggml: "model",
+ ctransformers: "model",
+
+ // AI Frameworks
+ transformers: "ai-framework",
+ torch: "ai-framework",
+ pytorch: "ai-framework",
+ tensorflow: "ai-framework",
+ "tensorflow-gpu": "ai-framework",
+ jax: "ai-framework",
+ keras: "ai-framework",
+ "pytorch-lightning": "ai-framework",
+ "scikit-learn": "ai-framework",
+ sklearn: "ai-framework",
+ xgboost: "ai-framework",
+ lightgbm: "ai-framework",
+ spacy: "ai-framework",
+ "huggingface-hub": "ai-framework",
+ diffusers: "ai-framework",
+ accelerate: "ai-framework",
+ peft: "ai-framework",
+ trl: "ai-framework",
+ onnxruntime: "ai-framework",
+ "onnxruntime-gpu": "ai-framework",
+ mlflow: "ai-framework",
+
+ // Embeddings
+ "sentence-transformers": "embedding",
+ fastembed: "embedding",
+ "openai-embeddings": "embedding",
+ "cohere-embed": "embedding",
+
+ // Inference Providers
+ openai: "inference-provider",
+ anthropic: "inference-provider",
+ ollama: "inference-provider",
+ vllm: "inference-provider",
+ "together-ai": "inference-provider",
+ replicate: "inference-provider",
+ groq: "inference-provider",
+ mistralai: "inference-provider",
+ cohere: "inference-provider",
+ "google-generativeai": "inference-provider",
+ ai21: "inference-provider",
+
+ // Vector Stores
+ pinecone: "vector-store",
+ "pinecone-client": "vector-store",
+ weaviate: "vector-store",
+ "weaviate-client": "vector-store",
+ qdrant: "vector-store",
+ "qdrant-client": "vector-store",
+ milvus: "vector-store",
+ pymilvus: "vector-store",
+ chromadb: "vector-store",
+ "chromadb-client": "vector-store",
+ pgvector: "vector-store",
+ faiss: "vector-store",
+ "faiss-cpu": "vector-store",
+ "faiss-gpu": "vector-store",
+
+ // Datasets
+ datasets: "dataset",
+
+ // Tools / Orchestration
+ langchain: "tool",
+ "langchain-core": "tool",
+ "langchain-community": "tool",
+ llamaindex: "tool",
+ "llama-index": "tool",
+ "semantic-kernel": "tool",
+ autogen: "tool",
+ crewai: "tool",
+ dspy: "tool",
+ "haystack-ai": "tool",
+ guidance: "tool",
+ guardrails: "tool",
+ "guardrails-ai": "tool",
+ lmql: "tool",
+ outlines: "tool",
+
+ // Scoped packages (npm)
+ "@langchain/core": "tool",
+ "@langchain/openai": "tool",
+ "@langchain/community": "tool",
+ "@langchain/anthropic": "tool",
+ "@huggingface/transformers": "ai-framework",
+ "@huggingface/inference": "inference-provider",
+ "@google/generative-ai": "inference-provider",
+ "@anthropic-ai/sdk": "inference-provider",
+ "@pinecone-database/pinecone": "vector-store",
+ "@qdrant/js-client-rest": "vector-store",
+};
+
+export const KNOWN_AI_SCOPES: Record = {
+ "@langchain": "tool",
+ "@huggingface": "ai-framework",
+ "@tensorflow": "ai-framework",
+ "@anthropic-ai": "inference-provider",
+ "@pinecone-database": "vector-store",
+ "@qdrant": "vector-store",
+};
+
+/**
+ * Look up a package name in the known AI packages registry.
+ * For scoped packages, tries:
+ * 1. Exact match (e.g. @langchain/core)
+ * 2. Scope match via KNOWN_AI_SCOPES (e.g. @langchain -> tool)
+ * 3. Strip scope, check bare name (e.g. @huggingface/transformers -> transformers)
+ * Returns the BomComponentKind if found, null otherwise.
+ */
+export function lookupKnownPackage(name: string): BomComponentKind | null {
+ const lower = name.toLowerCase();
+
+ // 1. Exact match
+ const exact = KNOWN_AI_PACKAGES[lower];
+ if (exact) return exact;
+
+ // Scoped package handling
+ if (lower.startsWith("@") && lower.includes("/")) {
+ const scopeEnd = lower.indexOf("/");
+ const scope = lower.slice(0, scopeEnd);
+ const bareName = lower.slice(scopeEnd + 1);
+
+ // 2. Known AI scope
+ const scopeKind = KNOWN_AI_SCOPES[scope];
+ if (scopeKind) return scopeKind;
+
+ // 3. Strip scope, check bare name
+ const bareKind = KNOWN_AI_PACKAGES[bareName];
+ if (bareKind) return bareKind;
+ }
+
+ return null;
+}
diff --git a/packages/core/src/sbom/purl.ts b/packages/core/src/sbom/purl.ts
new file mode 100644
index 0000000..ae48257
--- /dev/null
+++ b/packages/core/src/sbom/purl.ts
@@ -0,0 +1,66 @@
+export interface ParsedPurl {
+ scheme: "pkg";
+ type: string;
+ namespace?: string;
+ name: string;
+ version?: string;
+}
+
+/**
+ * Parse a Package URL (purl) string into its components.
+ * Follows the purl spec: pkg:type/namespace/name@version?qualifiers#subpath
+ * Returns null for invalid or non-pkg URLs.
+ */
+export function parsePurl(purl: string): ParsedPurl | null {
+ if (!purl || !purl.startsWith("pkg:")) return null;
+
+ // Strip qualifiers and subpath
+ let rest = purl.slice(4); // remove "pkg:"
+ const hashIdx = rest.indexOf("#");
+ if (hashIdx !== -1) rest = rest.slice(0, hashIdx);
+ const queryIdx = rest.indexOf("?");
+ if (queryIdx !== -1) rest = rest.slice(0, queryIdx);
+
+ // Split type from the rest: type/...
+ const slashIdx = rest.indexOf("/");
+ if (slashIdx <= 0) return null;
+
+ const type = rest.slice(0, slashIdx);
+ let remainder = rest.slice(slashIdx + 1);
+
+ try {
+ remainder = decodeURIComponent(remainder);
+ } catch {
+ return null;
+ }
+
+ // Extract version
+ let version: string | undefined;
+ const atIdx = remainder.lastIndexOf("@");
+ if (atIdx !== -1) {
+ version = remainder.slice(atIdx + 1);
+ remainder = remainder.slice(0, atIdx);
+ }
+
+ // Split namespace/name (last segment is name, rest is namespace)
+ const lastSlash = remainder.lastIndexOf("/");
+ let namespace: string | undefined;
+ let name: string;
+
+ if (lastSlash !== -1) {
+ namespace = remainder.slice(0, lastSlash);
+ name = remainder.slice(lastSlash + 1);
+ } else {
+ name = remainder;
+ }
+
+ if (!name) return null;
+
+ return {
+ scheme: "pkg",
+ type,
+ namespace: namespace || undefined,
+ name,
+ version: version || undefined,
+ };
+}
diff --git a/packages/core/src/sbom/types.ts b/packages/core/src/sbom/types.ts
new file mode 100644
index 0000000..47d9437
--- /dev/null
+++ b/packages/core/src/sbom/types.ts
@@ -0,0 +1,34 @@
+/**
+ * Minimal CycloneDX types covering fields relevant to AI component detection.
+ * Supports CycloneDX JSON format versions 1.4, 1.5, 1.6.
+ */
+
+export interface CycloneDxBom {
+ bomFormat: "CycloneDX";
+ specVersion: string;
+ version?: number;
+ metadata?: CycloneDxMetadata;
+ components?: CycloneDxComponent[];
+}
+
+export interface CycloneDxMetadata {
+ timestamp?: string;
+ tools?: Array<{ name?: string; version?: string }>;
+ component?: CycloneDxComponent;
+}
+
+export interface CycloneDxComponent {
+ type: string;
+ name: string;
+ version?: string;
+ purl?: string;
+ group?: string;
+ description?: string;
+ scope?: "required" | "optional" | "excluded";
+ properties?: Array<{ name: string; value: string }>;
+ /** CycloneDX ML-BOM extensions (1.5+) */
+ modelCard?: unknown;
+ data?: Array<{ type?: string }>;
+}
+
+export const SUPPORTED_SPEC_VERSIONS = ["1.4", "1.5", "1.6"] as const;
diff --git a/packages/core/src/validation/index.ts b/packages/core/src/validation/index.ts
new file mode 100644
index 0000000..6a9748b
--- /dev/null
+++ b/packages/core/src/validation/index.ts
@@ -0,0 +1,7 @@
+export {
+ validate,
+ type ValidationResult,
+ type ValidationError,
+ type SchemaType,
+ type ValidateOptions,
+} from "./schema-validator";
diff --git a/packages/core/src/validation/schema-validator.ts b/packages/core/src/validation/schema-validator.ts
new file mode 100644
index 0000000..0696ebb
--- /dev/null
+++ b/packages/core/src/validation/schema-validator.ts
@@ -0,0 +1,133 @@
+import { existsSync, readFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import addFormats from "ajv-formats";
+import Ajv2020 from "ajv/dist/2020";
+
+export interface ValidationError {
+ path: string;
+ message: string;
+ keyword: string;
+ expected?: unknown;
+ received?: unknown;
+}
+
+export type SchemaType = "report.v1" | "aibom.v1" | "aibom.v1.1" | "ci.v1" | "bundle.v1";
+
+export interface ValidationResult {
+ valid: boolean;
+ schemaType: SchemaType;
+ errors: ValidationError[];
+}
+
+export interface ValidateOptions {
+ strict?: boolean;
+}
+
+// Resolve schema directory relative to this file at runtime.
+// When bundled into packages/cli/dist, schemas are copied to dist/schemas/.
+// When running from source at packages/core/src/validation/, schemas are at ../../../../docs/spec/schemas.
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+const _bundledSchemasDir = resolve(__dirname, "schemas");
+const schemasDir = existsSync(_bundledSchemasDir)
+ ? _bundledSchemasDir
+ : resolve(__dirname, "../../../../docs/spec/schemas");
+
+function loadSchema(filename: string): object {
+ const content = readFileSync(resolve(schemasDir, filename), "utf-8");
+ return JSON.parse(content) as object;
+}
+
+const SCHEMA_MAP: Record = {
+ "euconform.report.v1": { schemaFile: "report-v1.schema.json", type: "report.v1" },
+ "euconform.aibom.v1": { schemaFile: "aibom-v1.schema.json", type: "aibom.v1" },
+ "euconform.aibom.v1.1": { schemaFile: "aibom-v1.1.schema.json", type: "aibom.v1.1" },
+ "euconform.ci.v1": { schemaFile: "ci-v1.schema.json", type: "ci.v1" },
+ "euconform.bundle.v1": { schemaFile: "bundle-v1.schema.json", type: "bundle.v1" },
+};
+
+let ajvInstance: Ajv2020 | null = null;
+
+function getAjv(): Ajv2020 {
+ if (!ajvInstance) {
+ ajvInstance = new Ajv2020({ allErrors: true, strict: false });
+ addFormats(ajvInstance);
+ }
+ return ajvInstance;
+}
+
+export function validate(data: unknown, _options?: ValidateOptions): ValidationResult {
+ if (typeof data !== "object" || data === null) {
+ return {
+ valid: false,
+ schemaType: "report.v1",
+ errors: [
+ {
+ path: "",
+ message: "Input must be a JSON object",
+ keyword: "type",
+ expected: "object",
+ received: typeof data,
+ },
+ ],
+ };
+ }
+
+ const record = data as Record;
+ const schemaVersion = record.schemaVersion;
+
+ if (typeof schemaVersion !== "string") {
+ return {
+ valid: false,
+ schemaType: "report.v1",
+ errors: [
+ {
+ path: "",
+ message: `Missing or invalid schemaVersion field. Expected one of: ${Object.keys(SCHEMA_MAP).join(", ")}`,
+ keyword: "required",
+ },
+ ],
+ };
+ }
+
+ const entry = SCHEMA_MAP[schemaVersion];
+ if (!entry) {
+ return {
+ valid: false,
+ schemaType: "report.v1",
+ errors: [
+ {
+ path: "/schemaVersion",
+ message: `Unknown schemaVersion "${schemaVersion}". Expected one of: ${Object.keys(SCHEMA_MAP).join(", ")}`,
+ keyword: "const",
+ expected: Object.keys(SCHEMA_MAP),
+ received: schemaVersion,
+ },
+ ],
+ };
+ }
+
+ const ajv = getAjv();
+ let validateFn = ajv.getSchema(schemaVersion);
+ if (!validateFn) {
+ const schema = loadSchema(entry.schemaFile);
+ validateFn = ajv.compile({ ...schema, $id: schemaVersion });
+ }
+
+ const valid = validateFn(data) as boolean;
+
+ if (valid) {
+ return { valid: true, schemaType: entry.type, errors: [] };
+ }
+
+ const errors: ValidationError[] = (validateFn.errors ?? []).map((err) => ({
+ path: err.instancePath ?? "",
+ message: err.message ?? "Validation error",
+ keyword: err.keyword ?? "unknown",
+ expected: err.params,
+ received: undefined,
+ }));
+
+ return { valid: false, schemaType: entry.type, errors };
+}
diff --git a/packages/core/tests/fixtures/cyclonedx-minimal.json b/packages/core/tests/fixtures/cyclonedx-minimal.json
new file mode 100644
index 0000000..a16404a
--- /dev/null
+++ b/packages/core/tests/fixtures/cyclonedx-minimal.json
@@ -0,0 +1,13 @@
+{
+ "bomFormat": "CycloneDX",
+ "specVersion": "1.5",
+ "version": 1,
+ "components": [
+ {
+ "type": "machine-learning-model",
+ "name": "llama-3.2-1b",
+ "version": "1.0.0",
+ "purl": "pkg:generic/llama-3.2-1b@1.0.0"
+ }
+ ]
+}
diff --git a/packages/core/tests/fixtures/cyclonedx-mixed.json b/packages/core/tests/fixtures/cyclonedx-mixed.json
new file mode 100644
index 0000000..8f706e6
--- /dev/null
+++ b/packages/core/tests/fixtures/cyclonedx-mixed.json
@@ -0,0 +1,17 @@
+{
+ "bomFormat": "CycloneDX",
+ "specVersion": "1.5",
+ "version": 1,
+ "components": [
+ { "type": "library", "name": "express", "version": "4.18.2" },
+ { "type": "library", "name": "lodash", "version": "4.17.21" },
+ { "type": "library", "name": "openai", "version": "1.12.0" },
+ { "type": "framework", "name": "transformers", "version": "4.38.0" },
+ { "type": "library", "name": "chromadb", "version": "0.4.22" },
+ { "type": "library", "name": "langchain", "version": "0.1.0" },
+ { "type": "machine-learning-model", "name": "whisper-large-v3", "version": "1.0.0" },
+ { "type": "library", "name": "typescript", "version": "5.3.0" },
+ { "type": "library", "name": "react", "version": "18.2.0" },
+ { "type": "library", "name": "axios", "version": "1.6.0" }
+ ]
+}
diff --git a/packages/core/tests/fixtures/cyclonedx-ml-bom.json b/packages/core/tests/fixtures/cyclonedx-ml-bom.json
new file mode 100644
index 0000000..972ef1a
--- /dev/null
+++ b/packages/core/tests/fixtures/cyclonedx-ml-bom.json
@@ -0,0 +1,25 @@
+{
+ "bomFormat": "CycloneDX",
+ "specVersion": "1.5",
+ "version": 1,
+ "components": [
+ {
+ "type": "library",
+ "name": "custom-bert-model",
+ "version": "2.0.0",
+ "modelCard": {
+ "modelParameters": {
+ "approach": { "type": "supervised" },
+ "task": "text-classification"
+ }
+ }
+ },
+ {
+ "type": "library",
+ "name": "sentiment-dataset",
+ "version": "1.0.0",
+ "data": [{ "type": "dataset", "name": "imdb-reviews" }]
+ },
+ { "type": "library", "name": "numpy", "version": "1.24.0" }
+ ]
+}
diff --git a/packages/core/tests/sbom/component-mapper.test.ts b/packages/core/tests/sbom/component-mapper.test.ts
new file mode 100644
index 0000000..89b1ad0
--- /dev/null
+++ b/packages/core/tests/sbom/component-mapper.test.ts
@@ -0,0 +1,258 @@
+import { describe, expect, it } from "vitest";
+import { mapComponent } from "../../src/sbom/component-mapper";
+import { lookupKnownPackage } from "../../src/sbom/known-packages";
+import type { CycloneDxComponent } from "../../src/sbom/types";
+
+describe("known-packages", () => {
+ it("recognizes torch as ai-framework", () => {
+ expect(lookupKnownPackage("torch")).toBe("ai-framework");
+ });
+
+ it("recognizes openai as inference-provider", () => {
+ expect(lookupKnownPackage("openai")).toBe("inference-provider");
+ });
+
+ it("recognizes chromadb as vector-store", () => {
+ expect(lookupKnownPackage("chromadb")).toBe("vector-store");
+ });
+
+ it("recognizes langchain as tool", () => {
+ expect(lookupKnownPackage("langchain")).toBe("tool");
+ });
+
+ it("returns null for unknown packages", () => {
+ expect(lookupKnownPackage("express")).toBeNull();
+ expect(lookupKnownPackage("lodash")).toBeNull();
+ expect(lookupKnownPackage("react")).toBeNull();
+ });
+
+ it("is case-insensitive", () => {
+ expect(lookupKnownPackage("Transformers")).toBe("ai-framework");
+ expect(lookupKnownPackage("OPENAI")).toBe("inference-provider");
+ });
+
+ it("recognizes @langchain/core as tool", () => {
+ expect(lookupKnownPackage("@langchain/core")).toBe("tool");
+ });
+
+ it("recognizes @huggingface/transformers as ai-framework", () => {
+ expect(lookupKnownPackage("@huggingface/transformers")).toBe("ai-framework");
+ });
+
+ it("recognizes @google/generative-ai as inference-provider", () => {
+ expect(lookupKnownPackage("@google/generative-ai")).toBe("inference-provider");
+ });
+
+ it("falls back to scope lookup for unknown @langchain/* packages", () => {
+ expect(lookupKnownPackage("@langchain/unknown-new-package")).toBe("tool");
+ });
+
+ it("falls back to stripping scope and checking name", () => {
+ expect(lookupKnownPackage("@someorg/transformers")).toBe("ai-framework");
+ });
+
+ it("returns null for unknown scoped packages", () => {
+ expect(lookupKnownPackage("@types/node")).toBeNull();
+ expect(lookupKnownPackage("@testing-library/react")).toBeNull();
+ });
+});
+
+describe("component-mapper", () => {
+ describe("tier 1: ML-BOM extensions", () => {
+ it("maps component with modelCard to model", () => {
+ const comp: CycloneDxComponent = {
+ type: "library",
+ name: "my-custom-model",
+ modelCard: { modelParameters: {} },
+ };
+ const result = mapComponent(comp);
+ expect(result).not.toBeNull();
+ expect(result?.kind).toBe("model");
+ expect(result?.confidence).toBe("high");
+ });
+
+ it("maps component with data array containing ml type to dataset", () => {
+ const comp: CycloneDxComponent = {
+ type: "library",
+ name: "training-data",
+ data: [{ type: "dataset" }],
+ };
+ const result = mapComponent(comp);
+ expect(result).not.toBeNull();
+ expect(result?.kind).toBe("dataset");
+ expect(result?.confidence).toBe("high");
+ });
+ });
+
+ describe("tier 2: CycloneDX component type", () => {
+ it("maps machine-learning-model to model", () => {
+ const comp: CycloneDxComponent = {
+ type: "machine-learning-model",
+ name: "llama-3",
+ };
+ const result = mapComponent(comp);
+ expect(result).not.toBeNull();
+ expect(result?.kind).toBe("model");
+ expect(result?.confidence).toBe("high");
+ });
+
+ it("maps data type to dataset", () => {
+ const comp: CycloneDxComponent = {
+ type: "data",
+ name: "training-corpus",
+ };
+ const result = mapComponent(comp);
+ expect(result).not.toBeNull();
+ expect(result?.kind).toBe("dataset");
+ expect(result?.confidence).toBe("medium");
+ });
+
+ it("maps platform type to inference-provider", () => {
+ const comp: CycloneDxComponent = {
+ type: "platform",
+ name: "inference-platform",
+ };
+ const result = mapComponent(comp);
+ expect(result).not.toBeNull();
+ expect(result?.kind).toBe("inference-provider");
+ expect(result?.confidence).toBe("medium");
+ });
+
+ it("maps service type to inference-provider", () => {
+ const comp: CycloneDxComponent = {
+ type: "service",
+ name: "model-api",
+ };
+ const result = mapComponent(comp);
+ expect(result).not.toBeNull();
+ expect(result?.kind).toBe("inference-provider");
+ expect(result?.confidence).toBe("medium");
+ });
+ });
+
+ describe("tier 3: known-package registry", () => {
+ it("maps known framework-type package to ai-framework", () => {
+ const comp: CycloneDxComponent = {
+ type: "framework",
+ name: "transformers",
+ version: "4.38.0",
+ };
+ const result = mapComponent(comp);
+ expect(result).not.toBeNull();
+ expect(result?.kind).toBe("ai-framework");
+ expect(result?.confidence).toBe("high");
+ });
+
+ it("maps known library-type package", () => {
+ const comp: CycloneDxComponent = {
+ type: "library",
+ name: "openai",
+ version: "1.12.0",
+ };
+ const result = mapComponent(comp);
+ expect(result).not.toBeNull();
+ expect(result?.kind).toBe("inference-provider");
+ expect(result?.confidence).toBe("high");
+ });
+ });
+
+ describe("non-AI components", () => {
+ it("returns null for unknown library", () => {
+ const comp: CycloneDxComponent = {
+ type: "library",
+ name: "express",
+ version: "4.18.0",
+ };
+ expect(mapComponent(comp)).toBeNull();
+ });
+
+ it("returns null for unknown framework", () => {
+ const comp: CycloneDxComponent = {
+ type: "framework",
+ name: "react",
+ version: "18.2.0",
+ };
+ expect(mapComponent(comp)).toBeNull();
+ });
+ });
+
+ describe("source field", () => {
+ it("always sets source to sbom-import", () => {
+ const comp: CycloneDxComponent = {
+ type: "machine-learning-model",
+ name: "test-model",
+ };
+ const result = mapComponent(comp);
+ expect(result?.source).toBe("sbom-import");
+ });
+ });
+
+ describe("name validation", () => {
+ it("returns null for component with empty name", () => {
+ const comp: CycloneDxComponent = {
+ type: "machine-learning-model",
+ name: "",
+ };
+ expect(mapComponent(comp)).toBeNull();
+ });
+ });
+
+ describe("purl fallback", () => {
+ it("detects AI package via purl when name is unrecognized", () => {
+ const comp: CycloneDxComponent = {
+ type: "library",
+ name: "custom-wrapper",
+ purl: "pkg:pypi/transformers@4.38.0",
+ };
+ const result = mapComponent(comp);
+ expect(result).not.toBeNull();
+ expect(result?.kind).toBe("ai-framework");
+ });
+
+ it("detects scoped package via purl namespace/name", () => {
+ const comp: CycloneDxComponent = {
+ type: "library",
+ name: "some-alias",
+ purl: "pkg:npm/%40langchain/core@0.1.22",
+ };
+ const result = mapComponent(comp);
+ expect(result).not.toBeNull();
+ expect(result?.kind).toBe("tool");
+ });
+
+ it("does not match purl when name already matched", () => {
+ const comp: CycloneDxComponent = {
+ type: "library",
+ name: "openai",
+ purl: "pkg:pypi/openai@1.0.0",
+ };
+ const result = mapComponent(comp);
+ expect(result).not.toBeNull();
+ expect(result?.kind).toBe("inference-provider");
+ expect(result?.confidence).toBe("high");
+ });
+ });
+
+ describe("version extraction from purl", () => {
+ it("extracts version from purl via purlVersion", () => {
+ const comp: CycloneDxComponent = {
+ type: "machine-learning-model",
+ name: "my-model",
+ purl: "pkg:generic/my-model@3.0.0",
+ };
+ const result = mapComponent(comp);
+ expect(result).not.toBeNull();
+ expect(result?.purlVersion).toBe("3.0.0");
+ });
+
+ it("returns undefined purlVersion when no purl", () => {
+ const comp: CycloneDxComponent = {
+ type: "machine-learning-model",
+ name: "my-model",
+ };
+ const result = mapComponent(comp);
+ expect(result).not.toBeNull();
+ expect(result?.purlVersion).toBeUndefined();
+ });
+ });
+});
diff --git a/packages/core/tests/sbom/cyclonedx-import.test.ts b/packages/core/tests/sbom/cyclonedx-import.test.ts
new file mode 100644
index 0000000..2c3b2ae
--- /dev/null
+++ b/packages/core/tests/sbom/cyclonedx-import.test.ts
@@ -0,0 +1,329 @@
+import { describe, expect, it } from "vitest";
+import { importCycloneDx } from "../../src/sbom/cyclonedx-import";
+import minimalBom from "../fixtures/cyclonedx-minimal.json";
+import mixedBom from "../fixtures/cyclonedx-mixed.json";
+import mlBom from "../fixtures/cyclonedx-ml-bom.json";
+
+describe("cyclonedx-import", () => {
+ describe("basic import", () => {
+ it("imports a minimal CycloneDX BOM", () => {
+ const result = importCycloneDx(minimalBom);
+ expect(result.aibom.schemaVersion).toBe("euconform.aibom.v1.1");
+ expect(result.aibom.components).toHaveLength(1);
+ expect(result.aibom.components[0].kind).toBe("model");
+ expect(result.aibom.components[0].name).toBe("llama-3.2-1b");
+ expect(result.aibom.components[0].source).toBe("sbom-import");
+ });
+
+ it("produces a valid aibom", () => {
+ const result = importCycloneDx(minimalBom);
+ expect(result.validation.valid).toBe(true);
+ });
+ });
+
+ describe("filtering", () => {
+ it("filters AI-relevant components from mixed BOM", () => {
+ const result = importCycloneDx(mixedBom);
+ expect(result.summary.totalComponents).toBe(10);
+ expect(result.summary.aiRelevant).toBe(5);
+ expect(result.summary.skipped).toBe(5);
+ });
+
+ it("correctly maps component kinds in mixed BOM", () => {
+ const result = importCycloneDx(mixedBom);
+ const kinds = result.aibom.components.map((c) => c.kind).sort();
+ expect(kinds).toEqual([
+ "ai-framework",
+ "inference-provider",
+ "model",
+ "tool",
+ "vector-store",
+ ]);
+ });
+
+ it("provides byKind summary", () => {
+ const result = importCycloneDx(mixedBom);
+ expect(result.summary.byKind.model).toBe(1);
+ expect(result.summary.byKind["ai-framework"]).toBe(1);
+ expect(result.summary.byKind["inference-provider"]).toBe(1);
+ expect(result.summary.byKind.tool).toBe(1);
+ expect(result.summary.byKind["vector-store"]).toBe(1);
+ });
+ });
+
+ describe("ML-BOM extensions", () => {
+ it("detects modelCard extension", () => {
+ const result = importCycloneDx(mlBom);
+ const model = result.aibom.components.find((c) => c.name === "custom-bert-model");
+ expect(model).toBeDefined();
+ expect(model?.kind).toBe("model");
+ });
+
+ it("detects data extension as dataset", () => {
+ const result = importCycloneDx(mlBom);
+ const dataset = result.aibom.components.find((c) => c.name === "sentiment-dataset");
+ expect(dataset).toBeDefined();
+ expect(dataset?.kind).toBe("dataset");
+ });
+
+ it("skips non-AI components even in ML BOM", () => {
+ const result = importCycloneDx(mlBom);
+ expect(result.summary.totalComponents).toBe(3);
+ expect(result.summary.aiRelevant).toBe(2);
+ expect(result.summary.skipped).toBe(1);
+ });
+ });
+
+ describe("input validation", () => {
+ it("rejects non-CycloneDX input", () => {
+ expect(() => importCycloneDx({ foo: "bar" })).toThrow("bomFormat");
+ });
+
+ it("rejects unsupported specVersion", () => {
+ expect(() =>
+ importCycloneDx({
+ bomFormat: "CycloneDX",
+ specVersion: "1.0",
+ components: [],
+ })
+ ).toThrow("specVersion");
+ });
+
+ it("handles BOM with no components", () => {
+ const result = importCycloneDx({
+ bomFormat: "CycloneDX",
+ specVersion: "1.5",
+ components: [],
+ });
+ expect(result.aibom.components).toEqual([]);
+ expect(result.summary.totalComponents).toBe(0);
+ });
+ });
+
+ describe("scope filtering", () => {
+ it("excludes optional-scope components when scope is production", () => {
+ const bom = {
+ bomFormat: "CycloneDX",
+ specVersion: "1.5",
+ components: [
+ { type: "machine-learning-model", name: "prod-model", scope: "required" },
+ { type: "machine-learning-model", name: "dev-model", scope: "optional" },
+ ],
+ };
+ const result = importCycloneDx(bom, { scope: "production" });
+ expect(result.aibom.components).toHaveLength(1);
+ expect(result.aibom.components[0].name).toBe("prod-model");
+ });
+
+ it("excludes excluded-scope components when scope is production", () => {
+ const bom = {
+ bomFormat: "CycloneDX",
+ specVersion: "1.5",
+ components: [
+ { type: "machine-learning-model", name: "prod-model", scope: "required" },
+ { type: "machine-learning-model", name: "excluded-model", scope: "excluded" },
+ ],
+ };
+ const result = importCycloneDx(bom, { scope: "production" });
+ expect(result.aibom.components).toHaveLength(1);
+ expect(result.aibom.components[0].name).toBe("prod-model");
+ });
+
+ it("includes components without scope in production mode", () => {
+ const bom = {
+ bomFormat: "CycloneDX",
+ specVersion: "1.5",
+ components: [{ type: "machine-learning-model", name: "no-scope-model" }],
+ };
+ const result = importCycloneDx(bom, { scope: "production" });
+ expect(result.aibom.components).toHaveLength(1);
+ });
+ });
+
+ describe("complianceCapabilities", () => {
+ it("sets all capabilities to false for imports", () => {
+ const result = importCycloneDx(minimalBom);
+ const caps = result.aibom.complianceCapabilities;
+ expect(caps.biasEvaluation).toBe(false);
+ expect(caps.jsonExport).toBe(false);
+ expect(caps.pdfExport).toBe(false);
+ expect(caps.loggingInfrastructure).toBe(false);
+ expect(caps.humanReviewFlow).toBe(false);
+ expect(caps.incidentHandling).toBe(false);
+ });
+ });
+
+ describe("component ID generation", () => {
+ it("generates id as kind:name", () => {
+ const result = importCycloneDx(minimalBom);
+ expect(result.aibom.components[0].id).toBe("model:llama-3.2-1b:1.0.0");
+ });
+ });
+
+ describe("deduplication", () => {
+ it("removes exact duplicates (same kind+name+version)", () => {
+ const bom = {
+ bomFormat: "CycloneDX",
+ specVersion: "1.5",
+ components: [
+ { type: "library", name: "openai", version: "1.12.0" },
+ { type: "library", name: "openai", version: "1.12.0" },
+ { type: "library", name: "openai", version: "1.12.0" },
+ ],
+ };
+ const result = importCycloneDx(bom);
+ expect(result.aibom.components).toHaveLength(1);
+ expect(result.summary.duplicatesRemoved).toBe(2);
+ });
+
+ it("keeps different versions of the same package", () => {
+ const bom = {
+ bomFormat: "CycloneDX",
+ specVersion: "1.5",
+ components: [
+ { type: "library", name: "torch", version: "2.0.0" },
+ { type: "library", name: "torch", version: "2.2.0" },
+ ],
+ };
+ const result = importCycloneDx(bom);
+ expect(result.aibom.components).toHaveLength(2);
+ expect(result.summary.duplicatesRemoved).toBe(0);
+ });
+ });
+
+ describe("ID format", () => {
+ it("uses kind:name:version when version present", () => {
+ const result = importCycloneDx(minimalBom);
+ expect(result.aibom.components[0].id).toBe("model:llama-3.2-1b:1.0.0");
+ });
+
+ it("uses kind:name when version absent", () => {
+ const bom = {
+ bomFormat: "CycloneDX",
+ specVersion: "1.5",
+ components: [{ type: "machine-learning-model", name: "some-model" }],
+ };
+ const result = importCycloneDx(bom);
+ expect(result.aibom.components[0].id).toBe("model:some-model");
+ });
+ });
+
+ describe("provenance", () => {
+ it("sets metadata from BOM metadata", () => {
+ const bom = {
+ bomFormat: "CycloneDX",
+ specVersion: "1.5",
+ metadata: {
+ timestamp: "2026-04-17T10:00:00Z",
+ tools: [{ name: "cdxgen", version: "10.0.0" }],
+ component: { type: "application", name: "my-ai-app" },
+ },
+ components: [{ type: "machine-learning-model", name: "test-model", version: "1.0" }],
+ };
+ const result = importCycloneDx(bom);
+ expect(result.aibom.metadata).toEqual({
+ importSource: "cyclonedx",
+ importTool: "cdxgen 10.0.0",
+ originalTimestamp: "2026-04-17T10:00:00Z",
+ });
+ });
+
+ it("omits importTool when no tools in metadata", () => {
+ const bom = {
+ bomFormat: "CycloneDX",
+ specVersion: "1.5",
+ components: [{ type: "machine-learning-model", name: "test-model", version: "1.0" }],
+ };
+ const result = importCycloneDx(bom);
+ expect(result.aibom.metadata).toEqual({
+ importSource: "cyclonedx",
+ });
+ });
+
+ it("includes source info in summary", () => {
+ const bom = {
+ bomFormat: "CycloneDX",
+ specVersion: "1.5",
+ metadata: {
+ timestamp: "2026-04-17T10:00:00Z",
+ tools: [{ name: "trivy", version: "0.50.0" }],
+ },
+ components: [],
+ };
+ const result = importCycloneDx(bom);
+ expect(result.summary.source.bomFormat).toBe("CycloneDX");
+ expect(result.summary.source.specVersion).toBe("1.5");
+ expect(result.summary.source.importTool).toBe("trivy 0.50.0");
+ expect(result.summary.source.originalTimestamp).toBe("2026-04-17T10:00:00Z");
+ });
+ });
+
+ describe("project name", () => {
+ it("uses metadata.component.name when available", () => {
+ const bom = {
+ bomFormat: "CycloneDX",
+ specVersion: "1.5",
+ metadata: {
+ component: { type: "application", name: "my-ai-app" },
+ },
+ components: [],
+ };
+ const result = importCycloneDx(bom);
+ expect(result.aibom.project.name).toBe("my-ai-app");
+ expect(result.summary.source.projectNameSource).toBe("metadata.component.name");
+ });
+
+ it("falls back to sourcePath basename", () => {
+ const bom = {
+ bomFormat: "CycloneDX",
+ specVersion: "1.5",
+ components: [],
+ };
+ const result = importCycloneDx(bom, { sourcePath: "/path/to/cyclonedx-bom.json" });
+ expect(result.aibom.project.name).toBe("cyclonedx-bom");
+ expect(result.summary.source.projectNameSource).toBe("sourcePath");
+ });
+
+ it("falls back to sbom-import when no metadata or sourcePath", () => {
+ const bom = {
+ bomFormat: "CycloneDX",
+ specVersion: "1.5",
+ components: [],
+ };
+ const result = importCycloneDx(bom);
+ expect(result.aibom.project.name).toBe("sbom-import");
+ expect(result.summary.source.projectNameSource).toBe("fallback");
+ });
+ });
+
+ describe("filteredByScope in summary", () => {
+ it("counts scope-filtered components", () => {
+ const bom = {
+ bomFormat: "CycloneDX",
+ specVersion: "1.5",
+ components: [
+ { type: "machine-learning-model", name: "prod", scope: "required" },
+ { type: "machine-learning-model", name: "opt", scope: "optional" },
+ { type: "machine-learning-model", name: "excl", scope: "excluded" },
+ { type: "library", name: "express", scope: "required" },
+ ],
+ };
+ const result = importCycloneDx(bom, { scope: "production" });
+ expect(result.summary.totalComponents).toBe(4);
+ expect(result.summary.filteredByScope).toBe(2);
+ expect(result.summary.aiRelevant).toBe(1);
+ });
+ });
+
+ describe("version fallback from purl", () => {
+ it("uses purl version when component version missing", () => {
+ const bom = {
+ bomFormat: "CycloneDX",
+ specVersion: "1.5",
+ components: [{ type: "library", name: "openai", purl: "pkg:pypi/openai@1.30.0" }],
+ };
+ const result = importCycloneDx(bom);
+ expect(result.aibom.components[0].version).toBe("1.30.0");
+ });
+ });
+});
diff --git a/packages/core/tests/sbom/purl.test.ts b/packages/core/tests/sbom/purl.test.ts
new file mode 100644
index 0000000..79a3905
--- /dev/null
+++ b/packages/core/tests/sbom/purl.test.ts
@@ -0,0 +1,102 @@
+import { describe, expect, it } from "vitest";
+import { parsePurl } from "../../src/sbom/purl";
+
+describe("parsePurl", () => {
+ it("parses a simple pypi purl", () => {
+ const result = parsePurl("pkg:pypi/transformers@4.38.0");
+ expect(result).toEqual({
+ scheme: "pkg",
+ type: "pypi",
+ namespace: undefined,
+ name: "transformers",
+ version: "4.38.0",
+ });
+ });
+
+ it("parses npm purl with namespace", () => {
+ const result = parsePurl("pkg:npm/%40langchain/core@0.1.22");
+ expect(result).toEqual({
+ scheme: "pkg",
+ type: "npm",
+ namespace: "@langchain",
+ name: "core",
+ version: "0.1.22",
+ });
+ });
+
+ it("parses npm purl with unencoded @ namespace", () => {
+ const result = parsePurl("pkg:npm/@huggingface/transformers@2.0.0");
+ expect(result).toEqual({
+ scheme: "pkg",
+ type: "npm",
+ namespace: "@huggingface",
+ name: "transformers",
+ version: "2.0.0",
+ });
+ });
+
+ it("parses generic purl without version", () => {
+ const result = parsePurl("pkg:generic/llama-3.2-1b");
+ expect(result).toEqual({
+ scheme: "pkg",
+ type: "generic",
+ namespace: undefined,
+ name: "llama-3.2-1b",
+ version: undefined,
+ });
+ });
+
+ it("parses purl with qualifiers (ignores them)", () => {
+ const result = parsePurl("pkg:npm/openai@1.12.0?vcs_url=github.com/openai");
+ expect(result).toEqual({
+ scheme: "pkg",
+ type: "npm",
+ namespace: undefined,
+ name: "openai",
+ version: "1.12.0",
+ });
+ });
+
+ it("parses purl with subpath (ignores it)", () => {
+ const result = parsePurl("pkg:pypi/torch@2.2.0#cuda");
+ expect(result).toEqual({
+ scheme: "pkg",
+ type: "pypi",
+ namespace: undefined,
+ name: "torch",
+ version: "2.2.0",
+ });
+ });
+
+ it("parses maven purl with deep namespace", () => {
+ const result = parsePurl("pkg:maven/org.tensorflow/tensorflow-core-api@0.5.0");
+ expect(result).toEqual({
+ scheme: "pkg",
+ type: "maven",
+ namespace: "org.tensorflow",
+ name: "tensorflow-core-api",
+ version: "0.5.0",
+ });
+ });
+
+ it("returns null for empty string", () => {
+ expect(parsePurl("")).toBeNull();
+ });
+
+ it("returns null for non-pkg scheme", () => {
+ expect(parsePurl("http://example.com")).toBeNull();
+ });
+
+ it("returns null for malformed purl", () => {
+ expect(parsePurl("pkg:")).toBeNull();
+ expect(parsePurl("pkg:/")).toBeNull();
+ });
+
+ it("returns null for malformed percent-encoding", () => {
+ expect(parsePurl("pkg:npm/%ZZ/bad-encoding@1.0.0")).toBeNull();
+ });
+
+ it("returns null for truncated percent-encoding", () => {
+ expect(parsePurl("pkg:npm/foo%2")).toBeNull();
+ });
+});
diff --git a/packages/core/tests/validation/schema-validator.test.ts b/packages/core/tests/validation/schema-validator.test.ts
new file mode 100644
index 0000000..d67aabe
--- /dev/null
+++ b/packages/core/tests/validation/schema-validator.test.ts
@@ -0,0 +1,219 @@
+import { describe, expect, it } from "vitest";
+import { validate } from "../../src/validation/schema-validator";
+
+describe("schema-validator", () => {
+ describe("auto-detection", () => {
+ it("detects report.v1 schema type", () => {
+ const result = validate({
+ schemaVersion: "euconform.report.v1",
+ generatedAt: "2026-01-01T00:00:00Z",
+ tool: { name: "euconform", version: "1.0.0" },
+ target: {
+ name: "test",
+ rootPath: "/test",
+ repoType: "unknown",
+ detectedStack: [],
+ },
+ aiFootprint: {
+ usesAI: false,
+ inferenceModes: [],
+ providerHints: [],
+ ragHints: [],
+ },
+ complianceSignals: {
+ disclosure: { status: "unknown", confidence: "low", evidence: [] },
+ biasTesting: { status: "unknown", confidence: "low", evidence: [] },
+ reportingExports: { status: "unknown", confidence: "low", evidence: [] },
+ loggingMonitoring: { status: "unknown", confidence: "low", evidence: [] },
+ humanOversight: { status: "unknown", confidence: "low", evidence: [] },
+ dataGovernance: { status: "unknown", confidence: "low", evidence: [] },
+ incidentReporting: { status: "unknown", confidence: "low", evidence: [] },
+ },
+ assessmentHints: {
+ possibleModes: [],
+ riskIndicators: [],
+ gpaiIndicators: [],
+ openQuestions: [],
+ },
+ gaps: [],
+ recommendationSummary: [],
+ });
+ expect(result.schemaType).toBe("report.v1");
+ expect(result.valid).toBe(true);
+ expect(result.errors).toEqual([]);
+ });
+
+ it("detects aibom.v1 schema type", () => {
+ const result = validate({
+ schemaVersion: "euconform.aibom.v1",
+ generatedAt: "2026-01-01T00:00:00Z",
+ project: { name: "test", rootPath: "/test" },
+ components: [],
+ complianceCapabilities: {
+ biasEvaluation: false,
+ jsonExport: true,
+ pdfExport: false,
+ loggingInfrastructure: false,
+ humanReviewFlow: false,
+ incidentHandling: false,
+ },
+ });
+ expect(result.schemaType).toBe("aibom.v1");
+ expect(result.valid).toBe(true);
+ });
+
+ it("returns error for missing schemaVersion", () => {
+ const result = validate({ foo: "bar" });
+ expect(result.valid).toBe(false);
+ expect(result.errors[0].message).toContain("schemaVersion");
+ });
+
+ it("returns error for unknown schemaVersion", () => {
+ const result = validate({ schemaVersion: "euconform.unknown.v9" });
+ expect(result.valid).toBe(false);
+ expect(result.errors[0].message).toContain("unknown");
+ });
+ });
+
+ describe("validation errors", () => {
+ it("reports missing required fields", () => {
+ const result = validate({
+ schemaVersion: "euconform.aibom.v1",
+ });
+ expect(result.valid).toBe(false);
+ expect(result.errors.length).toBeGreaterThan(0);
+ const paths = result.errors.map((e) => e.path);
+ expect(paths).toContain("");
+ });
+
+ it("reports invalid enum values", () => {
+ const result = validate({
+ schemaVersion: "euconform.aibom.v1",
+ generatedAt: "2026-01-01T00:00:00Z",
+ project: { name: "test", rootPath: "/test" },
+ components: [
+ {
+ id: "bad:component",
+ kind: "invalid-kind",
+ name: "Bad",
+ source: "package.json",
+ },
+ ],
+ complianceCapabilities: {
+ biasEvaluation: false,
+ jsonExport: false,
+ pdfExport: false,
+ loggingInfrastructure: false,
+ humanReviewFlow: false,
+ incidentHandling: false,
+ },
+ });
+ expect(result.valid).toBe(false);
+ expect(result.errors.some((e) => e.keyword === "enum")).toBe(true);
+ });
+
+ it("reports additional properties", () => {
+ const result = validate({
+ schemaVersion: "euconform.aibom.v1",
+ generatedAt: "2026-01-01T00:00:00Z",
+ project: { name: "test", rootPath: "/test" },
+ components: [],
+ complianceCapabilities: {
+ biasEvaluation: false,
+ jsonExport: false,
+ pdfExport: false,
+ loggingInfrastructure: false,
+ humanReviewFlow: false,
+ incidentHandling: false,
+ },
+ extraField: "should fail",
+ });
+ expect(result.valid).toBe(false);
+ expect(result.errors.some((e) => e.keyword === "additionalProperties")).toBe(true);
+ });
+
+ it("accepts sbom-import as valid source", () => {
+ const result = validate({
+ schemaVersion: "euconform.aibom.v1",
+ generatedAt: "2026-01-01T00:00:00Z",
+ project: { name: "test", rootPath: "/test" },
+ components: [
+ {
+ id: "ai-framework:torch",
+ kind: "ai-framework",
+ name: "torch",
+ version: "2.0.0",
+ source: "sbom-import",
+ },
+ ],
+ complianceCapabilities: {
+ biasEvaluation: false,
+ jsonExport: false,
+ pdfExport: false,
+ loggingInfrastructure: false,
+ humanReviewFlow: false,
+ incidentHandling: false,
+ },
+ });
+ expect(result.valid).toBe(true);
+ });
+
+ it("accepts aibom v1.1 with metadata field", () => {
+ const result = validate({
+ schemaVersion: "euconform.aibom.v1.1",
+ generatedAt: "2026-01-01T00:00:00Z",
+ project: { name: "test", rootPath: "/test" },
+ components: [],
+ complianceCapabilities: {
+ biasEvaluation: false,
+ jsonExport: false,
+ pdfExport: false,
+ loggingInfrastructure: false,
+ humanReviewFlow: false,
+ incidentHandling: false,
+ },
+ metadata: {
+ importSource: "cyclonedx",
+ importTool: "cdxgen 10.0.0",
+ originalTimestamp: "2026-04-19T10:00:00Z",
+ },
+ });
+ expect(result.valid).toBe(true);
+ });
+
+ it("rejects aibom v1 with metadata field", () => {
+ const result = validate({
+ schemaVersion: "euconform.aibom.v1",
+ generatedAt: "2026-01-01T00:00:00Z",
+ project: { name: "test", rootPath: "/test" },
+ components: [],
+ complianceCapabilities: {
+ biasEvaluation: false,
+ jsonExport: false,
+ pdfExport: false,
+ loggingInfrastructure: false,
+ humanReviewFlow: false,
+ incidentHandling: false,
+ },
+ metadata: {
+ importSource: "cyclonedx",
+ },
+ });
+ expect(result.valid).toBe(false);
+ });
+ });
+
+ describe("ValidationResult structure", () => {
+ it("returns structured errors with path, message, keyword", () => {
+ const result = validate({
+ schemaVersion: "euconform.aibom.v1",
+ });
+ expect(result.valid).toBe(false);
+ for (const error of result.errors) {
+ expect(error).toHaveProperty("path");
+ expect(error).toHaveProperty("message");
+ expect(error).toHaveProperty("keyword");
+ }
+ });
+ });
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7ed7fe5..fcaaac7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -206,6 +206,12 @@ importers:
'@xenova/transformers':
specifier: ^2.17.2
version: 2.17.2
+ ajv:
+ specifier: ^8.18.0
+ version: 8.18.0
+ ajv-formats:
+ specifier: ^3.0.1
+ version: 3.0.1(ajv@8.18.0)
ignore:
specifier: ^7.0.3
version: 7.0.5
@@ -1924,6 +1930,17 @@ packages:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
+ ajv-formats@3.0.1:
+ resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
+ peerDependencies:
+ ajv: ^8.0.0
+ peerDependenciesMeta:
+ ajv:
+ optional: true
+
+ ajv@8.18.0:
+ resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
+
ansi-colors@4.1.3:
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
engines: {node: '>=6'}
@@ -2325,6 +2342,9 @@ packages:
extendable-error@0.1.7:
resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==}
+ fast-deep-equal@3.1.3:
+ resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
+
fast-fifo@1.3.2:
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
@@ -2332,6 +2352,9 @@ packages:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
+ fast-uri@3.1.0:
+ resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
+
fastq@1.19.1:
resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
@@ -2589,6 +2612,9 @@ packages:
engines: {node: '>=6'}
hasBin: true
+ json-schema-traverse@1.0.0:
+ resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
+
json5@2.2.3:
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
engines: {node: '>=6'}
@@ -3111,6 +3137,10 @@ packages:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
+ require-from-string@2.0.2:
+ resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
+ engines: {node: '>=0.10.0'}
+
resolve-from@5.0.0:
resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
engines: {node: '>=8'}
@@ -5086,6 +5116,17 @@ snapshots:
agent-base@7.1.4: {}
+ ajv-formats@3.0.1(ajv@8.18.0):
+ optionalDependencies:
+ ajv: 8.18.0
+
+ ajv@8.18.0:
+ dependencies:
+ fast-deep-equal: 3.1.3
+ fast-uri: 3.1.0
+ json-schema-traverse: 1.0.0
+ require-from-string: 2.0.2
+
ansi-colors@4.1.3: {}
ansi-escapes@7.2.0:
@@ -5483,6 +5524,8 @@ snapshots:
extendable-error@0.1.7: {}
+ fast-deep-equal@3.1.3: {}
+
fast-fifo@1.3.2: {}
fast-glob@3.3.3:
@@ -5493,6 +5536,8 @@ snapshots:
merge2: 1.4.1
micromatch: 4.0.8
+ fast-uri@3.1.0: {}
+
fastq@1.19.1:
dependencies:
reusify: 1.1.0
@@ -5734,6 +5779,8 @@ snapshots:
jsesc@3.1.0: {}
+ json-schema-traverse@1.0.0: {}
+
json5@2.2.3: {}
jsonfile@4.0.0:
@@ -6217,6 +6264,8 @@ snapshots:
require-directory@2.1.1: {}
+ require-from-string@2.0.2: {}
+
resolve-from@5.0.0: {}
restore-cursor@5.1.0: