diff --git a/docs/ARCHITECTURE-BLUEPRINT.md b/docs/ARCHITECTURE-BLUEPRINT.md index 2e8d2c9..aecc8a5 100644 --- a/docs/ARCHITECTURE-BLUEPRINT.md +++ b/docs/ARCHITECTURE-BLUEPRINT.md @@ -363,6 +363,10 @@ Maps evidence to: Mappings belong to extensions or assessment packs, not the core. +The first implementation loads extension-owned JSON mapping sets from +`extensions//mappings/`, joins them to evidence `requirement_refs`, +and writes normalized mapping records under each run directory. + ### Expectation And Waiver Engine Applies declared target posture after evidence normalization. diff --git a/docs/EXTENSION-SDK.md b/docs/EXTENSION-SDK.md index 997e601..17ebd80 100644 --- a/docs/EXTENSION-SDK.md +++ b/docs/EXTENSION-SDK.md @@ -51,6 +51,7 @@ The key runtime fields are: - `check_groups`: named groups that assessment profiles can select. - `preflight_runner`: optional runner ID used before selected check groups. - `runner_entrypoints`: concrete runner declarations. +- `mappings`: mapping set IDs under `mappings/.json`. - `certification_boundary`: explicit statement of what the extension does not certify. @@ -102,6 +103,30 @@ Command placeholders: The command is executed with the extension directory as its working directory. The core does not use a shell for command runners. +## Mapping Sets + +Mapping sets connect normalized evidence requirement refs to capability groups, +controls, conformance classes, quality dimensions, or other assessment targets. + +Each mapping set lives under: + +```text +extensions//mappings/.json +``` + +and validates against: + +```text +docs/schemas/mapping-set.schema.json +``` + +The core does not embed domain policy. It only joins evidence `requirement_refs` +to extension-owned mappings and writes normalized mapping records to: + +```text +runs//normalized/mappings.json +``` + ## Python Runner Contract A Python runner receives one context object and returns one result object. @@ -167,6 +192,6 @@ Initial statuses: ## Next SDK Steps -- Add normalizer and mapping plug-in contracts. +- Add normalizer plug-in contracts. - Add extension-owned schema validation for domain-specific target profile fields. diff --git a/docs/schemas/assessment-package.schema.json b/docs/schemas/assessment-package.schema.json index bfbbcce..726a7b9 100644 --- a/docs/schemas/assessment-package.schema.json +++ b/docs/schemas/assessment-package.schema.json @@ -11,6 +11,7 @@ "extensions", "source_lock", "summary", + "mapping_summary", "findings", "evidence_refs", "artifact_manifest", @@ -26,6 +27,7 @@ "extensions": { "type": "array", "items": { "type": "object" } }, "source_lock": { "type": "object" }, "summary": { "type": "object" }, + "mapping_summary": { "type": "object" }, "findings": { "type": "array", "items": { "type": "object" } }, "evidence_refs": { "type": "array", "items": { "type": "string" } }, "artifact_manifest": { "type": "array", "items": { "type": "object" } }, diff --git a/docs/schemas/mapping-set.schema.json b/docs/schemas/mapping-set.schema.json new file mode 100644 index 0000000..c718993 --- /dev/null +++ b/docs/schemas/mapping-set.schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Guide Board Mapping Set", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "extension_id", + "framework_refs", + "mappings" + ], + "properties": { + "id": { "type": "string" }, + "extension_id": { "type": "string" }, + "framework_refs": { "type": "array", "items": { "type": "string" } }, + "mappings": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "requirement_ref", + "target_type", + "target_id", + "label", + "description" + ], + "properties": { + "requirement_ref": { "type": "string" }, + "target_type": { "type": "string" }, + "target_id": { "type": "string" }, + "label": { "type": "string" }, + "description": { "type": "string" } + } + } + } + } +} diff --git a/extensions/open-cmis-tck/mappings/cmis-capability-map.json b/extensions/open-cmis-tck/mappings/cmis-capability-map.json new file mode 100644 index 0000000..d106a61 --- /dev/null +++ b/extensions/open-cmis-tck/mappings/cmis-capability-map.json @@ -0,0 +1,65 @@ +{ + "id": "cmis-capability-map", + "extension_id": "open-cmis-tck", + "framework_refs": [ + "cmis.browser-binding.compatibility.v1" + ], + "mappings": [ + { + "requirement_ref": "cmis.repository-info", + "target_type": "capability_group", + "target_id": "repository-type", + "label": "Repository And Type Metadata", + "description": "Repository identity, repository information, and supported CMIS metadata posture." + }, + { + "requirement_ref": "cmis.type-definitions", + "target_type": "capability_group", + "target_id": "repository-type", + "label": "Repository And Type Metadata", + "description": "Type definition exposure and type metadata consistency." + }, + { + "requirement_ref": "cmis.object-services", + "target_type": "capability_group", + "target_id": "object-content", + "label": "Object And Content Services", + "description": "Object service behavior for folders, documents, properties, and object lifecycle operations." + }, + { + "requirement_ref": "cmis.content-streams", + "target_type": "capability_group", + "target_id": "object-content", + "label": "Object And Content Services", + "description": "Content stream creation, retrieval, update, and deletion behavior." + }, + { + "requirement_ref": "cmis.navigation-services", + "target_type": "capability_group", + "target_id": "navigation", + "label": "Navigation Services", + "description": "Folder tree, descendants, children, checked-out documents, and relationship navigation behavior." + }, + { + "requirement_ref": "cmis.query", + "target_type": "capability_group", + "target_id": "query", + "label": "Query", + "description": "CMIS query support, query capability flags, and query result behavior." + }, + { + "requirement_ref": "cmis.acl", + "target_type": "capability_group", + "target_id": "acl-policy", + "label": "ACL And Policy", + "description": "Access-control list and policy behavior where supported by the target profile." + }, + { + "requirement_ref": "cmis.versioning", + "target_type": "capability_group", + "target_id": "versioning", + "label": "Versioning", + "description": "Checkout, checkin, version series, and version-specific object behavior." + } + ] +} diff --git a/extensions/open-cmis-tck/workplans/OPEN-CMIS-TCK-WP-0001-harness-foundation.md b/extensions/open-cmis-tck/workplans/OPEN-CMIS-TCK-WP-0001-harness-foundation.md index c71e4ad..477ba5b 100644 --- a/extensions/open-cmis-tck/workplans/OPEN-CMIS-TCK-WP-0001-harness-foundation.md +++ b/extensions/open-cmis-tck/workplans/OPEN-CMIS-TCK-WP-0001-harness-foundation.md @@ -157,7 +157,7 @@ Acceptance: ```task id: OPEN-CMIS-TCK-WP-0001-T006 -status: todo +status: in_progress priority: high state_hub_task_id: "9f7dacc5-4d19-4755-aa9a-8572d4285514" ``` @@ -171,6 +171,14 @@ Acceptance: - Markdown reports summarize pass/fail/skip counts and unexpected gaps. - Known gaps do not hide unexpected failures in the same capability area. +Progress: + +- `cmis-capability-map` maps initial CMIS requirement refs to repository/type, + object/content, navigation, query, ACL/policy, and versioning capability + groups. +- Guide-board writes normalized mapping records and includes capability-group + counts in Markdown reports. + ## D1.7 - Optional Local Service API Adapter ```task diff --git a/extensions/sample-noop/extension.json b/extensions/sample-noop/extension.json index c8aa453..a8b75d3 100644 --- a/extensions/sample-noop/extension.json +++ b/extensions/sample-noop/extension.json @@ -28,7 +28,9 @@ "preflight_runner": null, "runner_entrypoints": [], "normalizers": [], - "mappings": [], + "mappings": [ + "sample-readiness-map" + ], "report_fragments": [], "dependencies": [], "restricted_assets": [], diff --git a/extensions/sample-noop/mappings/sample-readiness-map.json b/extensions/sample-noop/mappings/sample-readiness-map.json new file mode 100644 index 0000000..fb2b3ee --- /dev/null +++ b/extensions/sample-noop/mappings/sample-readiness-map.json @@ -0,0 +1,16 @@ +{ + "id": "sample-readiness-map", + "extension_id": "sample-noop", + "framework_refs": [ + "guide-board.sample-readiness.v0" + ], + "mappings": [ + { + "requirement_ref": "guide-board.sample-readiness.v0.profile-shape", + "target_type": "quality_dimension", + "target_id": "profile-readiness", + "label": "Profile Readiness", + "description": "The sample target and assessment profiles can be discovered, validated, and planned." + } + ] +} diff --git a/src/guide_board/execution.py b/src/guide_board/execution.py index 911679f..8d045ce 100644 --- a/src/guide_board/execution.py +++ b/src/guide_board/execution.py @@ -9,6 +9,7 @@ from typing import Any from guide_board.artifacts import build_artifact_manifest from guide_board.io import write_json +from guide_board.mapping import build_mapping_records, summarize_mappings from guide_board.planning import build_run_plan from guide_board.runners import run_step from guide_board.schema import assert_valid @@ -37,6 +38,8 @@ def run_assessment( assert_valid(finding, "finding") artifact_manifest = build_artifact_manifest(run_dir, run_id, evidence) + mapping_records = build_mapping_records(root, run_id, plan, evidence) + mapping_summary = summarize_mappings(mapping_records) assessment_package = _assessment_package( run_id, @@ -44,6 +47,7 @@ def run_assessment( evidence, findings, artifact_manifest, + mapping_summary, created_at, ) assert_valid(assessment_package, "assessment-package") @@ -57,7 +61,15 @@ def run_assessment( "assessment_profile_ref": plan["assessment_profile_snapshot"]["id"], } - _write_run_directory(run_dir, run_metadata, plan, evidence, findings, assessment_package) + _write_run_directory( + run_dir, + run_metadata, + plan, + evidence, + findings, + mapping_records, + assessment_package, + ) return { "status": run_metadata["status"], "run_id": run_id, @@ -175,6 +187,7 @@ def _assessment_package( evidence: list[dict[str, Any]], findings: list[dict[str, Any]], artifact_manifest: list[dict[str, Any]], + mapping_summary: dict[str, Any], created_at: str, ) -> dict[str, Any]: summary = dict(Counter(item["result"] for item in evidence)) @@ -188,6 +201,7 @@ def _assessment_package( "extensions": plan["extension_snapshots"], "source_lock": plan["source_lock"], "summary": summary, + "mapping_summary": mapping_summary, "findings": findings, "evidence_refs": [item["id"] for item in evidence], "artifact_manifest": artifact_manifest, @@ -203,6 +217,7 @@ def _write_run_directory( plan: dict[str, Any], evidence: list[dict[str, Any]], findings: list[dict[str, Any]], + mapping_records: list[dict[str, Any]], assessment_package: dict[str, Any], ) -> None: write_json(run_dir / "run.json", run_metadata) @@ -215,7 +230,7 @@ def _write_run_directory( ) write_json(run_dir / "normalized" / "evidence.json", {"evidence": evidence}) write_json(run_dir / "normalized" / "findings.json", {"findings": findings}) - write_json(run_dir / "normalized" / "mappings.json", {"mappings": []}) + write_json(run_dir / "normalized" / "mappings.json", {"mappings": mapping_records}) write_json(run_dir / "reports" / "assessment-package.json", assessment_package) (run_dir / "reports").mkdir(parents=True, exist_ok=True) (run_dir / "reports" / "report.md").write_text( @@ -230,6 +245,7 @@ def _markdown_report(run_metadata: dict[str, Any], package: dict[str, Any]) -> s ) if not summary_lines: summary_lines = "- no evidence produced" + mapping_lines = _mapping_summary_lines(package) return "\n".join( [ @@ -243,6 +259,10 @@ def _markdown_report(run_metadata: dict[str, Any], package: dict[str, Any]) -> s "", summary_lines, "", + "## Mappings", + "", + mapping_lines, + "", "## Boundary", "", package["certification_boundary"], @@ -251,6 +271,20 @@ def _markdown_report(run_metadata: dict[str, Any], package: dict[str, Any]) -> s ) +def _mapping_summary_lines(package: dict[str, Any]) -> str: + targets = package.get("mapping_summary", {}).get("targets", []) + if not targets: + return "- no mapped evidence" + lines = [] + for target in targets: + results = ", ".join( + f"{status}: {count}" + for status, count in sorted(target.get("results", {}).items()) + ) + lines.append(f"- {target['label']} ({target['target_id']}): {results}") + return "\n".join(lines) + + def _run_status(evidence: list[dict[str, Any]]) -> str: if any(item["result"] == "fail" for item in evidence): return "failed" diff --git a/src/guide_board/mapping.py b/src/guide_board/mapping.py new file mode 100644 index 0000000..802d712 --- /dev/null +++ b/src/guide_board/mapping.py @@ -0,0 +1,103 @@ +"""Evidence-to-capability/control mapping.""" + +from __future__ import annotations + +from collections import defaultdict +from pathlib import Path +from typing import Any + +from guide_board.io import load_json +from guide_board.schema import assert_valid + + +def build_mapping_records( + root: Path, + run_id: str, + plan: dict[str, Any], + evidence: list[dict[str, Any]], +) -> list[dict[str, Any]]: + index = _mapping_index(root, plan) + records: list[dict[str, Any]] = [] + for item in evidence: + extension_id = item["extension_id"] + for requirement_ref in item.get("requirement_refs", []): + mappings = index.get((extension_id, requirement_ref), []) + for mapping in mappings: + records.append( + { + "id": _record_id(item["id"], mapping), + "run_id": run_id, + "evidence_id": item["id"], + "check_id": item["check_id"], + "extension_id": extension_id, + "requirement_ref": requirement_ref, + "result": item["result"], + "target_type": mapping["target_type"], + "target_id": mapping["target_id"], + "label": mapping["label"], + "description": mapping["description"], + } + ) + return records + + +def summarize_mappings(mapping_records: list[dict[str, Any]]) -> dict[str, Any]: + targets: dict[tuple[str, str], dict[str, Any]] = {} + for record in mapping_records: + key = (record["target_type"], record["target_id"]) + if key not in targets: + targets[key] = { + "target_type": record["target_type"], + "target_id": record["target_id"], + "label": record["label"], + "results": {}, + "requirement_refs": [], + } + target = targets[key] + target["results"][record["result"]] = target["results"].get(record["result"], 0) + 1 + if record["requirement_ref"] not in target["requirement_refs"]: + target["requirement_refs"].append(record["requirement_ref"]) + return { + "targets": sorted( + targets.values(), + key=lambda item: (item["target_type"], item["target_id"]), + ) + } + + +def _mapping_index( + root: Path, + plan: dict[str, Any], +) -> dict[tuple[str, str], list[dict[str, Any]]]: + by_requirement: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list) + for extension in plan["extension_snapshots"]: + extension_path = root / extension["path"] + manifest = load_json(extension_path / "extension.json") + for mapping_id in manifest.get("mappings", []): + mapping_path = extension_path / "mappings" / f"{mapping_id}.json" + if not mapping_path.exists(): + continue + mapping_set = load_json(mapping_path) + assert_valid(mapping_set, "mapping-set") + for mapping in mapping_set["mappings"]: + by_requirement[ + (mapping_set["extension_id"], mapping["requirement_ref"]) + ].append(mapping) + return by_requirement + + +def _record_id(evidence_id: str, mapping: dict[str, Any]) -> str: + return "mapping:" + _safe_id( + ":".join( + [ + evidence_id, + mapping["requirement_ref"], + mapping["target_type"], + mapping["target_id"], + ] + ) + ) + + +def _safe_id(value: str) -> str: + return "".join(char if char.isalnum() or char in {"-", "_"} else "_" for char in value) diff --git a/tests/test_core.py b/tests/test_core.py index c57b861..5491d91 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -81,6 +81,11 @@ class CoreArchitectureTests(unittest.TestCase): self.assertTrue((run_dir / "normalized" / "evidence.json").exists()) self.assertTrue((run_dir / "reports" / "assessment-package.json").exists()) self.assertTrue((run_dir / "reports" / "report.md").exists()) + mappings = json.loads( + (run_dir / "normalized" / "mappings.json").read_text(encoding="utf-8") + )["mappings"] + self.assertEqual(len(mappings), 1) + self.assertEqual(mappings[0]["target_id"], "profile-readiness") def test_runs_cmis_preflight_against_local_endpoint(self) -> None: server = HTTPServer(("127.0.0.1", 0), _CmisHandler) @@ -158,6 +163,11 @@ class CoreArchitectureTests(unittest.TestCase): encoding="utf-8" ) ) + mappings = json.loads( + (Path(result["run_dir"]) / "normalized" / "mappings.json").read_text( + encoding="utf-8" + ) + )["mappings"] self.assertEqual(result["status"], "completed") self.assertEqual(evidence["evidence"][0]["result"], "pass") @@ -173,6 +183,7 @@ class CoreArchitectureTests(unittest.TestCase): ["local-test-repository"], ) self.assertEqual(len(package["artifact_manifest"]), 2) + self.assertEqual(mappings, []) self.assertTrue( ( Path(result["run_dir"]) @@ -270,6 +281,11 @@ class CoreArchitectureTests(unittest.TestCase): encoding="utf-8" ) ) + mappings = json.loads( + (Path(result["run_dir"]) / "normalized" / "mappings.json").read_text( + encoding="utf-8" + ) + )["mappings"] self.assertEqual(result["status"], "blocked") self.assertEqual(evidence[0]["result"], "pass") @@ -284,6 +300,15 @@ class CoreArchitectureTests(unittest.TestCase): evidence[1]["facts"]["blocked_reason"], ) self.assertGreaterEqual(len(package["artifact_manifest"]), 3) + self.assertEqual(len(mappings), 2) + self.assertEqual( + {mapping["target_id"] for mapping in mappings}, + {"repository-type"}, + ) + self.assertEqual( + package["mapping_summary"]["targets"][0]["results"], + {"blocked": 2}, + ) finally: server.shutdown() thread.join(timeout=5) diff --git a/workplans/GUIDE-BOARD-WP-0001-bootstrapping.md b/workplans/GUIDE-BOARD-WP-0001-bootstrapping.md index 65bd865..5f98d46 100644 --- a/workplans/GUIDE-BOARD-WP-0001-bootstrapping.md +++ b/workplans/GUIDE-BOARD-WP-0001-bootstrapping.md @@ -208,6 +208,8 @@ Acceptance: normalized evidence through the same runner result contract. - Runner artifact refs are constrained to the run directory and fingerprinted in the assessment package artifact manifest. +- Extension-owned mapping sets connect evidence requirement refs to capability, + control, conformance, or quality targets. ## D1.8 - CMIS Seed Extension Integration @@ -233,6 +235,8 @@ Progress: - The OpenCMIS Java/Maven TCK wrapper executes through the command runner bridge and currently reports dependency or configuration blockers as structured evidence. +- CMIS requirement refs map to extension-owned capability groups in + `normalized/mappings.json` and the Markdown report. ## D1.9 - Containerized Execution Design