parent
ed289ff582
commit
d1faf2b3e4
@ -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"]) |
||||
Loading…
Reference in new issue