From be3ab87c6af01c70b1554dabd0a1d55c1b14e087 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 7 May 2026 11:56:14 +0200 Subject: [PATCH] first working guide-board architecture core --- README.md | 21 ++ docs/schemas/assessment-package.schema.json | 36 +++ docs/schemas/assessment-profile.schema.json | 35 +++ docs/schemas/authority.schema.json | 28 +++ docs/schemas/check-definition.schema.json | 30 +++ docs/schemas/evidence-item.schema.json | 50 +++++ docs/schemas/extension-manifest.schema.json | 71 ++++++ docs/schemas/finding.schema.json | 30 +++ docs/schemas/framework.schema.json | 28 +++ docs/schemas/raw-artifact.schema.json | 26 +++ docs/schemas/retention-summary.schema.json | 26 +++ docs/schemas/run-plan.schema.json | 28 +++ docs/schemas/target-profile.schema.json | 55 +++++ docs/schemas/waiver.schema.json | 28 +++ extensions/_template/extension.json | 22 ++ extensions/open-cmis-tck/extension.json | 80 +++++++ extensions/sample-noop/INTENT.md | 14 ++ extensions/sample-noop/extension.json | 36 +++ .../assessments/cmis-browser-baseline.json | 33 +++ profiles/assessments/sample-noop.json | 32 +++ profiles/targets/kontextual-cmis-compat.json | 42 ++++ profiles/targets/sample-repository.json | 19 ++ pyproject.toml | 21 ++ src/guide_board/__init__.py | 3 + src/guide_board/__main__.py | 5 + src/guide_board/cli.py | 138 ++++++++++++ src/guide_board/discovery.py | 48 ++++ src/guide_board/errors.py | 13 ++ src/guide_board/execution.py | 211 ++++++++++++++++++ src/guide_board/io.py | 22 ++ src/guide_board/planning.py | 109 +++++++++ src/guide_board/schema.py | 108 +++++++++ tests/test_core.py | 84 +++++++ .../GUIDE-BOARD-WP-0001-bootstrapping.md | 6 +- 34 files changed, 1536 insertions(+), 2 deletions(-) create mode 100644 docs/schemas/assessment-package.schema.json create mode 100644 docs/schemas/assessment-profile.schema.json create mode 100644 docs/schemas/authority.schema.json create mode 100644 docs/schemas/check-definition.schema.json create mode 100644 docs/schemas/evidence-item.schema.json create mode 100644 docs/schemas/extension-manifest.schema.json create mode 100644 docs/schemas/finding.schema.json create mode 100644 docs/schemas/framework.schema.json create mode 100644 docs/schemas/raw-artifact.schema.json create mode 100644 docs/schemas/retention-summary.schema.json create mode 100644 docs/schemas/run-plan.schema.json create mode 100644 docs/schemas/target-profile.schema.json create mode 100644 docs/schemas/waiver.schema.json create mode 100644 extensions/_template/extension.json create mode 100644 extensions/open-cmis-tck/extension.json create mode 100644 extensions/sample-noop/INTENT.md create mode 100644 extensions/sample-noop/extension.json create mode 100644 profiles/assessments/cmis-browser-baseline.json create mode 100644 profiles/assessments/sample-noop.json create mode 100644 profiles/targets/kontextual-cmis-compat.json create mode 100644 profiles/targets/sample-repository.json create mode 100644 pyproject.toml create mode 100644 src/guide_board/__init__.py create mode 100644 src/guide_board/__main__.py create mode 100644 src/guide_board/cli.py create mode 100644 src/guide_board/discovery.py create mode 100644 src/guide_board/errors.py create mode 100644 src/guide_board/execution.py create mode 100644 src/guide_board/io.py create mode 100644 src/guide_board/planning.py create mode 100644 src/guide_board/schema.py create mode 100644 tests/test_core.py diff --git a/README.md b/README.md index 6cee836..51be610 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,27 @@ evidence that can be reviewed, repeated, compared, and used during assessments. The root project owns the framework contracts. Domain-specific work lives in extensions. +## Local Baseline + +The first core is intentionally dependency-light. From a clean checkout: + +```sh +PYTHONPATH=src python3 -m guide_board extensions list +PYTHONPATH=src python3 -m guide_board extensions validate +PYTHONPATH=src python3 -m guide_board profile validate-target profiles/targets/sample-repository.json +PYTHONPATH=src python3 -m guide_board profile validate-assessment profiles/assessments/sample-noop.json +PYTHONPATH=src python3 -m guide_board plan \ + --target profiles/targets/sample-repository.json \ + --assessment profiles/assessments/sample-noop.json +PYTHONPATH=src python3 -m guide_board run \ + --target profiles/targets/sample-repository.json \ + --assessment profiles/assessments/sample-noop.json +PYTHONPATH=src python3 -m unittest discover -s tests +``` + +The `sample-noop` extension exercises the guide-board contracts without invoking +an external harness. `open-cmis-tck` is the first real seed extension. + See: - [INTENT.md](INTENT.md) diff --git a/docs/schemas/assessment-package.schema.json b/docs/schemas/assessment-package.schema.json new file mode 100644 index 0000000..bfbbcce --- /dev/null +++ b/docs/schemas/assessment-package.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Guide Board Assessment Package", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "run_id", + "target", + "frameworks", + "extensions", + "source_lock", + "summary", + "findings", + "evidence_refs", + "artifact_manifest", + "waivers", + "certification_boundary", + "created_at" + ], + "properties": { + "id": { "type": "string" }, + "run_id": { "type": "string" }, + "target": { "type": "object" }, + "frameworks": { "type": "array", "items": { "type": "object" } }, + "extensions": { "type": "array", "items": { "type": "object" } }, + "source_lock": { "type": "object" }, + "summary": { "type": "object" }, + "findings": { "type": "array", "items": { "type": "object" } }, + "evidence_refs": { "type": "array", "items": { "type": "string" } }, + "artifact_manifest": { "type": "array", "items": { "type": "object" } }, + "waivers": { "type": "array", "items": { "type": "object" } }, + "certification_boundary": { "type": "string" }, + "created_at": { "type": "string" } + } +} diff --git a/docs/schemas/assessment-profile.schema.json b/docs/schemas/assessment-profile.schema.json new file mode 100644 index 0000000..c481374 --- /dev/null +++ b/docs/schemas/assessment-profile.schema.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Guide Board Assessment Profile", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "framework_refs", + "extension_refs", + "target_profile_ref", + "selected_check_groups", + "expectations_ref", + "waivers_ref", + "output_policy", + "retention_policy" + ], + "properties": { + "id": { "type": "string" }, + "framework_refs": { "type": "array", "items": { "type": "string" } }, + "extension_refs": { "type": "array", "items": { "type": "string" } }, + "target_profile_ref": { "type": "string" }, + "selected_check_groups": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { "type": "string" } + } + }, + "expectations_ref": { "type": ["string", "null"] }, + "waivers_ref": { "type": ["string", "null"] }, + "output_policy": { "type": "object" }, + "retention_policy": { "type": "object" }, + "runtime_policy": { "type": "object" } + } +} diff --git a/docs/schemas/authority.schema.json b/docs/schemas/authority.schema.json new file mode 100644 index 0000000..9fefc59 --- /dev/null +++ b/docs/schemas/authority.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Guide Board Authority", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "authority_type", + "source_urls", + "frameworks", + "license_posture", + "access_constraints", + "certification_boundary", + "lifecycle_status" + ], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "authority_type": { "type": "string" }, + "source_urls": { "type": "array", "items": { "type": "string" } }, + "frameworks": { "type": "array", "items": { "type": "string" } }, + "license_posture": { "type": "string" }, + "access_constraints": { "type": "string" }, + "certification_boundary": { "type": "string" }, + "lifecycle_status": { "type": "string" } + } +} diff --git a/docs/schemas/check-definition.schema.json b/docs/schemas/check-definition.schema.json new file mode 100644 index 0000000..b01c614 --- /dev/null +++ b/docs/schemas/check-definition.schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Guide Board Check Definition", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "extension_id", + "check_type", + "framework_refs", + "requirement_refs", + "inputs", + "preconditions", + "timeout", + "runner_ref", + "expected_artifacts" + ], + "properties": { + "id": { "type": "string" }, + "extension_id": { "type": "string" }, + "check_type": { "type": "string" }, + "framework_refs": { "type": "array", "items": { "type": "string" } }, + "requirement_refs": { "type": "array", "items": { "type": "string" } }, + "inputs": { "type": "object" }, + "preconditions": { "type": "array", "items": { "type": "string" } }, + "timeout": { "type": "integer" }, + "runner_ref": { "type": ["string", "null"] }, + "expected_artifacts": { "type": "array", "items": { "type": "string" } } + } +} diff --git a/docs/schemas/evidence-item.schema.json b/docs/schemas/evidence-item.schema.json new file mode 100644 index 0000000..e4412a3 --- /dev/null +++ b/docs/schemas/evidence-item.schema.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Guide Board Evidence Item", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "run_id", + "extension_id", + "check_id", + "subject_ref", + "result", + "observations", + "facts", + "requirement_refs", + "artifact_refs", + "started_at", + "completed_at" + ], + "properties": { + "id": { "type": "string" }, + "run_id": { "type": "string" }, + "extension_id": { "type": "string" }, + "check_id": { "type": "string" }, + "subject_ref": { "type": "string" }, + "result": { + "type": "string", + "enum": [ + "pass", + "fail", + "warning", + "manual", + "not_applicable", + "skipped", + "expected_gap", + "waiver_applied", + "unsupported_by_design", + "infrastructure_error", + "blocked", + "unknown" + ] + }, + "observations": { "type": "array", "items": { "type": "string" } }, + "facts": { "type": "object" }, + "requirement_refs": { "type": "array", "items": { "type": "string" } }, + "artifact_refs": { "type": "array", "items": { "type": "string" } }, + "started_at": { "type": "string" }, + "completed_at": { "type": "string" } + } +} diff --git a/docs/schemas/extension-manifest.schema.json b/docs/schemas/extension-manifest.schema.json new file mode 100644 index 0000000..3c100cd --- /dev/null +++ b/docs/schemas/extension-manifest.schema.json @@ -0,0 +1,71 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Guide Board Extension Manifest", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "version", + "extension_type", + "lifecycle_status", + "supported_frameworks", + "authorities", + "profile_schemas", + "check_groups", + "runner_entrypoints", + "normalizers", + "mappings", + "report_fragments", + "dependencies", + "restricted_assets", + "certification_boundary" + ], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "version": { "type": "string" }, + "extension_type": { + "type": "string", + "enum": [ + "executable_harness", + "validator", + "protocol_service", + "hosted_suite", + "repository_quality", + "procedural_evidence", + "hybrid" + ] + }, + "lifecycle_status": { + "type": "string", + "enum": ["candidate", "incubating", "active", "external", "deprecated"] + }, + "supported_frameworks": { "type": "array", "items": { "type": "string" } }, + "authorities": { "type": "array", "items": { "type": "string" } }, + "profile_schemas": { "type": "array", "items": { "type": "string" } }, + "check_groups": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "check_type", "requirement_refs", "runner_ref"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "check_type": { "type": "string" }, + "requirement_refs": { "type": "array", "items": { "type": "string" } }, + "runner_ref": { "type": ["string", "null"] } + } + } + }, + "preflight_runner": { "type": ["string", "null"] }, + "runner_entrypoints": { "type": "array", "items": { "type": "string" } }, + "normalizers": { "type": "array", "items": { "type": "string" } }, + "mappings": { "type": "array", "items": { "type": "string" } }, + "report_fragments": { "type": "array", "items": { "type": "string" } }, + "dependencies": { "type": "array", "items": { "type": "string" } }, + "restricted_assets": { "type": "array", "items": { "type": "string" } }, + "certification_boundary": { "type": "string" } + } +} diff --git a/docs/schemas/finding.schema.json b/docs/schemas/finding.schema.json new file mode 100644 index 0000000..77c419b --- /dev/null +++ b/docs/schemas/finding.schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Guide Board Finding", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "run_id", + "status", + "severity", + "classification", + "requirement_refs", + "evidence_refs", + "expected", + "waiver_ref", + "remediation" + ], + "properties": { + "id": { "type": "string" }, + "run_id": { "type": "string" }, + "status": { "type": "string" }, + "severity": { "type": "string" }, + "classification": { "type": "string" }, + "requirement_refs": { "type": "array", "items": { "type": "string" } }, + "evidence_refs": { "type": "array", "items": { "type": "string" } }, + "expected": { "type": "boolean" }, + "waiver_ref": { "type": ["string", "null"] }, + "remediation": { "type": ["string", "null"] } + } +} diff --git a/docs/schemas/framework.schema.json b/docs/schemas/framework.schema.json new file mode 100644 index 0000000..dbe0eff --- /dev/null +++ b/docs/schemas/framework.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Guide Board Framework", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "authority_id", + "name", + "version", + "status", + "source_urls", + "requirement_index", + "profile_index", + "license_posture" + ], + "properties": { + "id": { "type": "string" }, + "authority_id": { "type": "string" }, + "name": { "type": "string" }, + "version": { "type": "string" }, + "status": { "type": "string" }, + "source_urls": { "type": "array", "items": { "type": "string" } }, + "requirement_index": { "type": "array", "items": { "type": "string" } }, + "profile_index": { "type": "array", "items": { "type": "string" } }, + "license_posture": { "type": "string" } + } +} diff --git a/docs/schemas/raw-artifact.schema.json b/docs/schemas/raw-artifact.schema.json new file mode 100644 index 0000000..0c083dd --- /dev/null +++ b/docs/schemas/raw-artifact.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Guide Board Raw Artifact", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "run_id", + "path", + "media_type", + "producer", + "checksum", + "created_at", + "retention_class" + ], + "properties": { + "id": { "type": "string" }, + "run_id": { "type": "string" }, + "path": { "type": "string" }, + "media_type": { "type": "string" }, + "producer": { "type": "string" }, + "checksum": { "type": "string" }, + "created_at": { "type": "string" }, + "retention_class": { "type": "string" } + } +} diff --git a/docs/schemas/retention-summary.schema.json b/docs/schemas/retention-summary.schema.json new file mode 100644 index 0000000..adf4e94 --- /dev/null +++ b/docs/schemas/retention-summary.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Guide Board Retention Summary", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "run_id", + "target_profile_ref", + "assessment_profile_ref", + "created_at", + "summary", + "report_refs", + "artifact_retention" + ], + "properties": { + "id": { "type": "string" }, + "run_id": { "type": "string" }, + "target_profile_ref": { "type": "string" }, + "assessment_profile_ref": { "type": "string" }, + "created_at": { "type": "string" }, + "summary": { "type": "object" }, + "report_refs": { "type": "array", "items": { "type": "string" } }, + "artifact_retention": { "type": "object" } + } +} diff --git a/docs/schemas/run-plan.schema.json b/docs/schemas/run-plan.schema.json new file mode 100644 index 0000000..eca96d7 --- /dev/null +++ b/docs/schemas/run-plan.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Guide Board Run Plan", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "assessment_profile_snapshot", + "target_profile_snapshot", + "extension_snapshots", + "source_lock", + "ordered_steps", + "credential_refs", + "artifact_policy", + "runtime_policy" + ], + "properties": { + "id": { "type": "string" }, + "assessment_profile_snapshot": { "type": "object" }, + "target_profile_snapshot": { "type": "object" }, + "extension_snapshots": { "type": "array", "items": { "type": "object" } }, + "source_lock": { "type": "object" }, + "ordered_steps": { "type": "array", "items": { "type": "object" } }, + "credential_refs": { "type": "array", "items": { "type": "string" } }, + "artifact_policy": { "type": "object" }, + "runtime_policy": { "type": "object" } + } +} diff --git a/docs/schemas/target-profile.schema.json b/docs/schemas/target-profile.schema.json new file mode 100644 index 0000000..e34a698 --- /dev/null +++ b/docs/schemas/target-profile.schema.json @@ -0,0 +1,55 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Guide Board Target Profile", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "subject_type", + "subject_name", + "environment", + "scope", + "endpoints", + "artifacts", + "credentials_ref", + "declared_capabilities", + "known_gaps" + ], + "properties": { + "id": { "type": "string" }, + "subject_type": { "type": "string" }, + "subject_name": { "type": "string" }, + "environment": { "type": "string" }, + "scope": { "type": "array", "items": { "type": "string" } }, + "endpoints": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "url", "binding"], + "properties": { + "id": { "type": "string" }, + "url": { "type": "string" }, + "binding": { "type": "string" } + } + } + }, + "artifacts": { "type": "array", "items": { "type": "string" } }, + "credentials_ref": { "type": ["string", "null"] }, + "declared_capabilities": { "type": "array", "items": { "type": "string" } }, + "known_gaps": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id", "requirement_refs", "reason", "status"], + "properties": { + "id": { "type": "string" }, + "requirement_refs": { "type": "array", "items": { "type": "string" } }, + "reason": { "type": "string" }, + "status": { "type": "string" } + } + } + } + } +} diff --git a/docs/schemas/waiver.schema.json b/docs/schemas/waiver.schema.json new file mode 100644 index 0000000..8d848df --- /dev/null +++ b/docs/schemas/waiver.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Guide Board Waiver", + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "scope", + "requirement_refs", + "reason", + "owner", + "approved_by", + "created_at", + "expires_at", + "review_status" + ], + "properties": { + "id": { "type": "string" }, + "scope": { "type": "string" }, + "requirement_refs": { "type": "array", "items": { "type": "string" } }, + "reason": { "type": "string" }, + "owner": { "type": "string" }, + "approved_by": { "type": ["string", "null"] }, + "created_at": { "type": "string" }, + "expires_at": { "type": ["string", "null"] }, + "review_status": { "type": "string" } + } +} diff --git a/extensions/_template/extension.json b/extensions/_template/extension.json new file mode 100644 index 0000000..a8b6f72 --- /dev/null +++ b/extensions/_template/extension.json @@ -0,0 +1,22 @@ +{ + "id": "replace-with-extension-id", + "name": "Replace With Extension Name", + "version": "0.1.0", + "extension_type": "executable_harness", + "lifecycle_status": "candidate", + "supported_frameworks": [], + "authorities": [], + "profile_schemas": [ + "target-profile", + "assessment-profile" + ], + "check_groups": [], + "preflight_runner": null, + "runner_entrypoints": [], + "normalizers": [], + "mappings": [], + "report_fragments": [], + "dependencies": [], + "restricted_assets": [], + "certification_boundary": "This template is not an assessment or certification authority." +} diff --git a/extensions/open-cmis-tck/extension.json b/extensions/open-cmis-tck/extension.json new file mode 100644 index 0000000..aca38bd --- /dev/null +++ b/extensions/open-cmis-tck/extension.json @@ -0,0 +1,80 @@ +{ + "id": "open-cmis-tck", + "name": "OpenCMIS TCK", + "version": "0.1.0", + "extension_type": "executable_harness", + "lifecycle_status": "incubating", + "supported_frameworks": [ + "cmis.browser-binding.compatibility.v1" + ], + "authorities": [ + "oasis-cmis", + "apache-chemistry-opencmis" + ], + "profile_schemas": [ + "target-profile", + "assessment-profile" + ], + "check_groups": [ + { + "id": "repository-type", + "name": "Repository And Type Checks", + "check_type": "executable_harness", + "requirement_refs": [ + "cmis.repository-info", + "cmis.type-definitions" + ], + "runner_ref": "opencmis-tck" + }, + { + "id": "object-content", + "name": "Object And Content Checks", + "check_type": "executable_harness", + "requirement_refs": [ + "cmis.object-services", + "cmis.content-streams" + ], + "runner_ref": "opencmis-tck" + }, + { + "id": "navigation", + "name": "Navigation Checks", + "check_type": "executable_harness", + "requirement_refs": [ + "cmis.navigation-services" + ], + "runner_ref": "opencmis-tck" + }, + { + "id": "query-acl-versioning", + "name": "Query, ACL, And Versioning Checks", + "check_type": "executable_harness", + "requirement_refs": [ + "cmis.query", + "cmis.acl", + "cmis.versioning" + ], + "runner_ref": "opencmis-tck" + } + ], + "preflight_runner": "cmis-browser-preflight", + "runner_entrypoints": [ + "opencmis-tck" + ], + "normalizers": [ + "opencmis-result-normalizer" + ], + "mappings": [ + "cmis-capability-map" + ], + "report_fragments": [ + "cmis-summary" + ], + "dependencies": [ + "java", + "maven", + "Apache Chemistry OpenCMIS TCK artifact" + ], + "restricted_assets": [], + "certification_boundary": "Runs selected OpenCMIS TCK checks as preparation evidence only. It does not claim formal CMIS certification." +} diff --git a/extensions/sample-noop/INTENT.md b/extensions/sample-noop/INTENT.md new file mode 100644 index 0000000..ad87eee --- /dev/null +++ b/extensions/sample-noop/INTENT.md @@ -0,0 +1,14 @@ +# INTENT + +## Extension Name + +`sample-noop` + +## Purpose + +`sample-noop` is a tiny guide-board extension used to prove that the core can +discover extensions, validate profiles, and build run plans without depending on +CMIS or any external test harness. + +It should stay boring. Its job is to exercise the guide-board contracts before +real extension adapters add domain-specific runners and normalizers. diff --git a/extensions/sample-noop/extension.json b/extensions/sample-noop/extension.json new file mode 100644 index 0000000..c8aa453 --- /dev/null +++ b/extensions/sample-noop/extension.json @@ -0,0 +1,36 @@ +{ + "id": "sample-noop", + "name": "Sample No-Op Extension", + "version": "0.1.0", + "extension_type": "procedural_evidence", + "lifecycle_status": "incubating", + "supported_frameworks": [ + "guide-board.sample-readiness.v0" + ], + "authorities": [ + "guide-board" + ], + "profile_schemas": [ + "target-profile", + "assessment-profile" + ], + "check_groups": [ + { + "id": "profile-shape", + "name": "Profile Shape", + "check_type": "manual", + "requirement_refs": [ + "guide-board.sample-readiness.v0.profile-shape" + ], + "runner_ref": null + } + ], + "preflight_runner": null, + "runner_entrypoints": [], + "normalizers": [], + "mappings": [], + "report_fragments": [], + "dependencies": [], + "restricted_assets": [], + "certification_boundary": "Development-only sample extension. It produces no certification or compliance conclusion." +} diff --git a/profiles/assessments/cmis-browser-baseline.json b/profiles/assessments/cmis-browser-baseline.json new file mode 100644 index 0000000..b68e581 --- /dev/null +++ b/profiles/assessments/cmis-browser-baseline.json @@ -0,0 +1,33 @@ +{ + "id": "cmis-browser-baseline", + "framework_refs": [ + "cmis.browser-binding.compatibility.v1" + ], + "extension_refs": [ + "open-cmis-tck" + ], + "target_profile_ref": "kontextual-cmis-compat", + "selected_check_groups": { + "open-cmis-tck": [ + "repository-type", + "object-content" + ] + }, + "expectations_ref": null, + "waivers_ref": null, + "output_policy": { + "report_formats": [ + "json", + "markdown" + ], + "artifact_retention": "raw-logs-plus-summary" + }, + "retention_policy": { + "summary_days": 365, + "raw_artifact_days": 30 + }, + "runtime_policy": { + "offline": false, + "timeout_seconds": 300 + } +} diff --git a/profiles/assessments/sample-noop.json b/profiles/assessments/sample-noop.json new file mode 100644 index 0000000..2dc5488 --- /dev/null +++ b/profiles/assessments/sample-noop.json @@ -0,0 +1,32 @@ +{ + "id": "sample-noop-assessment", + "framework_refs": [ + "guide-board.sample-readiness.v0" + ], + "extension_refs": [ + "sample-noop" + ], + "target_profile_ref": "sample-repository", + "selected_check_groups": { + "sample-noop": [ + "profile-shape" + ] + }, + "expectations_ref": null, + "waivers_ref": null, + "output_policy": { + "report_formats": [ + "json", + "markdown" + ], + "artifact_retention": "summary-only" + }, + "retention_policy": { + "summary_days": 365, + "raw_artifact_days": 0 + }, + "runtime_policy": { + "offline": true, + "timeout_seconds": 30 + } +} diff --git a/profiles/targets/kontextual-cmis-compat.json b/profiles/targets/kontextual-cmis-compat.json new file mode 100644 index 0000000..ee42b13 --- /dev/null +++ b/profiles/targets/kontextual-cmis-compat.json @@ -0,0 +1,42 @@ +{ + "id": "kontextual-cmis-compat", + "subject_type": "cmis-browser-binding-endpoint", + "subject_name": "kontextual-engine compat-tck", + "environment": "local", + "scope": [ + "CMIS 1.1 Browser Binding compatibility preparation" + ], + "endpoints": [ + { + "id": "browser-binding", + "url": "http://127.0.0.1:8000/cmis/compat-tck/browser", + "binding": "cmis-browser" + } + ], + "artifacts": [], + "credentials_ref": null, + "declared_capabilities": [ + "cmis.repository-info", + "cmis.type-definitions", + "cmis.object-services", + "cmis.content-streams" + ], + "known_gaps": [ + { + "id": "atompub-not-targeted", + "requirement_refs": [ + "cmis.atompub-binding" + ], + "reason": "The first target profile is Browser Binding only.", + "status": "unsupported_by_design" + }, + { + "id": "web-services-not-targeted", + "requirement_refs": [ + "cmis.web-services-binding" + ], + "reason": "The first target profile is Browser Binding only.", + "status": "unsupported_by_design" + } + ] +} diff --git a/profiles/targets/sample-repository.json b/profiles/targets/sample-repository.json new file mode 100644 index 0000000..a158b15 --- /dev/null +++ b/profiles/targets/sample-repository.json @@ -0,0 +1,19 @@ +{ + "id": "sample-repository", + "subject_type": "repository", + "subject_name": "Sample Repository", + "environment": "local", + "scope": [ + "profile validation", + "run planning" + ], + "endpoints": [], + "artifacts": [ + "README.md" + ], + "credentials_ref": null, + "declared_capabilities": [ + "guide-board.sample-readiness.v0.profile-shape" + ], + "known_gaps": [] +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f81c159 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "guide-board" +version = "0.1.0" +description = "Certification and compliance preparation framework core." +readme = "README.md" +requires-python = ">=3.11" +license = { file = "LICENSE" } +authors = [ + { name = "guide-board contributors" } +] +dependencies = [] + +[project.scripts] +guide-board = "guide_board.cli:main" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/guide_board/__init__.py b/src/guide_board/__init__.py new file mode 100644 index 0000000..a8b38f0 --- /dev/null +++ b/src/guide_board/__init__.py @@ -0,0 +1,3 @@ +"""Guide Board core package.""" + +__version__ = "0.1.0" diff --git a/src/guide_board/__main__.py b/src/guide_board/__main__.py new file mode 100644 index 0000000..beab248 --- /dev/null +++ b/src/guide_board/__main__.py @@ -0,0 +1,5 @@ +from guide_board.cli import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/guide_board/cli.py b/src/guide_board/cli.py new file mode 100644 index 0000000..d22baf1 --- /dev/null +++ b/src/guide_board/cli.py @@ -0,0 +1,138 @@ +"""Guide Board command line interface.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +from guide_board.discovery import discover_extensions +from guide_board.errors import GuideBoardError +from guide_board.execution import run_assessment +from guide_board.io import load_json, write_json +from guide_board.planning import ( + build_run_plan, + validate_assessment_profile, + validate_target_profile, +) +from guide_board.schema import assert_valid + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + result = args.func(args) + except GuideBoardError as exc: + print(f"guide-board: {exc}", file=sys.stderr) + return 2 + except (OSError, ValueError) as exc: + print(f"guide-board: {exc}", file=sys.stderr) + return 1 + + if result is not None: + print_json(result) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="guide-board") + parser.add_argument("--root", type=Path, default=Path.cwd(), help="repository root") + subcommands = parser.add_subparsers(required=True) + + extensions = subcommands.add_parser("extensions", help="extension operations") + extension_commands = extensions.add_subparsers(required=True) + list_extensions = extension_commands.add_parser("list", help="list discovered extensions") + list_extensions.set_defaults(func=cmd_extensions_list) + validate_extensions = extension_commands.add_parser( + "validate", help="validate discovered extension manifests" + ) + validate_extensions.set_defaults(func=cmd_extensions_validate) + + profile = subcommands.add_parser("profile", help="profile validation") + profile_commands = profile.add_subparsers(required=True) + target = profile_commands.add_parser("validate-target", help="validate a target profile") + target.add_argument("path", type=Path) + target.set_defaults(func=cmd_validate_target) + assessment = profile_commands.add_parser( + "validate-assessment", help="validate an assessment profile" + ) + assessment.add_argument("path", type=Path) + assessment.set_defaults(func=cmd_validate_assessment) + + plan = subcommands.add_parser("plan", help="build a run plan") + plan.add_argument("--target", type=Path, required=True) + plan.add_argument("--assessment", type=Path, required=True) + plan.add_argument("--output", type=Path) + plan.set_defaults(func=cmd_plan) + + run = subcommands.add_parser("run", help="run the baseline assessment executor") + run.add_argument("--target", type=Path, required=True) + run.add_argument("--assessment", type=Path, required=True) + run.add_argument("--output-dir", type=Path) + run.set_defaults(func=cmd_run) + + schema = subcommands.add_parser("schema", help="schema validation") + schema.add_argument("schema_name") + schema.add_argument("path", type=Path) + schema.set_defaults(func=cmd_schema_validate) + + return parser + + +def cmd_extensions_list(args: argparse.Namespace) -> dict[str, Any]: + extensions = discover_extensions(args.root) + return { + "extensions": [ + { + "id": extension.id, + "name": extension.manifest["name"], + "version": extension.manifest["version"], + "type": extension.manifest["extension_type"], + "path": str(extension.path.relative_to(args.root)), + } + for extension in extensions + ] + } + + +def cmd_extensions_validate(args: argparse.Namespace) -> dict[str, Any]: + extensions = discover_extensions(args.root) + return { + "status": "valid", + "extensions": [extension.id for extension in extensions], + } + + +def cmd_validate_target(args: argparse.Namespace) -> dict[str, Any]: + profile = validate_target_profile(args.path) + return {"status": "valid", "target_profile": profile["id"]} + + +def cmd_validate_assessment(args: argparse.Namespace) -> dict[str, Any]: + profile = validate_assessment_profile(args.path) + return {"status": "valid", "assessment_profile": profile["id"]} + + +def cmd_plan(args: argparse.Namespace) -> dict[str, Any] | None: + plan = build_run_plan(args.root, args.target, args.assessment) + if args.output: + write_json(args.output, plan) + return {"status": "written", "path": str(args.output)} + return plan + + +def cmd_run(args: argparse.Namespace) -> dict[str, Any]: + return run_assessment(args.root, args.target, args.assessment, args.output_dir) + + +def cmd_schema_validate(args: argparse.Namespace) -> dict[str, Any]: + document = load_json(args.path) + assert_valid(document, args.schema_name) + return {"status": "valid", "schema": args.schema_name, "path": str(args.path)} + + +def print_json(value: Any) -> None: + print(json.dumps(value, indent=2, sort_keys=True)) diff --git a/src/guide_board/discovery.py b/src/guide_board/discovery.py new file mode 100644 index 0000000..90ad485 --- /dev/null +++ b/src/guide_board/discovery.py @@ -0,0 +1,48 @@ +"""Extension discovery.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from guide_board.errors import DiscoveryError, ValidationError +from guide_board.io import load_json +from guide_board.schema import assert_valid + + +@dataclass(frozen=True) +class Extension: + id: str + path: Path + manifest: dict[str, Any] + + +def discover_extensions(root: Path) -> list[Extension]: + extension_root = root / "extensions" + if not extension_root.exists(): + raise DiscoveryError(f"extension directory not found: {extension_root}") + + extensions: list[Extension] = [] + for child in sorted(extension_root.iterdir()): + if not child.is_dir() or child.name.startswith("_"): + continue + manifest_path = child / "extension.json" + if not manifest_path.exists(): + continue + manifest = load_json(manifest_path) + assert_valid(manifest, "extension-manifest") + extension_id = manifest["id"] + if extension_id != child.name: + raise ValidationError( + f"{manifest_path}: extension id {extension_id!r} must match directory {child.name!r}" + ) + extensions.append(Extension(id=extension_id, path=child, manifest=manifest)) + return extensions + + +def find_extension(root: Path, extension_id: str) -> Extension: + for extension in discover_extensions(root): + if extension.id == extension_id: + return extension + raise DiscoveryError(f"extension not found: {extension_id}") diff --git a/src/guide_board/errors.py b/src/guide_board/errors.py new file mode 100644 index 0000000..f1de9b1 --- /dev/null +++ b/src/guide_board/errors.py @@ -0,0 +1,13 @@ +"""Shared exceptions for guide-board core.""" + + +class GuideBoardError(Exception): + """Base exception for user-facing guide-board errors.""" + + +class ValidationError(GuideBoardError): + """Raised when a document does not match its contract.""" + + +class DiscoveryError(GuideBoardError): + """Raised when extension discovery fails.""" diff --git a/src/guide_board/execution.py b/src/guide_board/execution.py new file mode 100644 index 0000000..a9a84ab --- /dev/null +++ b/src/guide_board/execution.py @@ -0,0 +1,211 @@ +"""Baseline assessment execution.""" + +from __future__ import annotations + +from collections import Counter +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from guide_board.io import write_json +from guide_board.planning import build_run_plan +from guide_board.schema import assert_valid + + +def run_assessment( + root: Path, + target_path: Path, + assessment_path: Path, + output_dir: Path | None = None, +) -> dict[str, Any]: + plan = build_run_plan(root, target_path, assessment_path) + run_id = f"run-{_timestamp()}" + run_dir = output_dir or root / "runs" / run_id + created_at = _now() + + evidence = [_evidence_for_step(run_id, plan, step) for step in plan["ordered_steps"]] + for item in evidence: + assert_valid(item, "evidence-item") + + findings = _findings_for_evidence(run_id, evidence) + for finding in findings: + assert_valid(finding, "finding") + + assessment_package = _assessment_package(run_id, plan, evidence, findings, created_at) + assert_valid(assessment_package, "assessment-package") + + run_metadata = { + "id": run_id, + "status": _run_status(evidence), + "created_at": created_at, + "plan_id": plan["id"], + "target_profile_ref": plan["target_profile_snapshot"]["id"], + "assessment_profile_ref": plan["assessment_profile_snapshot"]["id"], + } + + _write_run_directory(run_dir, run_metadata, plan, evidence, findings, assessment_package) + return { + "status": run_metadata["status"], + "run_id": run_id, + "run_dir": str(run_dir), + "assessment_package": str(run_dir / "reports" / "assessment-package.json"), + "report": str(run_dir / "reports" / "report.md"), + } + + +def _evidence_for_step(run_id: str, plan: dict[str, Any], step: dict[str, Any]) -> dict[str, Any]: + now = _now() + runner_ref = step.get("runner_ref") + if runner_ref is None: + result = "manual" if step["kind"] == "check_group" else "skipped" + observations = [ + "No runner is configured for this step in the baseline core." + ] + else: + result = "blocked" + observations = [ + f"Runner {runner_ref!r} is declared but not implemented by the baseline core." + ] + + return { + "id": f"evidence:{step['id']}", + "run_id": run_id, + "extension_id": step["extension_id"], + "check_id": step["id"], + "subject_ref": plan["target_profile_snapshot"]["id"], + "result": result, + "observations": observations, + "facts": { + "step_kind": step["kind"], + "runner_ref": runner_ref, + }, + "requirement_refs": _requirement_refs(plan, step), + "artifact_refs": [], + "started_at": now, + "completed_at": now, + } + + +def _requirement_refs(plan: dict[str, Any], step: dict[str, Any]) -> list[str]: + if step["kind"] != "check_group": + return [] + return list(step.get("requirement_refs", [])) + + +def _findings_for_evidence(run_id: str, evidence: list[dict[str, Any]]) -> list[dict[str, Any]]: + findings: list[dict[str, Any]] = [] + for item in evidence: + if item["result"] != "blocked": + continue + findings.append( + { + "id": f"finding:{item['check_id']}", + "run_id": run_id, + "status": "blocked", + "severity": "info", + "classification": "runner_not_implemented", + "requirement_refs": item["requirement_refs"], + "evidence_refs": [item["id"]], + "expected": True, + "waiver_ref": None, + "remediation": "Implement or configure the declared extension runner.", + } + ) + return findings + + +def _assessment_package( + run_id: str, + plan: dict[str, Any], + evidence: list[dict[str, Any]], + findings: list[dict[str, Any]], + created_at: str, +) -> dict[str, Any]: + summary = dict(Counter(item["result"] for item in evidence)) + return { + "id": f"assessment-package:{run_id}", + "run_id": run_id, + "target": plan["target_profile_snapshot"], + "frameworks": [ + {"id": framework_id} for framework_id in plan["source_lock"]["framework_refs"] + ], + "extensions": plan["extension_snapshots"], + "source_lock": plan["source_lock"], + "summary": summary, + "findings": findings, + "evidence_refs": [item["id"] for item in evidence], + "artifact_manifest": [], + "waivers": [], + "certification_boundary": "Guide Board produces preparation evidence only and does not issue certifications or audit assurance.", + "created_at": created_at, + } + + +def _write_run_directory( + run_dir: Path, + run_metadata: dict[str, Any], + plan: dict[str, Any], + evidence: list[dict[str, Any]], + findings: list[dict[str, Any]], + assessment_package: dict[str, Any], +) -> None: + write_json(run_dir / "run.json", run_metadata) + write_json(run_dir / "plan.json", plan) + write_json(run_dir / "sources.lock.json", plan["source_lock"]) + write_json(run_dir / "target-profile.snapshot.json", plan["target_profile_snapshot"]) + write_json( + run_dir / "assessment-profile.snapshot.json", + plan["assessment_profile_snapshot"], + ) + 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 / "reports" / "assessment-package.json", assessment_package) + (run_dir / "reports").mkdir(parents=True, exist_ok=True) + (run_dir / "reports" / "report.md").write_text( + _markdown_report(run_metadata, assessment_package), + encoding="utf-8", + ) + + +def _markdown_report(run_metadata: dict[str, Any], package: dict[str, Any]) -> str: + summary_lines = "\n".join( + f"- {status}: {count}" for status, count in sorted(package["summary"].items()) + ) + if not summary_lines: + summary_lines = "- no evidence produced" + + return "\n".join( + [ + f"# Guide Board Assessment Report: {run_metadata['id']}", + "", + f"Status: {run_metadata['status']}", + f"Target: {run_metadata['target_profile_ref']}", + f"Assessment: {run_metadata['assessment_profile_ref']}", + "", + "## Summary", + "", + summary_lines, + "", + "## Boundary", + "", + package["certification_boundary"], + "", + ] + ) + + +def _run_status(evidence: list[dict[str, Any]]) -> str: + if any(item["result"] == "fail" for item in evidence): + return "failed" + if any(item["result"] == "blocked" for item in evidence): + return "blocked" + return "completed" + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _timestamp() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") diff --git a/src/guide_board/io.py b/src/guide_board/io.py new file mode 100644 index 0000000..d27fa49 --- /dev/null +++ b/src/guide_board/io.py @@ -0,0 +1,22 @@ +"""Small file-loading helpers.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +def load_json(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8") as handle: + value = json.load(handle) + if not isinstance(value, dict): + raise ValueError(f"{path} must contain a JSON object") + return value + + +def write_json(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as handle: + json.dump(value, handle, indent=2, sort_keys=True) + handle.write("\n") diff --git a/src/guide_board/planning.py b/src/guide_board/planning.py new file mode 100644 index 0000000..51b88ef --- /dev/null +++ b/src/guide_board/planning.py @@ -0,0 +1,109 @@ +"""Assessment planning.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from guide_board.discovery import discover_extensions +from guide_board.errors import ValidationError +from guide_board.io import load_json +from guide_board.schema import assert_valid + + +def validate_target_profile(path: Path) -> dict[str, Any]: + document = load_json(path) + assert_valid(document, "target-profile") + return document + + +def validate_assessment_profile(path: Path) -> dict[str, Any]: + document = load_json(path) + assert_valid(document, "assessment-profile") + return document + + +def build_run_plan(root: Path, target_path: Path, assessment_path: Path) -> dict[str, Any]: + target = validate_target_profile(target_path) + assessment = validate_assessment_profile(assessment_path) + extensions = {extension.id: extension for extension in discover_extensions(root)} + + selected_extensions = assessment["extension_refs"] + missing = [extension_id for extension_id in selected_extensions if extension_id not in extensions] + if missing: + raise ValidationError(f"assessment references unknown extension(s): {', '.join(missing)}") + + if assessment["target_profile_ref"] != target["id"]: + raise ValidationError( + "assessment target_profile_ref " + f"{assessment['target_profile_ref']!r} does not match target profile {target['id']!r}" + ) + + ordered_steps: list[dict[str, Any]] = [] + for extension_id in selected_extensions: + extension = extensions[extension_id] + selected_groups = assessment["selected_check_groups"].get(extension_id, []) + available_groups = {group["id"]: group for group in extension.manifest["check_groups"]} + unknown_groups = [group_id for group_id in selected_groups if group_id not in available_groups] + if unknown_groups: + raise ValidationError( + f"{extension_id}: unknown check group(s): {', '.join(unknown_groups)}" + ) + + ordered_steps.append( + { + "id": f"preflight:{extension_id}", + "extension_id": extension_id, + "kind": "preflight", + "check_groups": selected_groups, + "runner_ref": extension.manifest.get("preflight_runner"), + } + ) + for group_id in selected_groups: + group = available_groups[group_id] + ordered_steps.append( + { + "id": f"check-group:{extension_id}:{group_id}", + "extension_id": extension_id, + "kind": "check_group", + "check_group": group_id, + "runner_ref": group.get("runner_ref"), + "requirement_refs": group.get("requirement_refs", []), + } + ) + + plan = { + "id": f"plan-{_timestamp()}", + "assessment_profile_snapshot": assessment, + "target_profile_snapshot": target, + "extension_snapshots": [ + { + "id": extension_id, + "version": extensions[extension_id].manifest["version"], + "path": str(extensions[extension_id].path.relative_to(root)), + } + for extension_id in selected_extensions + ], + "source_lock": { + "framework_refs": assessment["framework_refs"], + "extension_refs": selected_extensions, + }, + "ordered_steps": ordered_steps, + "credential_refs": _credential_refs(target), + "artifact_policy": assessment["output_policy"], + "runtime_policy": assessment.get("runtime_policy", {}), + } + assert_valid(plan, "run-plan") + return plan + + +def _credential_refs(target: dict[str, Any]) -> list[str]: + credential_ref = target.get("credentials_ref") + if isinstance(credential_ref, str) and credential_ref: + return [credential_ref] + return [] + + +def _timestamp() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") diff --git a/src/guide_board/schema.py b/src/guide_board/schema.py new file mode 100644 index 0000000..8362e4b --- /dev/null +++ b/src/guide_board/schema.py @@ -0,0 +1,108 @@ +"""Minimal JSON-schema-like validation for guide-board contracts. + +The first core should work from a clean checkout without pulling dependencies. +This validator intentionally supports only the schema features used by the +project's own draft contracts. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from guide_board.errors import ValidationError +from guide_board.io import load_json + + +SCHEMA_DIR = Path(__file__).resolve().parents[2] / "docs" / "schemas" + + +def load_schema(schema_name: str) -> dict[str, Any]: + return load_json(SCHEMA_DIR / f"{schema_name}.schema.json") + + +def validate_document(document: Any, schema: dict[str, Any], path: str = "$") -> list[str]: + errors: list[str] = [] + _validate(document, schema, path, errors) + return errors + + +def assert_valid(document: Any, schema_name: str) -> None: + schema = load_schema(schema_name) + errors = validate_document(document, schema) + if errors: + formatted = "\n".join(f"- {error}" for error in errors) + raise ValidationError(f"{schema_name} validation failed:\n{formatted}") + + +def _validate(value: Any, schema: dict[str, Any], path: str, errors: list[str]) -> None: + if "type" in schema and not _matches_type(value, schema["type"]): + errors.append(f"{path}: expected {schema['type']}, got {_type_name(value)}") + return + + if "enum" in schema and value not in schema["enum"]: + allowed = ", ".join(repr(item) for item in schema["enum"]) + errors.append(f"{path}: expected one of {allowed}, got {value!r}") + + if isinstance(value, dict): + required = schema.get("required", []) + for key in required: + if key not in value: + errors.append(f"{path}: missing required property {key!r}") + + properties = schema.get("properties", {}) + additional_allowed = schema.get("additionalProperties", True) + for key, child in value.items(): + child_path = f"{path}.{key}" + if key in properties: + _validate(child, properties[key], child_path, errors) + elif additional_allowed is False: + errors.append(f"{child_path}: unexpected property") + + if isinstance(value, list): + min_items = schema.get("minItems") + if isinstance(min_items, int) and len(value) < min_items: + errors.append(f"{path}: expected at least {min_items} item(s)") + + item_schema = schema.get("items") + if isinstance(item_schema, dict): + for index, child in enumerate(value): + _validate(child, item_schema, f"{path}[{index}]", errors) + + +def _matches_type(value: Any, expected: str | list[str]) -> bool: + if isinstance(expected, list): + return any(_matches_type(value, item) for item in expected) + if expected == "object": + return isinstance(value, dict) + if expected == "array": + return isinstance(value, list) + if expected == "string": + return isinstance(value, str) + if expected == "integer": + return isinstance(value, int) and not isinstance(value, bool) + if expected == "number": + return isinstance(value, (int, float)) and not isinstance(value, bool) + if expected == "boolean": + return isinstance(value, bool) + if expected == "null": + return value is None + return True + + +def _type_name(value: Any) -> str: + if isinstance(value, bool): + return "boolean" + if isinstance(value, dict): + return "object" + if isinstance(value, list): + return "array" + if isinstance(value, str): + return "string" + if isinstance(value, int): + return "integer" + if isinstance(value, float): + return "number" + if value is None: + return "null" + return type(value).__name__ diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..59852fe --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import unittest +from tempfile import TemporaryDirectory +from pathlib import Path + +from guide_board.discovery import discover_extensions +from guide_board.execution import run_assessment +from guide_board.planning import ( + build_run_plan, + validate_assessment_profile, + validate_target_profile, +) + + +ROOT = Path(__file__).resolve().parents[1] + + +class CoreArchitectureTests(unittest.TestCase): + def test_discovers_incubating_extensions(self) -> None: + extensions = {extension.id for extension in discover_extensions(ROOT)} + + self.assertIn("sample-noop", extensions) + self.assertIn("open-cmis-tck", extensions) + + def test_validates_sample_profiles(self) -> None: + target = validate_target_profile(ROOT / "profiles" / "targets" / "sample-repository.json") + assessment = validate_assessment_profile( + ROOT / "profiles" / "assessments" / "sample-noop.json" + ) + + self.assertEqual(target["id"], "sample-repository") + self.assertEqual(assessment["target_profile_ref"], "sample-repository") + + def test_builds_sample_run_plan(self) -> None: + plan = build_run_plan( + ROOT, + ROOT / "profiles" / "targets" / "sample-repository.json", + ROOT / "profiles" / "assessments" / "sample-noop.json", + ) + + self.assertEqual(plan["target_profile_snapshot"]["id"], "sample-repository") + self.assertEqual(plan["extension_snapshots"][0]["id"], "sample-noop") + self.assertEqual( + [step["id"] for step in plan["ordered_steps"]], + [ + "preflight:sample-noop", + "check-group:sample-noop:profile-shape", + ], + ) + self.assertEqual( + plan["ordered_steps"][1]["requirement_refs"], + ["guide-board.sample-readiness.v0.profile-shape"], + ) + + def test_builds_cmis_baseline_plan(self) -> None: + plan = build_run_plan( + ROOT, + ROOT / "profiles" / "targets" / "kontextual-cmis-compat.json", + ROOT / "profiles" / "assessments" / "cmis-browser-baseline.json", + ) + + self.assertEqual(plan["extension_snapshots"][0]["id"], "open-cmis-tck") + self.assertEqual(len(plan["ordered_steps"]), 3) + + def test_runs_sample_noop_assessment(self) -> None: + with TemporaryDirectory() as temporary_directory: + result = run_assessment( + ROOT, + ROOT / "profiles" / "targets" / "sample-repository.json", + ROOT / "profiles" / "assessments" / "sample-noop.json", + Path(temporary_directory) / "sample-run", + ) + + run_dir = Path(result["run_dir"]) + self.assertEqual(result["status"], "completed") + self.assertTrue((run_dir / "run.json").exists()) + 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()) + + +if __name__ == "__main__": + unittest.main() diff --git a/workplans/GUIDE-BOARD-WP-0001-bootstrapping.md b/workplans/GUIDE-BOARD-WP-0001-bootstrapping.md index 882154c..0ab128f 100644 --- a/workplans/GUIDE-BOARD-WP-0001-bootstrapping.md +++ b/workplans/GUIDE-BOARD-WP-0001-bootstrapping.md @@ -151,7 +151,7 @@ Acceptance: ```task id: GUIDE-BOARD-WP-0001-T004 -status: todo +status: done priority: high state_hub_task_id: "a989702f-cc55-4751-8304-75ee2375f8ec" ``` @@ -170,7 +170,7 @@ Acceptance: ```task id: GUIDE-BOARD-WP-0001-T005 -status: todo +status: done priority: high state_hub_task_id: "f22f8cc7-27f4-4377-bb61-3e4ac2040475" ``` @@ -182,6 +182,8 @@ Acceptance: - CLI operation works before any service API is introduced. - CLI can execute a no-op/sample extension to prove core contracts independent of CMIS. +- The baseline executor writes the run directory contract, normalized evidence, + an assessment package, and a Markdown report. ## D1.7 - Extension SDK Skeleton