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