sourcepack 1.10.0a0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sourcepack/commands.py ADDED
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ import configparser
4
+ import json
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ COMMAND_SCHEMA_VERSION = "sourcepack.command_resolver.v1"
10
+ COMPOSE_FILES = ("compose.yml", "compose.yaml", "docker-compose.yml", "docker-compose.yaml")
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class CommandResolution:
15
+ verdict: str
16
+ reason_code: str | None
17
+ command: str
18
+ evidence_source: str | None = None
19
+ message: str = ""
20
+
21
+ def to_dict(self) -> dict:
22
+ return {"schema_version": COMMAND_SCHEMA_VERSION, "verdict": self.verdict, "reason_code": self.reason_code, "command": self.command, "evidence_source": self.evidence_source, "message": self.message}
23
+
24
+
25
+ def _safe(root: Path, rel: str) -> Path | None:
26
+ p = (root / rel).resolve()
27
+ try:
28
+ p.relative_to(root.resolve())
29
+ except ValueError:
30
+ return None
31
+ return p
32
+
33
+
34
+ def _read_json(path: Path) -> dict | None:
35
+ try:
36
+ return json.loads(path.read_text(encoding="utf-8"))
37
+ except Exception:
38
+ return None
39
+
40
+
41
+ def _make_targets(text: str) -> set[str]:
42
+ return {m.group(1) for m in re.finditer(r"^([A-Za-z0-9_.-][^\s:=]*)\s*:(?!=)", text, re.M)}
43
+
44
+
45
+ def _just_targets(text: str) -> set[str]:
46
+ return {m.group(1) for m in re.finditer(r"^([A-Za-z0-9_.-]+)\s*:", text, re.M)}
47
+
48
+
49
+ def _taskfile_targets(data: dict) -> set[str]:
50
+ tasks = data.get("tasks") if isinstance(data, dict) else None
51
+ return set(tasks.keys()) if isinstance(tasks, dict) else set()
52
+
53
+
54
+ def resolve_command(root: str | Path, command: str, *, added_manifests: dict[str, str] | None = None) -> CommandResolution:
55
+ root = Path(root).resolve(); added_manifests = added_manifests or {}; command = command.strip()
56
+ parts = command.split()
57
+ if not parts:
58
+ return CommandResolution("WARN", "command_check_inconclusive", command, message="empty command")
59
+ if len(parts) >= 3 and parts[0] == "npm" and parts[1] == "run":
60
+ script = parts[2]
61
+ pj = _safe(root, "package.json")
62
+ if "package.json" in added_manifests:
63
+ data = _read_json_from_text(added_manifests["package.json"])
64
+ if script in (data.get("scripts") or {}):
65
+ return CommandResolution("WARN", "declared_command", command, "package.json", "script added in patch")
66
+ if not pj or not pj.exists():
67
+ return CommandResolution("WARN", "command_manifest_missing", command, "package.json", "package.json missing")
68
+ data = _read_json(pj) or {}
69
+ return CommandResolution("PASS", None, command, "package.json", "script present") if script in (data.get("scripts") or {}) else CommandResolution("FAIL", "unsupported_command", command, "package.json", "npm script missing")
70
+ if len(parts) >= 3 and parts[0] == "docker" and parts[1] == "compose":
71
+ for name in COMPOSE_FILES:
72
+ p = _safe(root, name)
73
+ if p and p.exists():
74
+ return CommandResolution("PASS", None, command, name, "compose file present")
75
+ return CommandResolution("FAIL", "unsupported_command", command, ",".join(COMPOSE_FILES), "compose file missing")
76
+ if parts[0] == "make" and len(parts) >= 2:
77
+ p = _safe(root, "Makefile")
78
+ if not p or not p.exists():
79
+ return CommandResolution("WARN", "command_manifest_missing", command, "Makefile", "Makefile missing")
80
+ targets = _make_targets(p.read_text(encoding="utf-8", errors="ignore"))
81
+ return CommandResolution("PASS", None, command, "Makefile", "target present") if parts[1] in targets else CommandResolution("FAIL", "unsupported_command", command, "Makefile", "Make target missing")
82
+ if parts[0] == "just" and len(parts) >= 2:
83
+ p = _safe(root, "justfile") or _safe(root, "Justfile")
84
+ if not p or not p.exists():
85
+ return CommandResolution("WARN", "command_manifest_missing", command, "justfile", "justfile missing")
86
+ targets = _just_targets(p.read_text(encoding="utf-8", errors="ignore"))
87
+ return CommandResolution("PASS", None, command, str(p.name), "recipe present") if parts[1] in targets else CommandResolution("FAIL", "unsupported_command", command, str(p.name), "recipe missing")
88
+ if parts[0] == "task" and len(parts) >= 2:
89
+ for name in ("Taskfile.yml", "Taskfile.yaml"):
90
+ p = _safe(root, name)
91
+ if p and p.exists():
92
+ try:
93
+ import yaml # type: ignore
94
+ data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
95
+ except Exception:
96
+ data = _simple_taskfile_parse(p.read_text(encoding="utf-8", errors="ignore"))
97
+ targets = _taskfile_targets(data)
98
+ return CommandResolution("PASS", None, command, name, "task present") if parts[1] in targets else CommandResolution("FAIL", "unsupported_command", command, name, "task missing")
99
+ return CommandResolution("WARN", "command_manifest_missing", command, "Taskfile.yml", "Taskfile missing")
100
+ if parts[0] in {"pytest", "py.test"} or (len(parts) >= 3 and parts[0] == "python" and parts[1] == "-m" and parts[2] == "pytest"):
101
+ has_tests = any((root / name).exists() for name in ("tests", "test", "pytest.ini"))
102
+ if has_tests:
103
+ return CommandResolution("PASS", None, command, "tests", "pytest evidence present")
104
+ pyproject = _safe(root, "pyproject.toml")
105
+ requirements = list(root.glob("requirements*.txt"))
106
+ manifest_text = "\n".join(p.read_text(encoding="utf-8", errors="ignore") for p in ([pyproject] if pyproject and pyproject.exists() else []) + requirements)
107
+ if re.search(r"(?im)\bpytest\b", manifest_text):
108
+ return CommandResolution("PASS", None, command, "python dependency manifest", "pytest dependency present")
109
+ return CommandResolution("FAIL", "unsupported_command", command, "tests/pytest.ini/pyproject.toml", "pytest project evidence missing")
110
+ if parts[0] == "tox" and "-e" in parts:
111
+ env = parts[parts.index("-e") + 1] if parts.index("-e") + 1 < len(parts) else ""
112
+ p = _safe(root, "tox.ini")
113
+ if not p or not p.exists():
114
+ return CommandResolution("WARN", "command_check_inconclusive", command, "tox.ini", "tox.ini missing")
115
+ cp = configparser.ConfigParser(); cp.read(p)
116
+ raw = cp.get("tox", "envlist", fallback="")
117
+ if "{" in raw or "}" in raw or not raw:
118
+ return CommandResolution("WARN", "command_check_inconclusive", command, "tox.ini", "dynamic or missing envlist")
119
+ envs = {e.strip() for e in re.split(r"[,\n]", raw) if e.strip()}
120
+ return CommandResolution("PASS", None, command, "tox.ini", "env present") if env in envs else CommandResolution("FAIL", "unsupported_command", command, "tox.ini", "tox env missing")
121
+ if parts[0] == "nox" and "-s" in parts:
122
+ session = parts[parts.index("-s") + 1] if parts.index("-s") + 1 < len(parts) else ""
123
+ p = _safe(root, "noxfile.py")
124
+ if not p or not p.exists():
125
+ return CommandResolution("WARN", "command_manifest_missing", command, "noxfile.py", "noxfile missing")
126
+ text = p.read_text(encoding="utf-8", errors="ignore")
127
+ if re.search(r"@nox\.session(?:\([^)]*\))?\s*\ndef\s+" + re.escape(session) + r"\b", text):
128
+ return CommandResolution("PASS", None, command, "noxfile.py", "session present")
129
+ return CommandResolution("WARN", "command_check_inconclusive", command, "noxfile.py", "dynamic or missing nox session")
130
+ return CommandResolution("WARN", "command_check_inconclusive", command, message="command parser unsupported")
131
+
132
+
133
+ def _read_json_from_text(text: str) -> dict:
134
+ try:
135
+ return json.loads(text)
136
+ except Exception:
137
+ return {}
138
+
139
+
140
+ def _simple_taskfile_parse(text: str) -> dict:
141
+ tasks: dict[str, dict] = {}
142
+ in_tasks = False
143
+ for line in text.splitlines():
144
+ if re.match(r"^tasks:\s*$", line):
145
+ in_tasks = True; continue
146
+ if in_tasks:
147
+ m = re.match(r"^\s{2}([A-Za-z0-9_.-]+):", line)
148
+ if m: tasks[m.group(1)] = {}
149
+ return {"tasks": tasks}
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ import ast, json, re, sys, tomllib
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from .ecosystems.python import PY_IMPORT_ALIASES
7
+
8
+ DEPENDENCY_SCHEMA_VERSION = "sourcepack.dependency_resolver.v1"
9
+ UNSUPPORTED_ECOSYSTEM_MANIFESTS = {"Cargo.toml", "go.mod", "pom.xml", "build.gradle", "settings.gradle"}
10
+
11
+ @dataclass(frozen=True)
12
+ class DependencyResolution:
13
+ verdict: str
14
+ reason_code: str | None
15
+ dependency: str
16
+ evidence_source: str | None = None
17
+ message: str = ""
18
+ def to_dict(self) -> dict:
19
+ return {"schema_version": DEPENDENCY_SCHEMA_VERSION, "verdict": self.verdict, "reason_code": self.reason_code, "dependency": self.dependency, "evidence_source": self.evidence_source, "message": self.message}
20
+
21
+ def normalize_python_package(name: str) -> str:
22
+ base = name.split(".")[0].replace("_", "-").lower()
23
+ return PY_IMPORT_ALIASES.get(base, base)
24
+
25
+ def normalize_js_package(spec: str) -> str:
26
+ if spec.startswith(".") or spec.startswith("/"):
27
+ return spec
28
+ parts = spec.split("/")
29
+ return "/".join(parts[:2]) if spec.startswith("@") and len(parts) >= 2 else parts[0]
30
+
31
+ def python_declared_dependencies(root: str | Path) -> dict[str, str]:
32
+ root = Path(root); found: dict[str, str] = {}
33
+ pyproject = root / "pyproject.toml"
34
+ if pyproject.exists():
35
+ try: data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
36
+ except Exception: data = {}
37
+ for dep in data.get("project", {}).get("dependencies", []) or []: found[_dep_name(dep)] = "pyproject.toml"
38
+ for group, deps in (data.get("project", {}).get("optional-dependencies", {}) or {}).items():
39
+ for dep in deps or []: found.setdefault(_dep_name(dep), f"pyproject.toml optional:{group}")
40
+ for group, gdata in (data.get("dependency-groups", {}) or {}).items():
41
+ for dep in (gdata if isinstance(gdata, list) else []): found.setdefault(_dep_name(str(dep)), f"pyproject.toml group:{group}")
42
+ poetry = data.get("tool", {}).get("poetry", {}).get("dependencies", {}) or {}
43
+ for dep in poetry:
44
+ if dep.lower() != "python": found[_dep_name(dep)] = "pyproject.toml poetry"
45
+ for req in root.glob("requirements*.txt"):
46
+ for line in req.read_text(encoding="utf-8", errors="ignore").splitlines():
47
+ line = line.strip()
48
+ if line and not line.startswith("#") and not line.startswith("-"):
49
+ found[_dep_name(line)] = req.name
50
+ return found
51
+
52
+ def js_declared_dependencies(root: str | Path) -> dict[str, str]:
53
+ pj = Path(root) / "package.json"; found: dict[str, str] = {}
54
+ if not pj.exists(): return found
55
+ try: data = json.loads(pj.read_text(encoding="utf-8"))
56
+ except Exception: return found
57
+ for section in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
58
+ for dep in (data.get(section) or {}): found[dep] = f"package.json {section}"
59
+ return found
60
+
61
+ def resolve_python_import(root: str | Path, imported: str, *, added_dependencies: set[str] | None = None) -> DependencyResolution:
62
+ root = Path(root); top = imported.split(".")[0]
63
+ if top in sys.stdlib_module_names: return DependencyResolution("PASS", None, imported, "python stdlib", "stdlib")
64
+ if (root / (top + ".py")).exists() or (root / top / "__init__.py").exists() or (root / "src" / top / "__init__.py").exists() or (root / "src" / (top + ".py")).exists(): return DependencyResolution("PASS", None, imported, "worktree", "local module")
65
+ pkg = normalize_python_package(imported); declared = python_declared_dependencies(root)
66
+ if pkg in (added_dependencies or set()): return DependencyResolution("WARN", "declared_dependency", pkg, "patch", "dependency added in same patch")
67
+ if pkg in declared:
68
+ source = declared[pkg]
69
+ if "optional:" in source or "group:" in source: return DependencyResolution("WARN", "dependency_scope_review", pkg, source, "declared outside runtime dependency scope")
70
+ return DependencyResolution("PASS", None, pkg, source, "declared")
71
+ return DependencyResolution("FAIL", "unsupported_dependency", pkg, None, "external dependency not declared")
72
+
73
+ def resolve_js_import(root: str | Path, spec: str) -> DependencyResolution:
74
+ root = Path(root); pkg = normalize_js_package(spec)
75
+ if pkg.startswith(".") or pkg.startswith("/"): return DependencyResolution("PASS", None, spec, "relative import", "local relative import")
76
+ declared = js_declared_dependencies(root)
77
+ if pkg in declared:
78
+ src = declared[pkg]
79
+ if "devDependencies" in src: return DependencyResolution("WARN", "dependency_scope_review", pkg, src, "devDependency requires scope review")
80
+ return DependencyResolution("PASS", None, pkg, src, "declared")
81
+ if spec.startswith(("@/", "~/")):
82
+ return DependencyResolution("WARN", "js_alias_uncertain", spec, "tsconfig.json", "alias requires bounded resolver")
83
+ return DependencyResolution("FAIL", "unsupported_dependency", pkg, None, "package dependency not declared")
84
+
85
+ def unsupported_ecosystems(root: str | Path) -> list[DependencyResolution]:
86
+ root = Path(root); return [DependencyResolution("WARN", "unsupported_ecosystem", m.name, m.name, "ecosystem detected but not semantically resolved") for m in root.iterdir() if m.name in UNSUPPORTED_ECOSYSTEM_MANIFESTS]
87
+
88
+ def imports_from_python_source(text: str) -> set[str]:
89
+ out = set()
90
+ try: tree = ast.parse(text)
91
+ except SyntaxError: return out
92
+ for node in ast.walk(tree):
93
+ if isinstance(node, ast.Import): out.update(alias.name for alias in node.names)
94
+ elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: out.add(node.module)
95
+ return out
96
+
97
+ def _dep_name(spec: str) -> str:
98
+ return re.split(r"[<>=!~;\[\s]", str(spec).strip(), 1)[0].replace("_", "-").lower()
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from pathlib import PurePosixPath
6
+
7
+ @dataclass
8
+ class PatchFileChange:
9
+ path: str
10
+ old_path: str | None
11
+ new_file: bool = False
12
+ deleted_file: bool = False
13
+ added_lines: list[str] | None = None
14
+ diff_lines: list[str] | None = None
15
+ unsafe_path: bool = False
16
+ operation: str = "modify"
17
+
18
+
19
+ def normalize_diff_path(path: str) -> tuple[str, bool]:
20
+ raw = path.strip().replace("\\", "/")
21
+ if raw.startswith("a/") or raw.startswith("b/"):
22
+ raw = raw[2:]
23
+ if not raw or raw in {"a/", "b/"}:
24
+ return raw, True
25
+ if raw.startswith("/") or re.match(r"^[A-Za-z]:/", raw):
26
+ return raw, True
27
+ parts: list[str] = []
28
+ unsafe = False
29
+ for part in PurePosixPath(raw).parts:
30
+ if part in {"", "."}:
31
+ continue
32
+ if part == "..":
33
+ if not parts:
34
+ unsafe = True
35
+ else:
36
+ parts.pop()
37
+ continue
38
+ parts.append(part)
39
+ return "/".join(parts), unsafe
40
+
41
+
42
+ def parse_unified_diff(text: str) -> list[PatchFileChange]:
43
+ changes: list[PatchFileChange] = []
44
+ current: PatchFileChange | None = None
45
+ old_path: str | None = None
46
+ new_path: str | None = None
47
+ new_file = False
48
+ deleted_file = False
49
+ operation = "modify"
50
+
51
+ malformed = False
52
+
53
+ def clean(path: str) -> tuple[str, bool]:
54
+ path = path.strip().split("\t", 1)[0]
55
+ return normalize_diff_path(path)
56
+
57
+ def flush():
58
+ nonlocal current
59
+ if current is not None:
60
+ changes.append(current)
61
+ current = None
62
+
63
+ for line in text.splitlines():
64
+ if line.startswith("diff --git "):
65
+ flush(); old_path = new_path = None; new_file = deleted_file = False; operation = "modify"
66
+ parts = line.split()
67
+ if len(parts) >= 4:
68
+ old_path, old_unsafe = clean(parts[2]); new_path, new_unsafe = clean(parts[3])
69
+ if old_unsafe or new_unsafe:
70
+ malformed = True
71
+ else:
72
+ malformed = True
73
+ elif line.startswith("new file mode"):
74
+ new_file = True
75
+ elif line.startswith("deleted file mode"):
76
+ deleted_file = True
77
+ elif line.startswith("rename from "):
78
+ old_path, unsafe = clean(line.removeprefix("rename from "))
79
+ operation = "rename"
80
+ malformed = malformed or unsafe
81
+ elif line.startswith("rename to "):
82
+ new_path, unsafe = clean(line.removeprefix("rename to "))
83
+ operation = "rename"
84
+ malformed = malformed or unsafe
85
+ current = PatchFileChange(path=new_path or old_path or "", old_path=old_path, new_file=False, deleted_file=False, added_lines=[], diff_lines=[], unsafe_path=unsafe, operation=operation)
86
+ elif line.startswith("copy from "):
87
+ old_path, unsafe = clean(line.removeprefix("copy from "))
88
+ operation = "copy"
89
+ malformed = malformed or unsafe
90
+ elif line.startswith("copy to "):
91
+ new_path, unsafe = clean(line.removeprefix("copy to "))
92
+ operation = "copy"
93
+ malformed = malformed or unsafe
94
+ current = PatchFileChange(path=new_path or old_path or "", old_path=old_path, new_file=True, deleted_file=False, added_lines=[], diff_lines=[], unsafe_path=unsafe, operation=operation)
95
+ elif line.startswith("--- "):
96
+ val = line[4:].strip()
97
+ if val == "/dev/null":
98
+ old_path = None
99
+ else:
100
+ old_path, unsafe = clean(val)
101
+ malformed = malformed or unsafe
102
+ elif line.startswith("+++ "):
103
+ val = line[4:].strip()
104
+ if val == "/dev/null":
105
+ new_path = None
106
+ unsafe = False
107
+ else:
108
+ new_path, unsafe = clean(val)
109
+ malformed = malformed or unsafe
110
+ path = new_path or old_path or ""
111
+ current = PatchFileChange(path=path, old_path=old_path, new_file=new_file or old_path is None, deleted_file=deleted_file or new_path is None, added_lines=[], diff_lines=[], unsafe_path=unsafe, operation=operation)
112
+ elif line.startswith("@@ ") and current is None:
113
+ malformed = True
114
+ elif current is not None and line.startswith("+") and not line.startswith("+++"):
115
+ current.added_lines.append(line[1:])
116
+ current.diff_lines.append(line)
117
+ elif current is not None and (line.startswith("-") or line.startswith(" ") or line.startswith("@@")):
118
+ current.diff_lines.append(line)
119
+ flush()
120
+ if malformed:
121
+ changes.append(PatchFileChange(path="", old_path=None, added_lines=[], diff_lines=[], unsafe_path=True))
122
+ return changes
@@ -0,0 +1,3 @@
1
+ from .python import PY_IMPORT_ALIASES
2
+
3
+ __all__ = ["PY_IMPORT_ALIASES"]
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ UNSUPPORTED_ECOSYSTEM_FILES = {
4
+ "Cargo.toml": "Rust/Cargo",
5
+ "go.mod": "Go modules",
6
+ "pom.xml": "Java/Maven",
7
+ "build.gradle": "Java/Gradle",
8
+ "Gemfile": "Ruby/Bundler",
9
+ "composer.json": "PHP/Composer",
10
+ "*.csproj": ".NET/NuGet",
11
+ "main.tf": "Terraform",
12
+ "flake.nix": "Nix",
13
+ }
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ JS_SOURCE_EXTENSIONS = {".js", ".jsx", ".ts", ".tsx"}
@@ -0,0 +1,12 @@
1
+ from __future__ import annotations
2
+
3
+ PY_IMPORT_ALIASES: dict[str, str] = {
4
+ "yaml": "pyyaml",
5
+ "cv2": "opencv-python",
6
+ "pil": "pillow",
7
+ "sklearn": "scikit-learn",
8
+ "bs4": "beautifulsoup4",
9
+ "dotenv": "python-dotenv",
10
+ "jwt": "pyjwt",
11
+ "dateutil": "python-dateutil",
12
+ }
sourcepack/errors.py ADDED
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ class SourcePackError(Exception):
4
+ """Base class for typed SourcePack core failures."""
5
+
6
+ class BaselineMissingError(SourcePackError):
7
+ pass
8
+
9
+ class BaselineCorruptError(SourcePackError):
10
+ pass
11
+
12
+ class MalformedDiffError(SourcePackError):
13
+ pass
14
+
15
+ class UnsafePathError(SourcePackError):
16
+ pass
17
+
18
+ class UnsupportedEcosystemError(SourcePackError):
19
+ pass
sourcepack/evidence.py ADDED
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+ from enum import StrEnum
5
+ from typing import Iterable
6
+
7
+ EVIDENCE_SCHEMA_VERSION = "sourcepack.evidence.v1"
8
+
9
+
10
+ class EvidenceClass(StrEnum):
11
+ TRUSTED_BASELINE = "trusted_baseline"
12
+ CURRENT_WORKTREE = "current_worktree"
13
+ DEPENDENCY_MANIFEST = "dependency_manifest"
14
+ COMMAND_MANIFEST = "command_manifest"
15
+ EXECUTION_LEDGER = "execution_ledger"
16
+ GIT_METADATA = "git_metadata"
17
+ PROMPT_CONTEXT = "prompt_context"
18
+ AI_ANSWER = "ai_answer"
19
+ USER_CONFIG = "user_config"
20
+ UNSUPPORTED = "unsupported"
21
+ NOT_CHECKED = "not_checked"
22
+
23
+
24
+ TRUST_LEVELS = {
25
+ EvidenceClass.TRUSTED_BASELINE: "trusted",
26
+ EvidenceClass.CURRENT_WORKTREE: "local_observation",
27
+ EvidenceClass.DEPENDENCY_MANIFEST: "local_manifest",
28
+ EvidenceClass.COMMAND_MANIFEST: "local_manifest",
29
+ EvidenceClass.EXECUTION_LEDGER: "local_execution_record",
30
+ EvidenceClass.GIT_METADATA: "local_metadata",
31
+ EvidenceClass.USER_CONFIG: "user_policy",
32
+ EvidenceClass.PROMPT_CONTEXT: "advisory",
33
+ EvidenceClass.AI_ANSWER: "advisory",
34
+ EvidenceClass.UNSUPPORTED: "unsupported",
35
+ EvidenceClass.NOT_CHECKED: "not_checked",
36
+ }
37
+
38
+ ENFORCEMENT_CAPABLE = {
39
+ EvidenceClass.TRUSTED_BASELINE,
40
+ EvidenceClass.CURRENT_WORKTREE,
41
+ EvidenceClass.DEPENDENCY_MANIFEST,
42
+ EvidenceClass.COMMAND_MANIFEST,
43
+ EvidenceClass.EXECUTION_LEDGER,
44
+ EvidenceClass.GIT_METADATA,
45
+ EvidenceClass.USER_CONFIG,
46
+ }
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class EvidenceRecord:
51
+ evidence_class: str
52
+ evidence_source: str
53
+ trust_level: str
54
+ checked_status: str
55
+ missing_evidence: str | None = None
56
+ required_evidence_class: str | None = None
57
+ supports_claim: str | None = None
58
+
59
+ def to_dict(self) -> dict:
60
+ return asdict(self)
61
+
62
+
63
+ def normalize_evidence_class(value: str | EvidenceClass) -> EvidenceClass:
64
+ return value if isinstance(value, EvidenceClass) else EvidenceClass(str(value))
65
+
66
+
67
+ def make_evidence(evidence_class: str | EvidenceClass, evidence_source: str, checked_status: str = "checked", *, missing_evidence: str | None = None, required_evidence_class: str | EvidenceClass | None = None, supports_claim: str | None = None) -> EvidenceRecord:
68
+ cls = normalize_evidence_class(evidence_class)
69
+ req = normalize_evidence_class(required_evidence_class).value if required_evidence_class else None
70
+ return EvidenceRecord(cls.value, evidence_source, TRUST_LEVELS[cls], checked_status, missing_evidence, req, supports_claim)
71
+
72
+
73
+ def can_satisfy(evidence: EvidenceRecord | dict, required: str | EvidenceClass, claim: str | None = None) -> bool:
74
+ eclass = normalize_evidence_class(evidence["evidence_class"] if isinstance(evidence, dict) else evidence.evidence_class)
75
+ required_cls = normalize_evidence_class(required)
76
+ if eclass in {EvidenceClass.PROMPT_CONTEXT, EvidenceClass.AI_ANSWER, EvidenceClass.UNSUPPORTED, EvidenceClass.NOT_CHECKED}:
77
+ return False
78
+ if eclass != required_cls:
79
+ return False
80
+ if eclass == EvidenceClass.EXECUTION_LEDGER and claim not in {None, "local_execution"}:
81
+ return False
82
+ return eclass in ENFORCEMENT_CAPABLE
83
+
84
+
85
+ def evidence_summary(records: Iterable[EvidenceRecord | dict]) -> dict:
86
+ checked: list[dict] = []
87
+ missing: list[dict] = []
88
+ advisory: list[dict] = []
89
+ not_checked: list[dict] = []
90
+ for rec in records:
91
+ item = rec if isinstance(rec, dict) else rec.to_dict()
92
+ cls = item.get("evidence_class")
93
+ status = item.get("checked_status")
94
+ if cls in {EvidenceClass.PROMPT_CONTEXT.value, EvidenceClass.AI_ANSWER.value}:
95
+ advisory.append(item)
96
+ elif cls == EvidenceClass.NOT_CHECKED.value or status == "not_checked":
97
+ not_checked.append(item)
98
+ elif item.get("missing_evidence") or status in {"missing", "unavailable"}:
99
+ missing.append(item)
100
+ else:
101
+ checked.append(item)
102
+ return {"schema_version": EVIDENCE_SCHEMA_VERSION, "checked_evidence": checked, "missing_evidence": missing, "advisory_evidence_ignored_for_enforcement": advisory, "not_checked": not_checked}
103
+
104
+
105
+ def attach_evidence_to_finding(finding: dict, evidence_class: str | EvidenceClass, evidence_source: str, checked_status: str = "checked", **kwargs) -> dict:
106
+ result = dict(finding)
107
+ rec = make_evidence(evidence_class, evidence_source, checked_status, **kwargs).to_dict()
108
+ result.update(rec)
109
+ return result