feat(mindmodel): add CLI wrapper, edge-case tests, and manifest schema tests
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
"""Command-line wrapper around src.validators.mindmodel_validator.validate_manifest
|
||||
|
||||
This tiny CLI loads a manifest and writes a structured JSON report to stdout
|
||||
and optionally to a file path. It is report-only: it never raises an error or
|
||||
changes exit code based on findings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _write_report(report: dict[str, Any], path: Path | None) -> None:
|
||||
text = json.dumps(report, indent=2, ensure_ascii=False)
|
||||
print(text)
|
||||
if path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser("validate_mindmodel")
|
||||
parser.add_argument("manifest", nargs="?", help="path to manifest file")
|
||||
parser.add_argument("--manifest", dest="manifest_opt", help="path to manifest file")
|
||||
parser.add_argument("--report", help="optional output report path")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
manifest = args.manifest_opt or args.manifest
|
||||
if not manifest:
|
||||
parser.error("manifest path is required (positional or --manifest)")
|
||||
|
||||
# import here to keep CLI tiny when unused
|
||||
try:
|
||||
from src.validators.mindmodel_validator import validate_manifest
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
print(f"Failed to import validator: {e}")
|
||||
return 0
|
||||
|
||||
try:
|
||||
report = validate_manifest(manifest, report_only=True)
|
||||
except Exception as e: # never fail the process
|
||||
report = {"error": str(e)}
|
||||
|
||||
report_path = Path(args.report) if args.report else None
|
||||
_write_report(report, report_path)
|
||||
|
||||
# always exit zero for report-only operation
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,29 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
except Exception:
|
||||
yaml = None
|
||||
|
||||
|
||||
def test_manifest_loads():
|
||||
"""Ensure the .mindmodel/manifest.yaml can be read and contains a 'files' list."""
|
||||
p = Path(".mindmodel/manifest.yaml")
|
||||
assert p.exists(), ".mindmodel/manifest.yaml must exist"
|
||||
text = p.read_text(encoding="utf-8")
|
||||
|
||||
if yaml is not None:
|
||||
data = yaml.safe_load(text)
|
||||
assert isinstance(data, dict), "manifest should parse to a mapping"
|
||||
assert "files" in data, "top-level 'files' key missing"
|
||||
assert isinstance(data["files"], list), "'files' should be a list"
|
||||
assert len(data["files"]) >= 1, "'files' must have at least one entry"
|
||||
else:
|
||||
# Fallback simple checks if PyYAML is not available in the environment.
|
||||
assert re.search(r"^\s*files:\s*$", text, re.M), (
|
||||
"manifest must contain top-level 'files:'"
|
||||
)
|
||||
assert re.search(r"^\s*-\s+path:\s+", text, re.M), (
|
||||
"manifest must contain at least one '- path:' entry"
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
from pathlib import Path
|
||||
|
||||
from src.validators.types import parse_manifest
|
||||
|
||||
|
||||
def test_manifest_schema_parses_into_types():
|
||||
"""Ensure the .mindmodel/manifest.yaml parses via parse_manifest and
|
||||
yields a manifest-like object with a files list where each entry has a
|
||||
`path` key.
|
||||
|
||||
The test relies on parse_manifest to use its PyYAML fallback when
|
||||
PyYAML is not available in the test environment.
|
||||
"""
|
||||
p = Path(".mindmodel/manifest.yaml")
|
||||
assert p.exists(), ".mindmodel/manifest.yaml must exist"
|
||||
|
||||
manifest = parse_manifest(str(p))
|
||||
|
||||
# Accept either a plain mapping or the Manifest dataclass returned by
|
||||
# parse_manifest. Normalize to the files list for assertions.
|
||||
if isinstance(manifest, dict):
|
||||
files = manifest.get("files", [])
|
||||
else:
|
||||
# Manifest dataclass has .files attribute
|
||||
files = getattr(manifest, "files", [])
|
||||
|
||||
assert isinstance(files, list), "manifest.files must be a list"
|
||||
assert files, "manifest must contain at least one file entry"
|
||||
|
||||
for entry in files:
|
||||
assert isinstance(entry, dict), "each file entry should be a mapping"
|
||||
assert "path" in entry, f"file entry missing 'path': {entry}"
|
||||
@@ -0,0 +1,52 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_cli_runs(tmp_path):
|
||||
manifest = Path(".mindmodel/manifest.yaml")
|
||||
assert manifest.exists(), "expected .mindmodel/manifest.yaml to exist in repo"
|
||||
|
||||
report_path = tmp_path / "report.json"
|
||||
|
||||
# Try module mode first, fallback to direct script invocation
|
||||
cmds = [
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"scripts.validate_mindmodel",
|
||||
str(manifest),
|
||||
"--report",
|
||||
str(report_path),
|
||||
],
|
||||
[
|
||||
sys.executable,
|
||||
"scripts/validate_mindmodel.py",
|
||||
str(manifest),
|
||||
"--report",
|
||||
str(report_path),
|
||||
],
|
||||
]
|
||||
|
||||
result = None
|
||||
for cmd in cmds:
|
||||
try:
|
||||
result = subprocess.run(cmd, check=False, capture_output=True, text=True)
|
||||
# if process ran (any exit code), break and use this result
|
||||
break
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
|
||||
assert result is not None, "Failed to run script (no suitable invocation)"
|
||||
# CLI should exit with 0 (report-only)
|
||||
assert result.returncode == 0, (
|
||||
f"CLI exited non-zero: {result.returncode}\nstderr: {result.stderr}"
|
||||
)
|
||||
|
||||
assert report_path.exists(), f"Report file was not created at {report_path}"
|
||||
|
||||
data = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
# top-level keys expected from validator
|
||||
for key in ("missing_files", "truncated_evidence", "potential_secrets"):
|
||||
assert key in data, f"Report JSON missing key: {key}"
|
||||
@@ -0,0 +1,56 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from src.validators.mindmodel_validator import validate_manifest
|
||||
|
||||
|
||||
def test_missing_files_reported(tmp_path):
|
||||
# create two paths that do not exist
|
||||
p1 = str(tmp_path / "missing_one.txt")
|
||||
p2 = str(tmp_path / "missing_two.txt")
|
||||
|
||||
manifest = f"""
|
||||
files:
|
||||
- path: {p1}
|
||||
- path: {p2}
|
||||
"""
|
||||
|
||||
mpath = tmp_path / "manifest_missing.yaml"
|
||||
mpath.write_text(manifest, encoding="utf-8")
|
||||
|
||||
report = validate_manifest(str(mpath))
|
||||
assert "missing_files" in report
|
||||
# both missing paths should be reported
|
||||
assert p1 in report["missing_files"]
|
||||
assert p2 in report["missing_files"]
|
||||
|
||||
|
||||
def test_truncated_evidence_and_secrets_reported(tmp_path):
|
||||
# entry with truncated evidence (ends with ...)
|
||||
trunc_path = str(tmp_path / "trunc.txt")
|
||||
trunc_evidence = "This output was cut off..."
|
||||
|
||||
# entry with potential secret (contains PASSWORD)
|
||||
secret_path = str(tmp_path / "secret.txt")
|
||||
secret_evidence = "Found PASSWORD=sekret123 in the logs"
|
||||
|
||||
manifest = f"""
|
||||
files:
|
||||
- path: {trunc_path}
|
||||
evidence_excerpt: "{trunc_evidence}"
|
||||
- path: {secret_path}
|
||||
evidence_excerpt: "{secret_evidence}"
|
||||
"""
|
||||
|
||||
mpath = tmp_path / "manifest_edgecases.yaml"
|
||||
mpath.write_text(manifest, encoding="utf-8")
|
||||
|
||||
report = validate_manifest(str(mpath))
|
||||
|
||||
# truncated evidence should report the trunc_path
|
||||
assert "truncated_evidence" in report
|
||||
assert any(item.get("path") == trunc_path for item in report["truncated_evidence"])
|
||||
|
||||
# potential secrets should report the secret_path
|
||||
assert "potential_secrets" in report
|
||||
assert any(item.get("path") == secret_path for item in report["potential_secrets"])
|
||||
Reference in New Issue
Block a user