generated from coulomb/repo-seed
Separated open-cmis-tck and guide-board repositories
This commit is contained in:
3
src/guide_board/__init__.py
Normal file
3
src/guide_board/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Guide Board core package."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
5
src/guide_board/__main__.py
Normal file
5
src/guide_board/__main__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from guide_board.cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
65
src/guide_board/artifacts.py
Normal file
65
src/guide_board/artifacts.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Artifact manifest helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import mimetypes
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from guide_board.schema import assert_valid
|
||||
|
||||
|
||||
def build_artifact_manifest(
|
||||
run_dir: Path,
|
||||
run_id: str,
|
||||
evidence: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
artifacts: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for item in evidence:
|
||||
producer = item["check_id"]
|
||||
for artifact_ref in item.get("artifact_refs", []):
|
||||
if not isinstance(artifact_ref, str) or artifact_ref in seen:
|
||||
continue
|
||||
seen.add(artifact_ref)
|
||||
path = (run_dir / artifact_ref).resolve()
|
||||
try:
|
||||
path.relative_to(run_dir.resolve())
|
||||
except ValueError:
|
||||
continue
|
||||
if not path.exists() or not path.is_file():
|
||||
continue
|
||||
artifact = {
|
||||
"id": f"artifact:{_safe_id(artifact_ref)}",
|
||||
"run_id": run_id,
|
||||
"path": artifact_ref,
|
||||
"media_type": _media_type(path),
|
||||
"producer": producer,
|
||||
"checksum": f"sha256:{_sha256(path)}",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"retention_class": "raw",
|
||||
}
|
||||
assert_valid(artifact, "raw-artifact")
|
||||
artifacts.append(artifact)
|
||||
return artifacts
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _media_type(path: Path) -> str:
|
||||
guessed, _ = mimetypes.guess_type(path.name)
|
||||
if guessed:
|
||||
return guessed
|
||||
return "application/octet-stream"
|
||||
|
||||
|
||||
def _safe_id(value: str) -> str:
|
||||
return "".join(char if char.isalnum() or char in {"-", "_"} else "_" for char in value)
|
||||
207
src/guide_board/cli.py
Normal file
207
src/guide_board/cli.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""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.gates import evaluate_trend_gates
|
||||
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.retention import build_trend_summary, list_retained_runs
|
||||
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")
|
||||
parser.add_argument(
|
||||
"--extension-dir",
|
||||
action="append",
|
||||
type=Path,
|
||||
help="external extension repo or directory containing extension repos",
|
||||
)
|
||||
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)
|
||||
|
||||
runs = subcommands.add_parser("runs", help="run history operations")
|
||||
runs_commands = runs.add_subparsers(required=True)
|
||||
list_runs = runs_commands.add_parser("list", help="list retained run summaries")
|
||||
list_runs.add_argument("--runs-dir", type=Path)
|
||||
list_runs.set_defaults(func=cmd_runs_list)
|
||||
trend_runs = runs_commands.add_parser("trend", help="summarize retained run trends")
|
||||
trend_runs.add_argument("--runs-dir", type=Path)
|
||||
trend_runs.set_defaults(func=cmd_runs_trend)
|
||||
gate_runs = runs_commands.add_parser("gate", help="evaluate retained run quality gates")
|
||||
gate_runs.add_argument("--runs-dir", type=Path)
|
||||
gate_runs.add_argument("--target")
|
||||
gate_runs.add_argument("--assessment")
|
||||
gate_runs.add_argument("--allowed-status", action="append")
|
||||
gate_runs.add_argument("--max-unexpected-findings", type=int, default=0)
|
||||
gate_runs.add_argument("--allow-regression", action="store_true")
|
||||
gate_runs.set_defaults(func=cmd_runs_gate)
|
||||
|
||||
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, args.extension_dir)
|
||||
return {
|
||||
"extensions": [
|
||||
{
|
||||
"id": extension.id,
|
||||
"name": extension.manifest["name"],
|
||||
"version": extension.manifest["version"],
|
||||
"type": extension.manifest["extension_type"],
|
||||
"path": _display_path(args.root, extension.path),
|
||||
"source": extension.source,
|
||||
}
|
||||
for extension in extensions
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def cmd_extensions_validate(args: argparse.Namespace) -> dict[str, Any]:
|
||||
extensions = discover_extensions(args.root, args.extension_dir)
|
||||
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, args.extension_dir)
|
||||
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,
|
||||
args.extension_dir,
|
||||
)
|
||||
|
||||
|
||||
def cmd_runs_list(args: argparse.Namespace) -> dict[str, Any]:
|
||||
runs_dir = args.runs_dir or args.root / "runs"
|
||||
return {
|
||||
"runs_dir": str(runs_dir),
|
||||
"runs": list_retained_runs(runs_dir),
|
||||
}
|
||||
|
||||
|
||||
def cmd_runs_trend(args: argparse.Namespace) -> dict[str, Any]:
|
||||
runs_dir = args.runs_dir or args.root / "runs"
|
||||
summary = build_trend_summary(runs_dir)
|
||||
assert_valid(summary, "trend-summary")
|
||||
return summary
|
||||
|
||||
|
||||
def cmd_runs_gate(args: argparse.Namespace) -> dict[str, Any]:
|
||||
runs_dir = args.runs_dir or args.root / "runs"
|
||||
trend_summary = build_trend_summary(runs_dir)
|
||||
gate_summary = evaluate_trend_gates(
|
||||
trend_summary,
|
||||
allowed_statuses=args.allowed_status,
|
||||
max_unexpected_findings=args.max_unexpected_findings,
|
||||
fail_on_regression=not args.allow_regression,
|
||||
target_profile_ref=args.target,
|
||||
assessment_profile_ref=args.assessment,
|
||||
)
|
||||
assert_valid(gate_summary, "gate-summary")
|
||||
return gate_summary
|
||||
|
||||
|
||||
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))
|
||||
|
||||
|
||||
def _display_path(root: Path, path: Path) -> str:
|
||||
try:
|
||||
return str(path.resolve().relative_to(root.resolve()))
|
||||
except ValueError:
|
||||
return str(path.resolve())
|
||||
103
src/guide_board/discovery.py
Normal file
103
src/guide_board/discovery.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Extension discovery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
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]
|
||||
source: str
|
||||
|
||||
|
||||
def discover_extensions(
|
||||
root: Path,
|
||||
extension_dirs: list[Path] | None = None,
|
||||
) -> list[Extension]:
|
||||
extensions: list[Extension] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
extension_root = root / "extensions"
|
||||
if extension_root.exists():
|
||||
for child in sorted(extension_root.iterdir()):
|
||||
extension = _extension_from_directory(child, "bundled")
|
||||
if extension is not None:
|
||||
_append_extension(extensions, seen, extension)
|
||||
|
||||
for external_path in _external_extension_dirs(extension_dirs):
|
||||
for extension in _discover_external_path(external_path):
|
||||
_append_extension(extensions, seen, extension)
|
||||
return extensions
|
||||
|
||||
|
||||
def find_extension(
|
||||
root: Path,
|
||||
extension_id: str,
|
||||
extension_dirs: list[Path] | None = None,
|
||||
) -> Extension:
|
||||
for extension in discover_extensions(root, extension_dirs):
|
||||
if extension.id == extension_id:
|
||||
return extension
|
||||
raise DiscoveryError(f"extension not found: {extension_id}")
|
||||
|
||||
|
||||
def _external_extension_dirs(extension_dirs: list[Path] | None) -> list[Path]:
|
||||
paths = list(extension_dirs or [])
|
||||
env_value = os.environ.get("GUIDE_BOARD_EXTENSION_PATHS")
|
||||
if env_value:
|
||||
paths.extend(Path(item) for item in env_value.split(os.pathsep) if item)
|
||||
return paths
|
||||
|
||||
|
||||
def _discover_external_path(path: Path) -> list[Extension]:
|
||||
resolved = path.expanduser().resolve()
|
||||
if not resolved.exists():
|
||||
raise DiscoveryError(f"external extension path not found: {path}")
|
||||
|
||||
extension = _extension_from_directory(resolved, "external")
|
||||
if extension is not None:
|
||||
return [extension]
|
||||
|
||||
extensions = []
|
||||
for child in sorted(resolved.iterdir()):
|
||||
extension = _extension_from_directory(child, "external")
|
||||
if extension is not None:
|
||||
extensions.append(extension)
|
||||
return extensions
|
||||
|
||||
|
||||
def _extension_from_directory(path: Path, source: str) -> Extension | None:
|
||||
if not path.is_dir() or path.name.startswith("_"):
|
||||
return None
|
||||
manifest_path = path / "extension.json"
|
||||
if not manifest_path.exists():
|
||||
return None
|
||||
manifest = load_json(manifest_path)
|
||||
assert_valid(manifest, "extension-manifest")
|
||||
extension_id = manifest["id"]
|
||||
if extension_id != path.name:
|
||||
raise ValidationError(
|
||||
f"{manifest_path}: extension id {extension_id!r} must match directory {path.name!r}"
|
||||
)
|
||||
return Extension(id=extension_id, path=path, manifest=manifest, source=source)
|
||||
|
||||
|
||||
def _append_extension(
|
||||
extensions: list[Extension],
|
||||
seen: set[str],
|
||||
extension: Extension,
|
||||
) -> None:
|
||||
if extension.id in seen:
|
||||
raise DiscoveryError(f"extension id is declared more than once: {extension.id}")
|
||||
seen.add(extension.id)
|
||||
extensions.append(extension)
|
||||
13
src/guide_board/errors.py
Normal file
13
src/guide_board/errors.py
Normal file
@@ -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."""
|
||||
393
src/guide_board/execution.py
Normal file
393
src/guide_board/execution.py
Normal file
@@ -0,0 +1,393 @@
|
||||
"""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.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.policy import apply_policy
|
||||
from guide_board.retention import build_retention_summary
|
||||
from guide_board.runners import run_step
|
||||
from guide_board.schema import assert_valid
|
||||
|
||||
|
||||
def run_assessment(
|
||||
root: Path,
|
||||
target_path: Path,
|
||||
assessment_path: Path,
|
||||
output_dir: Path | None = None,
|
||||
extension_dirs: list[Path] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
plan = build_run_plan(root, target_path, assessment_path, extension_dirs)
|
||||
run_id = f"run-{_timestamp()}"
|
||||
run_dir = output_dir or root / "runs" / run_id
|
||||
created_at = _now()
|
||||
|
||||
evidence = _execute_steps(root, run_dir, run_id, plan)
|
||||
for item in evidence:
|
||||
assert_valid(item, "evidence-item")
|
||||
|
||||
findings = _findings_for_evidence(run_id, evidence)
|
||||
findings, policy_summary, applied_waivers = apply_policy(root, plan, findings)
|
||||
for finding in findings:
|
||||
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,
|
||||
plan,
|
||||
evidence,
|
||||
findings,
|
||||
artifact_manifest,
|
||||
mapping_summary,
|
||||
policy_summary,
|
||||
applied_waivers,
|
||||
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"],
|
||||
}
|
||||
retention_summary = build_retention_summary(run_metadata, plan, assessment_package)
|
||||
assert_valid(retention_summary, "retention-summary")
|
||||
|
||||
_write_run_directory(
|
||||
run_dir,
|
||||
run_metadata,
|
||||
plan,
|
||||
evidence,
|
||||
findings,
|
||||
mapping_records,
|
||||
assessment_package,
|
||||
retention_summary,
|
||||
)
|
||||
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"),
|
||||
"retention_summary": str(run_dir / "retention-summary.json"),
|
||||
}
|
||||
|
||||
|
||||
def _execute_steps(
|
||||
root: Path,
|
||||
run_dir: Path,
|
||||
run_id: str,
|
||||
plan: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
evidence: list[dict[str, Any]] = []
|
||||
preflight_blocks: dict[str, dict[str, Any]] = {}
|
||||
for step in plan["ordered_steps"]:
|
||||
extension_id = step["extension_id"]
|
||||
if step["kind"] == "check_group" and extension_id in preflight_blocks:
|
||||
item = _blocked_by_preflight_evidence(run_id, plan, step, preflight_blocks[extension_id])
|
||||
else:
|
||||
item = _evidence_for_step(root, run_dir, run_id, plan, step)
|
||||
|
||||
evidence.append(item)
|
||||
if step["kind"] == "preflight" and _blocks_downstream(item):
|
||||
preflight_blocks[extension_id] = item
|
||||
return evidence
|
||||
|
||||
|
||||
def _blocked_by_preflight_evidence(
|
||||
run_id: str,
|
||||
plan: dict[str, Any],
|
||||
step: dict[str, Any],
|
||||
preflight: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
now = _now()
|
||||
runner_ref = step.get("runner_ref")
|
||||
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": "blocked",
|
||||
"observations": [
|
||||
"Check group was not executed because extension preflight did not pass."
|
||||
],
|
||||
"facts": {
|
||||
"step_kind": step["kind"],
|
||||
"runner_ref": runner_ref,
|
||||
"blocked_reason": "preflight_failed",
|
||||
"preflight_evidence_ref": preflight["id"],
|
||||
"preflight_result": preflight["result"],
|
||||
},
|
||||
"requirement_refs": _requirement_refs(plan, step),
|
||||
"artifact_refs": [],
|
||||
"started_at": now,
|
||||
"completed_at": now,
|
||||
}
|
||||
|
||||
|
||||
def _blocks_downstream(evidence: dict[str, Any]) -> bool:
|
||||
return evidence["result"] in {"fail", "blocked", "infrastructure_error"}
|
||||
|
||||
|
||||
def _evidence_for_step(
|
||||
root: Path,
|
||||
run_dir: Path,
|
||||
run_id: str,
|
||||
plan: dict[str, Any],
|
||||
step: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
now = _now()
|
||||
runner_ref = step.get("runner_ref")
|
||||
runner_result = run_step(root, run_dir, run_id, plan, step)
|
||||
|
||||
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": runner_result["result"],
|
||||
"observations": runner_result["observations"],
|
||||
"facts": {
|
||||
"step_kind": step["kind"],
|
||||
"runner_ref": runner_ref,
|
||||
**runner_result["facts"],
|
||||
},
|
||||
"requirement_refs": _requirement_refs(plan, step),
|
||||
"artifact_refs": runner_result["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"] not in {"blocked", "fail", "infrastructure_error"}:
|
||||
continue
|
||||
findings.append(
|
||||
{
|
||||
"id": f"finding:{item['check_id']}",
|
||||
"run_id": run_id,
|
||||
"check_id": item["check_id"],
|
||||
"status": item["result"],
|
||||
"severity": _severity_for_item(item),
|
||||
"classification": _classification_for_item(item),
|
||||
"requirement_refs": item["requirement_refs"],
|
||||
"evidence_refs": [item["id"]],
|
||||
"expected": _expected_for_item(item),
|
||||
"waiver_ref": None,
|
||||
"policy_ref": None,
|
||||
"remediation": _remediation_for_item(item),
|
||||
}
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def _classification_for_item(item: dict[str, Any]) -> str:
|
||||
result = item["result"]
|
||||
if result == "blocked":
|
||||
blocked_reason = item.get("facts", {}).get("blocked_reason")
|
||||
if isinstance(blocked_reason, str):
|
||||
return blocked_reason
|
||||
return "runner_not_implemented"
|
||||
if result == "fail":
|
||||
return "check_failed"
|
||||
return "infrastructure_error"
|
||||
|
||||
|
||||
def _severity_for_item(item: dict[str, Any]) -> str:
|
||||
if item["result"] == "blocked":
|
||||
return "info"
|
||||
return "medium"
|
||||
|
||||
|
||||
def _expected_for_item(item: dict[str, Any]) -> bool:
|
||||
if item["result"] != "blocked":
|
||||
return False
|
||||
blocked_reason = item.get("facts", {}).get("blocked_reason")
|
||||
return blocked_reason in {
|
||||
"missing_command",
|
||||
"missing_dependency",
|
||||
"preflight_failed",
|
||||
"tck_invocation_not_configured",
|
||||
}
|
||||
|
||||
|
||||
def _remediation_for_item(item: dict[str, Any]) -> str:
|
||||
result = item["result"]
|
||||
if result == "blocked":
|
||||
blocked_reason = item.get("facts", {}).get("blocked_reason")
|
||||
if blocked_reason == "missing_dependency":
|
||||
return "Install the missing runner dependencies and rerun the assessment."
|
||||
if blocked_reason == "preflight_failed":
|
||||
return "Fix the preflight failure and rerun downstream checks."
|
||||
if blocked_reason == "tck_invocation_not_configured":
|
||||
return "Configure the final harness invocation, group mapping, and raw artifact capture."
|
||||
return "Implement or configure the declared extension runner."
|
||||
if result == "infrastructure_error":
|
||||
return "Fix the target, network, credentials, or harness runtime and rerun the assessment."
|
||||
return "Review the failed check and target implementation."
|
||||
|
||||
|
||||
def _assessment_package(
|
||||
run_id: str,
|
||||
plan: dict[str, Any],
|
||||
evidence: list[dict[str, Any]],
|
||||
findings: list[dict[str, Any]],
|
||||
artifact_manifest: list[dict[str, Any]],
|
||||
mapping_summary: dict[str, Any],
|
||||
policy_summary: dict[str, Any],
|
||||
applied_waivers: 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,
|
||||
"mapping_summary": mapping_summary,
|
||||
"policy_summary": policy_summary,
|
||||
"findings": findings,
|
||||
"evidence_refs": [item["id"] for item in evidence],
|
||||
"artifact_manifest": artifact_manifest,
|
||||
"waivers": applied_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]],
|
||||
mapping_records: list[dict[str, Any]],
|
||||
assessment_package: dict[str, Any],
|
||||
retention_summary: dict[str, Any],
|
||||
) -> None:
|
||||
write_json(run_dir / "run.json", run_metadata)
|
||||
write_json(run_dir / "retention-summary.json", retention_summary)
|
||||
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": 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(
|
||||
_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"
|
||||
mapping_lines = _mapping_summary_lines(package)
|
||||
policy_lines = _policy_summary_lines(package)
|
||||
|
||||
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,
|
||||
"",
|
||||
"## Mappings",
|
||||
"",
|
||||
mapping_lines,
|
||||
"",
|
||||
"## Policy",
|
||||
"",
|
||||
policy_lines,
|
||||
"",
|
||||
"## Boundary",
|
||||
"",
|
||||
package["certification_boundary"],
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
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 _policy_summary_lines(package: dict[str, Any]) -> str:
|
||||
summary = package.get("policy_summary", {})
|
||||
return "\n".join(
|
||||
[
|
||||
f"- applied expectations: {summary.get('applied_expectations', 0)}",
|
||||
f"- applied waivers: {summary.get('applied_waivers', 0)}",
|
||||
f"- unexpected findings: {summary.get('unexpected_findings', 0)}",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _run_status(evidence: list[dict[str, Any]]) -> str:
|
||||
if any(item["result"] == "fail" for item in evidence):
|
||||
return "failed"
|
||||
if any(item["result"] == "infrastructure_error" for item in evidence):
|
||||
return "infrastructure_error"
|
||||
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")
|
||||
162
src/guide_board/gates.py
Normal file
162
src/guide_board/gates.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""Quality gate evaluation for retained run trends."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def evaluate_trend_gates(
|
||||
trend_summary: dict[str, Any],
|
||||
*,
|
||||
allowed_statuses: list[str] | None = None,
|
||||
max_unexpected_findings: int = 0,
|
||||
fail_on_regression: bool = True,
|
||||
target_profile_ref: str | None = None,
|
||||
assessment_profile_ref: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
allowed = allowed_statuses or ["completed"]
|
||||
selected_groups = [
|
||||
group
|
||||
for group in trend_summary.get("groups", [])
|
||||
if _matches_group(group, target_profile_ref, assessment_profile_ref)
|
||||
]
|
||||
|
||||
group_results = [
|
||||
_evaluate_group(group, allowed, max_unexpected_findings, fail_on_regression)
|
||||
for group in selected_groups
|
||||
]
|
||||
if not group_results:
|
||||
group_results.append(
|
||||
{
|
||||
"id": "no-matching-history",
|
||||
"target_profile_ref": target_profile_ref,
|
||||
"assessment_profile_ref": assessment_profile_ref,
|
||||
"status": "failed",
|
||||
"latest_run_ref": None,
|
||||
"checks": [
|
||||
{
|
||||
"id": "history-present",
|
||||
"status": "failed",
|
||||
"observed": 0,
|
||||
"expected": "at least one retained run",
|
||||
"message": "No retained run history matched the gate selection.",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
failed_groups = sum(1 for group in group_results if group["status"] == "failed")
|
||||
passed_groups = len(group_results) - failed_groups
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
return {
|
||||
"id": f"gate-summary:{now.strftime('%Y%m%dT%H%M%SZ')}",
|
||||
"created_at": now.isoformat(),
|
||||
"trend_summary_ref": trend_summary["id"],
|
||||
"status": "failed" if failed_groups else "passed",
|
||||
"policy": {
|
||||
"allowed_statuses": allowed,
|
||||
"max_unexpected_findings": max_unexpected_findings,
|
||||
"fail_on_regression": fail_on_regression,
|
||||
"target_profile_ref": target_profile_ref,
|
||||
"assessment_profile_ref": assessment_profile_ref,
|
||||
},
|
||||
"group_count": len(group_results),
|
||||
"passed_groups": passed_groups,
|
||||
"failed_groups": failed_groups,
|
||||
"groups": group_results,
|
||||
}
|
||||
|
||||
|
||||
def _matches_group(
|
||||
group: dict[str, Any],
|
||||
target_profile_ref: str | None,
|
||||
assessment_profile_ref: str | None,
|
||||
) -> bool:
|
||||
if target_profile_ref and group.get("target_profile_ref") != target_profile_ref:
|
||||
return False
|
||||
if (
|
||||
assessment_profile_ref
|
||||
and group.get("assessment_profile_ref") != assessment_profile_ref
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _evaluate_group(
|
||||
group: dict[str, Any],
|
||||
allowed_statuses: list[str],
|
||||
max_unexpected_findings: int,
|
||||
fail_on_regression: bool,
|
||||
) -> dict[str, Any]:
|
||||
latest = group.get("latest_run", {})
|
||||
trend = group.get("trend", {})
|
||||
checks = [
|
||||
_latest_status_check(latest, allowed_statuses),
|
||||
_unexpected_findings_check(latest, max_unexpected_findings),
|
||||
]
|
||||
if fail_on_regression:
|
||||
checks.append(_regression_check(trend))
|
||||
|
||||
failed = any(check["status"] == "failed" for check in checks)
|
||||
return {
|
||||
"id": group.get("id"),
|
||||
"target_profile_ref": group.get("target_profile_ref"),
|
||||
"assessment_profile_ref": group.get("assessment_profile_ref"),
|
||||
"status": "failed" if failed else "passed",
|
||||
"latest_run_ref": latest.get("run_id"),
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
|
||||
def _latest_status_check(
|
||||
latest: dict[str, Any],
|
||||
allowed_statuses: list[str],
|
||||
) -> dict[str, Any]:
|
||||
observed = latest.get("status", "unknown")
|
||||
passed = observed in allowed_statuses
|
||||
return {
|
||||
"id": "latest-status",
|
||||
"status": "passed" if passed else "failed",
|
||||
"observed": observed,
|
||||
"expected": allowed_statuses,
|
||||
"message": "Latest retained run status is acceptable."
|
||||
if passed
|
||||
else "Latest retained run status is outside the gate policy.",
|
||||
}
|
||||
|
||||
|
||||
def _unexpected_findings_check(
|
||||
latest: dict[str, Any],
|
||||
max_unexpected_findings: int,
|
||||
) -> dict[str, Any]:
|
||||
observed = _int_value(latest.get("unexpected_findings", 0))
|
||||
passed = observed <= max_unexpected_findings
|
||||
return {
|
||||
"id": "unexpected-findings",
|
||||
"status": "passed" if passed else "failed",
|
||||
"observed": observed,
|
||||
"expected": f"<= {max_unexpected_findings}",
|
||||
"message": "Unexpected finding count is within policy."
|
||||
if passed
|
||||
else "Unexpected finding count exceeds policy.",
|
||||
}
|
||||
|
||||
|
||||
def _regression_check(trend: dict[str, Any]) -> dict[str, Any]:
|
||||
observed = trend.get("direction", "insufficient-history")
|
||||
passed = observed != "regressed"
|
||||
return {
|
||||
"id": "trend-regression",
|
||||
"status": "passed" if passed else "failed",
|
||||
"observed": observed,
|
||||
"expected": "not regressed",
|
||||
"message": "Latest trend has not regressed."
|
||||
if passed
|
||||
else "Latest trend regressed compared with the previous retained run.",
|
||||
}
|
||||
|
||||
|
||||
def _int_value(value: Any) -> int:
|
||||
return value if isinstance(value, int) and not isinstance(value, bool) else 0
|
||||
22
src/guide_board/io.py
Normal file
22
src/guide_board/io.py
Normal file
@@ -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")
|
||||
108
src/guide_board/mapping.py
Normal file
108
src/guide_board/mapping.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""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 = _snapshot_path(root, extension)
|
||||
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 _snapshot_path(root: Path, extension: dict[str, Any]) -> Path:
|
||||
path = Path(extension["path"])
|
||||
return path if path.is_absolute() else root / path
|
||||
|
||||
|
||||
def _safe_id(value: str) -> str:
|
||||
return "".join(char if char.isalnum() or char in {"-", "_"} else "_" for char in value)
|
||||
130
src/guide_board/planning.py
Normal file
130
src/guide_board/planning.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""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,
|
||||
extension_dirs: list[Path] | None = None,
|
||||
) -> 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, extension_dirs)
|
||||
}
|
||||
|
||||
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": _extension_path_ref(root, extensions[extension_id].path),
|
||||
"source": extensions[extension_id].source,
|
||||
}
|
||||
for extension_id in selected_extensions
|
||||
],
|
||||
"source_lock": {
|
||||
"framework_refs": assessment["framework_refs"],
|
||||
"extension_refs": selected_extensions,
|
||||
},
|
||||
"profile_paths": {
|
||||
"target_profile_path": str(target_path.resolve()),
|
||||
"assessment_profile_path": str(assessment_path.resolve()),
|
||||
"assessment_profile_dir": str(assessment_path.resolve().parent),
|
||||
},
|
||||
"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 _extension_path_ref(root: Path, path: Path) -> str:
|
||||
try:
|
||||
return str(path.resolve().relative_to(root.resolve()))
|
||||
except ValueError:
|
||||
return str(path.resolve())
|
||||
|
||||
|
||||
def _timestamp() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
124
src/guide_board/policy.py
Normal file
124
src/guide_board/policy.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Expectation and waiver policy application."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from guide_board.io import load_json
|
||||
from guide_board.schema import assert_valid
|
||||
|
||||
|
||||
def apply_policy(
|
||||
root: Path,
|
||||
plan: dict[str, Any],
|
||||
findings: list[dict[str, Any]],
|
||||
) -> tuple[list[dict[str, Any]], dict[str, Any], list[dict[str, Any]]]:
|
||||
expectations = _load_optional_set(root, plan, "expectations_ref", "expectation-set")
|
||||
waiver_set = _load_optional_set(root, plan, "waivers_ref", "waiver-set")
|
||||
waivers = waiver_set.get("waivers", []) if waiver_set else []
|
||||
|
||||
applied_expectations = 0
|
||||
applied_waivers: list[dict[str, Any]] = []
|
||||
|
||||
for finding in findings:
|
||||
for expectation in expectations.get("expectations", []) if expectations else []:
|
||||
if _matches_rule(finding, expectation):
|
||||
finding["expected"] = expectation["expected"]
|
||||
finding["policy_ref"] = expectation["id"]
|
||||
applied_expectations += 1
|
||||
break
|
||||
|
||||
for waiver in waivers:
|
||||
if not _waiver_active(waiver):
|
||||
continue
|
||||
if _matches_rule(finding, waiver):
|
||||
finding["waiver_ref"] = waiver["id"]
|
||||
finding["expected"] = True
|
||||
finding["policy_ref"] = waiver["id"]
|
||||
finding["remediation"] = f"Waived: {waiver['reason']}"
|
||||
applied_waivers.append(waiver)
|
||||
break
|
||||
|
||||
policy_summary = {
|
||||
"expectations_ref": plan["assessment_profile_snapshot"].get("expectations_ref"),
|
||||
"waivers_ref": plan["assessment_profile_snapshot"].get("waivers_ref"),
|
||||
"applied_expectations": applied_expectations,
|
||||
"applied_waivers": len(applied_waivers),
|
||||
"unexpected_findings": sum(
|
||||
1 for finding in findings if not finding.get("expected") and not finding.get("waiver_ref")
|
||||
),
|
||||
}
|
||||
return findings, policy_summary, applied_waivers
|
||||
|
||||
|
||||
def _load_optional_set(
|
||||
root: Path,
|
||||
plan: dict[str, Any],
|
||||
ref_name: str,
|
||||
schema_name: str,
|
||||
) -> dict[str, Any] | None:
|
||||
ref = plan["assessment_profile_snapshot"].get(ref_name)
|
||||
if not ref:
|
||||
return None
|
||||
path = _resolve_policy_ref(root, plan, ref)
|
||||
document = load_json(path)
|
||||
assert_valid(document, schema_name)
|
||||
target_ref = plan["target_profile_snapshot"]["id"]
|
||||
if document["target_profile_ref"] != target_ref:
|
||||
raise ValueError(
|
||||
f"{path}: target_profile_ref {document['target_profile_ref']!r} "
|
||||
f"does not match target profile {target_ref!r}"
|
||||
)
|
||||
return document
|
||||
|
||||
|
||||
def _resolve_policy_ref(root: Path, plan: dict[str, Any], ref: str) -> Path:
|
||||
ref_path = Path(ref)
|
||||
if ref_path.is_absolute():
|
||||
return ref_path
|
||||
|
||||
root_relative = root / ref_path
|
||||
if root_relative.exists():
|
||||
return root_relative
|
||||
|
||||
assessment_dir = plan.get("profile_paths", {}).get("assessment_profile_dir")
|
||||
if isinstance(assessment_dir, str):
|
||||
return Path(assessment_dir) / ref_path
|
||||
|
||||
return root_relative
|
||||
|
||||
|
||||
def _matches_rule(finding: dict[str, Any], rule: dict[str, Any]) -> bool:
|
||||
return (
|
||||
_matches_any(finding.get("requirement_refs", []), rule.get("requirement_refs", []))
|
||||
and _matches_any([finding.get("check_id", "")], rule.get("check_refs", []))
|
||||
and _matches_scalar(finding.get("status"), rule.get("result_refs", []))
|
||||
and _matches_scalar(finding.get("classification"), rule.get("classification_refs", []))
|
||||
)
|
||||
|
||||
|
||||
def _matches_any(values: list[str], patterns: list[str]) -> bool:
|
||||
if not patterns:
|
||||
return True
|
||||
return any(value in patterns for value in values)
|
||||
|
||||
|
||||
def _matches_scalar(value: Any, patterns: list[str]) -> bool:
|
||||
if not patterns:
|
||||
return True
|
||||
return isinstance(value, str) and value in patterns
|
||||
|
||||
|
||||
def _waiver_active(waiver: dict[str, Any]) -> bool:
|
||||
if waiver.get("review_status") != "approved":
|
||||
return False
|
||||
expires_at = waiver.get("expires_at")
|
||||
if not expires_at:
|
||||
return True
|
||||
try:
|
||||
expiry = date.fromisoformat(expires_at)
|
||||
except ValueError:
|
||||
return False
|
||||
return expiry >= date.today()
|
||||
253
src/guide_board/retention.py
Normal file
253
src/guide_board/retention.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""Retention summaries and run history helpers."""
|
||||
|
||||
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 load_json
|
||||
|
||||
|
||||
def build_retention_summary(
|
||||
run_metadata: dict[str, Any],
|
||||
plan: dict[str, Any],
|
||||
assessment_package: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
artifact_manifest = assessment_package.get("artifact_manifest", [])
|
||||
retention_class_counts = Counter(
|
||||
artifact.get("retention_class", "unknown")
|
||||
for artifact in artifact_manifest
|
||||
if isinstance(artifact, dict)
|
||||
)
|
||||
policy_summary = assessment_package.get("policy_summary", {})
|
||||
findings = assessment_package.get("findings", [])
|
||||
|
||||
return {
|
||||
"id": f"retention-summary:{run_metadata['id']}",
|
||||
"run_id": run_metadata["id"],
|
||||
"target_profile_ref": run_metadata["target_profile_ref"],
|
||||
"assessment_profile_ref": run_metadata["assessment_profile_ref"],
|
||||
"created_at": run_metadata["created_at"],
|
||||
"summary": {
|
||||
"status": run_metadata["status"],
|
||||
"evidence_results": assessment_package.get("summary", {}),
|
||||
"finding_count": len(findings),
|
||||
"unexpected_findings": policy_summary.get("unexpected_findings", 0),
|
||||
"expected_findings": sum(1 for finding in findings if finding.get("expected")),
|
||||
"waived_findings": sum(1 for finding in findings if finding.get("waiver_ref")),
|
||||
"mapping_target_count": len(
|
||||
assessment_package.get("mapping_summary", {}).get("targets", [])
|
||||
),
|
||||
"artifact_count": len(artifact_manifest),
|
||||
},
|
||||
"report_refs": [
|
||||
"reports/assessment-package.json",
|
||||
"reports/report.md",
|
||||
],
|
||||
"artifact_retention": {
|
||||
"policy": plan["assessment_profile_snapshot"].get("retention_policy", {}),
|
||||
"output_artifact_retention": plan["assessment_profile_snapshot"]
|
||||
.get("output_policy", {})
|
||||
.get("artifact_retention"),
|
||||
"retention_class_counts": dict(sorted(retention_class_counts.items())),
|
||||
"raw_artifact_count": retention_class_counts.get("raw", 0),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def list_retained_runs(runs_dir: Path) -> list[dict[str, Any]]:
|
||||
if not runs_dir.exists():
|
||||
return []
|
||||
|
||||
summaries = []
|
||||
for run_dir in sorted(path for path in runs_dir.iterdir() if path.is_dir()):
|
||||
try:
|
||||
summary = _summary_for_run_dir(run_dir)
|
||||
except OSError:
|
||||
continue
|
||||
if summary is not None:
|
||||
summaries.append(summary)
|
||||
|
||||
return sorted(summaries, key=lambda item: item.get("created_at", ""), reverse=True)
|
||||
|
||||
|
||||
def build_trend_summary(
|
||||
runs_dir: Path,
|
||||
retained_runs: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
runs = retained_runs if retained_runs is not None else list_retained_runs(runs_dir)
|
||||
now = datetime.now(timezone.utc)
|
||||
groups = []
|
||||
for group_key, group_runs in _group_runs(runs).items():
|
||||
latest = group_runs[0]
|
||||
previous = group_runs[1] if len(group_runs) > 1 else None
|
||||
groups.append(
|
||||
{
|
||||
"id": group_key,
|
||||
"target_profile_ref": latest.get("target_profile_ref"),
|
||||
"assessment_profile_ref": latest.get("assessment_profile_ref"),
|
||||
"run_count": len(group_runs),
|
||||
"status_counts": dict(
|
||||
sorted(Counter(_status_for(run) for run in group_runs).items())
|
||||
),
|
||||
"latest_run": _run_projection(latest),
|
||||
"previous_run": _run_projection(previous) if previous else None,
|
||||
"trend": _trend_between(previous, latest),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"id": f"trend-summary:{now.strftime('%Y%m%dT%H%M%SZ')}",
|
||||
"created_at": now.isoformat(),
|
||||
"runs_dir": str(runs_dir),
|
||||
"run_count": len(runs),
|
||||
"groups": sorted(groups, key=lambda item: item["id"]),
|
||||
}
|
||||
|
||||
|
||||
def _summary_for_run_dir(run_dir: Path) -> dict[str, Any] | None:
|
||||
summary_path = run_dir / "retention-summary.json"
|
||||
if summary_path.exists():
|
||||
summary = load_json(summary_path)
|
||||
summary["run_dir"] = str(run_dir)
|
||||
return summary
|
||||
|
||||
metadata_path = run_dir / "run.json"
|
||||
if not metadata_path.exists():
|
||||
return None
|
||||
|
||||
metadata = load_json(metadata_path)
|
||||
return {
|
||||
"id": f"retention-summary:{metadata.get('id', run_dir.name)}",
|
||||
"run_id": metadata.get("id", run_dir.name),
|
||||
"run_dir": str(run_dir),
|
||||
"target_profile_ref": metadata.get("target_profile_ref"),
|
||||
"assessment_profile_ref": metadata.get("assessment_profile_ref"),
|
||||
"created_at": metadata.get("created_at"),
|
||||
"summary": {
|
||||
"status": metadata.get("status", "unknown"),
|
||||
},
|
||||
"report_refs": [],
|
||||
"artifact_retention": {},
|
||||
}
|
||||
|
||||
|
||||
def _group_runs(runs: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
|
||||
groups: dict[str, list[dict[str, Any]]] = {}
|
||||
for run in runs:
|
||||
target = run.get("target_profile_ref") or "unknown-target"
|
||||
assessment = run.get("assessment_profile_ref") or "unknown-assessment"
|
||||
groups.setdefault(f"{target}:{assessment}", []).append(run)
|
||||
|
||||
for group_runs in groups.values():
|
||||
group_runs.sort(key=lambda item: item.get("created_at", ""), reverse=True)
|
||||
return groups
|
||||
|
||||
|
||||
def _run_projection(run: dict[str, Any]) -> dict[str, Any]:
|
||||
summary = run.get("summary", {})
|
||||
return {
|
||||
"run_id": run.get("run_id"),
|
||||
"created_at": run.get("created_at"),
|
||||
"status": summary.get("status", "unknown"),
|
||||
"unexpected_findings": _summary_int(summary, "unexpected_findings"),
|
||||
"finding_count": _summary_int(summary, "finding_count"),
|
||||
"artifact_count": _summary_int(summary, "artifact_count"),
|
||||
"run_dir": run.get("run_dir"),
|
||||
}
|
||||
|
||||
|
||||
def _trend_between(
|
||||
previous: dict[str, Any] | None,
|
||||
latest: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if previous is None:
|
||||
return {
|
||||
"direction": "insufficient-history",
|
||||
"status_changed": False,
|
||||
"unexpected_findings_delta": 0,
|
||||
"finding_count_delta": 0,
|
||||
"artifact_count_delta": 0,
|
||||
"evidence_result_deltas": {},
|
||||
}
|
||||
|
||||
previous_summary = previous.get("summary", {})
|
||||
latest_summary = latest.get("summary", {})
|
||||
evidence_deltas = _dict_deltas(
|
||||
previous_summary.get("evidence_results", {}),
|
||||
latest_summary.get("evidence_results", {}),
|
||||
)
|
||||
unexpected_delta = _summary_int(latest_summary, "unexpected_findings") - _summary_int(
|
||||
previous_summary, "unexpected_findings"
|
||||
)
|
||||
finding_delta = _summary_int(latest_summary, "finding_count") - _summary_int(
|
||||
previous_summary, "finding_count"
|
||||
)
|
||||
artifact_delta = _summary_int(latest_summary, "artifact_count") - _summary_int(
|
||||
previous_summary, "artifact_count"
|
||||
)
|
||||
previous_status = _status_for(previous)
|
||||
latest_status = _status_for(latest)
|
||||
|
||||
return {
|
||||
"direction": _trend_direction(previous_status, latest_status, unexpected_delta),
|
||||
"status_changed": previous_status != latest_status,
|
||||
"unexpected_findings_delta": unexpected_delta,
|
||||
"finding_count_delta": finding_delta,
|
||||
"artifact_count_delta": artifact_delta,
|
||||
"evidence_result_deltas": evidence_deltas,
|
||||
}
|
||||
|
||||
|
||||
def _trend_direction(
|
||||
previous_status: str,
|
||||
latest_status: str,
|
||||
unexpected_delta: int,
|
||||
) -> str:
|
||||
previous_score = _status_score(previous_status)
|
||||
latest_score = _status_score(latest_status)
|
||||
if latest_score < previous_score:
|
||||
return "improved"
|
||||
if latest_score > previous_score:
|
||||
return "regressed"
|
||||
if unexpected_delta < 0:
|
||||
return "improved"
|
||||
if unexpected_delta > 0:
|
||||
return "regressed"
|
||||
return "unchanged"
|
||||
|
||||
|
||||
def _status_for(run: dict[str, Any]) -> str:
|
||||
summary = run.get("summary", {})
|
||||
status = summary.get("status", "unknown")
|
||||
return status if isinstance(status, str) else "unknown"
|
||||
|
||||
|
||||
def _status_score(status: str) -> int:
|
||||
return {
|
||||
"completed": 0,
|
||||
"blocked": 1,
|
||||
"infrastructure_error": 2,
|
||||
"failed": 3,
|
||||
}.get(status, 2)
|
||||
|
||||
|
||||
def _summary_int(summary: dict[str, Any], key: str) -> int:
|
||||
value = summary.get(key, 0)
|
||||
return value if isinstance(value, int) and not isinstance(value, bool) else 0
|
||||
|
||||
|
||||
def _dict_deltas(previous: Any, latest: Any) -> dict[str, int]:
|
||||
previous_dict = previous if isinstance(previous, dict) else {}
|
||||
latest_dict = latest if isinstance(latest, dict) else {}
|
||||
keys = set(previous_dict) | set(latest_dict)
|
||||
return {
|
||||
key: _int_value(latest_dict.get(key, 0)) - _int_value(previous_dict.get(key, 0))
|
||||
for key in sorted(keys)
|
||||
}
|
||||
|
||||
|
||||
def _int_value(value: Any) -> int:
|
||||
return value if isinstance(value, int) and not isinstance(value, bool) else 0
|
||||
332
src/guide_board/runners.py
Normal file
332
src/guide_board/runners.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""Runner bridge for extension-provided checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any, Callable
|
||||
|
||||
from guide_board.errors import ValidationError
|
||||
from guide_board.io import load_json, write_json
|
||||
|
||||
|
||||
RunnerCallable = Callable[[dict[str, Any]], dict[str, Any]]
|
||||
|
||||
|
||||
def run_step(
|
||||
root: Path,
|
||||
run_dir: Path,
|
||||
run_id: str,
|
||||
plan: dict[str, Any],
|
||||
step: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
runner_ref = step.get("runner_ref")
|
||||
if runner_ref is None:
|
||||
return _no_runner_result(step)
|
||||
|
||||
extension = _extension_snapshot(plan, step["extension_id"])
|
||||
extension_path = _snapshot_path(root, extension)
|
||||
manifest = load_json(extension_path / "extension.json")
|
||||
entrypoint = _runner_entrypoint(manifest, runner_ref)
|
||||
if entrypoint["kind"] == "python_module":
|
||||
return _run_python_module(root, run_dir, run_id, plan, step, extension_path, entrypoint)
|
||||
if entrypoint["kind"] == "external":
|
||||
return {
|
||||
"result": "blocked",
|
||||
"observations": [
|
||||
f"Runner {runner_ref!r} is declared as an external runner and is not implemented by the core."
|
||||
],
|
||||
"facts": {
|
||||
"runner_ref": runner_ref,
|
||||
"runner_kind": "external",
|
||||
},
|
||||
"artifact_refs": [],
|
||||
}
|
||||
if entrypoint["kind"] == "command":
|
||||
return _run_command(root, run_dir, run_id, plan, step, extension_path, entrypoint)
|
||||
raise ValidationError(f"{runner_ref}: unsupported runner kind {entrypoint['kind']!r}")
|
||||
|
||||
|
||||
def _no_runner_result(step: dict[str, Any]) -> dict[str, Any]:
|
||||
result = "manual" if step["kind"] == "check_group" else "skipped"
|
||||
return {
|
||||
"result": result,
|
||||
"observations": [
|
||||
"No runner is configured for this step in the baseline core."
|
||||
],
|
||||
"facts": {
|
||||
"runner_ref": None,
|
||||
"runner_kind": None,
|
||||
},
|
||||
"artifact_refs": [],
|
||||
}
|
||||
|
||||
|
||||
def _run_python_module(
|
||||
root: Path,
|
||||
run_dir: Path,
|
||||
run_id: str,
|
||||
plan: dict[str, Any],
|
||||
step: dict[str, Any],
|
||||
extension_path: Path,
|
||||
entrypoint: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
module_path = entrypoint.get("module_path")
|
||||
callable_name = entrypoint.get("callable")
|
||||
if not module_path or not callable_name:
|
||||
raise ValidationError(f"{entrypoint['id']}: python_module runners need module_path and callable")
|
||||
|
||||
module_file = (extension_path / module_path).resolve()
|
||||
try:
|
||||
module_file.relative_to(extension_path.resolve())
|
||||
except ValueError as exc:
|
||||
raise ValidationError(
|
||||
f"{entrypoint['id']}: module_path must stay inside the extension directory"
|
||||
) from exc
|
||||
|
||||
module = _load_module(module_file, entrypoint["id"])
|
||||
runner = getattr(module, callable_name, None)
|
||||
if not callable(runner):
|
||||
raise ValidationError(f"{entrypoint['id']}: callable {callable_name!r} was not found")
|
||||
|
||||
context = {
|
||||
"root": str(root),
|
||||
"run_dir": str(run_dir),
|
||||
"run_id": run_id,
|
||||
"plan": plan,
|
||||
"step": step,
|
||||
"target_profile": plan["target_profile_snapshot"],
|
||||
"assessment_profile": plan["assessment_profile_snapshot"],
|
||||
"extension_path": str(extension_path),
|
||||
"runner": entrypoint,
|
||||
}
|
||||
try:
|
||||
result = runner(context)
|
||||
except Exception as exc: # noqa: BLE001 - extension failures become evidence.
|
||||
return {
|
||||
"result": "infrastructure_error",
|
||||
"observations": [
|
||||
f"Runner {entrypoint['id']!r} failed before producing evidence: {exc}"
|
||||
],
|
||||
"facts": {
|
||||
"runner_ref": entrypoint["id"],
|
||||
"runner_kind": "python_module",
|
||||
"error_type": type(exc).__name__,
|
||||
},
|
||||
"artifact_refs": [],
|
||||
}
|
||||
if not isinstance(result, dict):
|
||||
raise ValidationError(f"{entrypoint['id']}: runner must return an object")
|
||||
return {
|
||||
"result": result.get("result", "unknown"),
|
||||
"observations": result.get("observations", []),
|
||||
"facts": result.get("facts", {}),
|
||||
"artifact_refs": result.get("artifact_refs", []),
|
||||
}
|
||||
|
||||
|
||||
def _run_command(
|
||||
root: Path,
|
||||
run_dir: Path,
|
||||
run_id: str,
|
||||
plan: dict[str, Any],
|
||||
step: dict[str, Any],
|
||||
extension_path: Path,
|
||||
entrypoint: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
command_template = entrypoint.get("command")
|
||||
if not isinstance(command_template, list) or not command_template:
|
||||
raise ValidationError(f"{entrypoint['id']}: command runners need a non-empty command")
|
||||
|
||||
context_path = run_dir / "artifacts" / "runner-contexts" / f"{_safe_id(step['id'])}.json"
|
||||
context = {
|
||||
"root": str(root),
|
||||
"run_dir": str(run_dir),
|
||||
"run_id": run_id,
|
||||
"plan": plan,
|
||||
"step": step,
|
||||
"target_profile": plan["target_profile_snapshot"],
|
||||
"assessment_profile": plan["assessment_profile_snapshot"],
|
||||
"extension_path": str(extension_path),
|
||||
"runner": entrypoint,
|
||||
}
|
||||
write_json(context_path, context)
|
||||
|
||||
command = [
|
||||
_expand_command_arg(arg, root, run_dir, extension_path, context_path)
|
||||
for arg in command_template
|
||||
]
|
||||
timeout = _timeout_seconds(plan)
|
||||
env = os.environ.copy()
|
||||
src_path = str(root / "src")
|
||||
env["PYTHONPATH"] = (
|
||||
src_path
|
||||
if not env.get("PYTHONPATH")
|
||||
else f"{src_path}{os.pathsep}{env['PYTHONPATH']}"
|
||||
)
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
cwd=extension_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
return {
|
||||
"result": "blocked",
|
||||
"observations": [
|
||||
f"Command runner {entrypoint['id']!r} could not start: {exc.filename} was not found."
|
||||
],
|
||||
"facts": {
|
||||
"runner_ref": entrypoint["id"],
|
||||
"runner_kind": "command",
|
||||
"blocked_reason": "missing_command",
|
||||
"command": command,
|
||||
},
|
||||
"artifact_refs": [str(context_path.relative_to(run_dir))],
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
"result": "infrastructure_error",
|
||||
"observations": [
|
||||
f"Command runner {entrypoint['id']!r} timed out after {timeout} seconds."
|
||||
],
|
||||
"facts": {
|
||||
"runner_ref": entrypoint["id"],
|
||||
"runner_kind": "command",
|
||||
"timeout_seconds": timeout,
|
||||
"command": command,
|
||||
},
|
||||
"artifact_refs": [str(context_path.relative_to(run_dir))],
|
||||
}
|
||||
|
||||
parsed = _parse_runner_stdout(completed.stdout)
|
||||
if parsed is None:
|
||||
result = "infrastructure_error" if completed.returncode else "unknown"
|
||||
return {
|
||||
"result": result,
|
||||
"observations": [
|
||||
f"Command runner {entrypoint['id']!r} did not return a JSON result on stdout."
|
||||
],
|
||||
"facts": {
|
||||
"runner_ref": entrypoint["id"],
|
||||
"runner_kind": "command",
|
||||
"returncode": completed.returncode,
|
||||
"stdout": completed.stdout[-4000:],
|
||||
"stderr": completed.stderr[-4000:],
|
||||
"command": command,
|
||||
},
|
||||
"artifact_refs": [str(context_path.relative_to(run_dir))],
|
||||
}
|
||||
|
||||
facts = parsed.get("facts", {})
|
||||
if not isinstance(facts, dict):
|
||||
facts = {}
|
||||
facts.update(
|
||||
{
|
||||
"runner_ref": entrypoint["id"],
|
||||
"runner_kind": "command",
|
||||
"returncode": completed.returncode,
|
||||
"stderr": completed.stderr[-4000:],
|
||||
}
|
||||
)
|
||||
observations = parsed.get("observations", [])
|
||||
if not isinstance(observations, list):
|
||||
observations = [str(observations)]
|
||||
artifact_refs = parsed.get("artifact_refs", [])
|
||||
if not isinstance(artifact_refs, list):
|
||||
artifact_refs = []
|
||||
artifact_refs.append(str(context_path.relative_to(run_dir)))
|
||||
|
||||
result = parsed.get("result", "unknown")
|
||||
if completed.returncode != 0 and result in {"pass", "warning", "manual", "skipped"}:
|
||||
result = "infrastructure_error"
|
||||
observations.append(
|
||||
f"Command runner {entrypoint['id']!r} exited with {completed.returncode}."
|
||||
)
|
||||
|
||||
return {
|
||||
"result": result,
|
||||
"observations": observations,
|
||||
"facts": facts,
|
||||
"artifact_refs": artifact_refs,
|
||||
}
|
||||
|
||||
|
||||
def _load_module(path: Path, runner_id: str) -> ModuleType:
|
||||
if not path.exists():
|
||||
raise ValidationError(f"{runner_id}: module not found: {path}")
|
||||
module_name = f"_guide_board_runner_{runner_id.replace('-', '_')}"
|
||||
spec = importlib.util.spec_from_file_location(module_name, path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ValidationError(f"{runner_id}: unable to load module from {path}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _extension_snapshot(plan: dict[str, Any], extension_id: str) -> dict[str, Any]:
|
||||
for extension in plan["extension_snapshots"]:
|
||||
if extension["id"] == extension_id:
|
||||
return extension
|
||||
raise ValidationError(f"step references unknown extension {extension_id!r}")
|
||||
|
||||
|
||||
def _snapshot_path(root: Path, extension: dict[str, Any]) -> Path:
|
||||
path = Path(extension["path"])
|
||||
return path if path.is_absolute() else root / path
|
||||
|
||||
|
||||
def _runner_entrypoint(manifest: dict[str, Any], runner_ref: str) -> dict[str, Any]:
|
||||
for entrypoint in manifest.get("runner_entrypoints", []):
|
||||
if entrypoint["id"] == runner_ref:
|
||||
return entrypoint
|
||||
raise ValidationError(f"{manifest['id']}: runner {runner_ref!r} is not declared")
|
||||
|
||||
|
||||
def _expand_command_arg(
|
||||
arg: str,
|
||||
root: Path,
|
||||
run_dir: Path,
|
||||
extension_path: Path,
|
||||
context_path: Path,
|
||||
) -> str:
|
||||
return (
|
||||
arg.replace("{root}", str(root))
|
||||
.replace("{run_dir}", str(run_dir))
|
||||
.replace("{extension_path}", str(extension_path))
|
||||
.replace("{context_json}", str(context_path))
|
||||
)
|
||||
|
||||
|
||||
def _timeout_seconds(plan: dict[str, Any]) -> float:
|
||||
runtime_policy = plan.get("runtime_policy", {})
|
||||
timeout = runtime_policy.get("timeout_seconds", 300)
|
||||
if not isinstance(timeout, (int, float)):
|
||||
return 300.0
|
||||
return max(1.0, float(timeout))
|
||||
|
||||
|
||||
def _parse_runner_stdout(stdout: str) -> dict[str, Any] | None:
|
||||
stripped = stdout.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def _safe_id(value: str) -> str:
|
||||
return "".join(char if char.isalnum() or char in {"-", "_"} else "_" for char in value)
|
||||
108
src/guide_board/schema.py
Normal file
108
src/guide_board/schema.py
Normal file
@@ -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__
|
||||
16
src/guide_board/sdk.py
Normal file
16
src/guide_board/sdk.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Public helper types for extension runners.
|
||||
|
||||
Extension Python runners are called with one dictionary context and should return
|
||||
one dictionary shaped like `RunnerResult`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, TypedDict
|
||||
|
||||
|
||||
class RunnerResult(TypedDict, total=False):
|
||||
result: str
|
||||
observations: list[str]
|
||||
facts: dict[str, Any]
|
||||
artifact_refs: list[str]
|
||||
Reference in New Issue
Block a user