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/__init__.py +19 -0
- sourcepack/assets/__init__.py +1 -0
- sourcepack/assets/audit_template.md +3 -0
- sourcepack/assets/packet_instructions.md +3 -0
- sourcepack/baseline.py +285 -0
- sourcepack/cli.py +2991 -0
- sourcepack/commands.py +149 -0
- sourcepack/dependencies.py +98 -0
- sourcepack/diff_parser.py +122 -0
- sourcepack/ecosystems/__init__.py +3 -0
- sourcepack/ecosystems/generic.py +13 -0
- sourcepack/ecosystems/node.py +3 -0
- sourcepack/ecosystems/python.py +12 -0
- sourcepack/errors.py +19 -0
- sourcepack/evidence.py +109 -0
- sourcepack/execution_ledger.py +252 -0
- sourcepack/git.py +50 -0
- sourcepack/judgment.py +1922 -0
- sourcepack/packet.py +837 -0
- sourcepack/paths.py +68 -0
- sourcepack/policy.py +38 -0
- sourcepack/reason_codes.py +72 -0
- sourcepack/reports/__init__.py +5 -0
- sourcepack/reports/html.py +88 -0
- sourcepack/reports/json.py +123 -0
- sourcepack/reports/markdown.py +61 -0
- sourcepack/schemas.py +63 -0
- sourcepack-1.10.0a0.dist-info/METADATA +311 -0
- sourcepack-1.10.0a0.dist-info/RECORD +33 -0
- sourcepack-1.10.0a0.dist-info/WHEEL +5 -0
- sourcepack-1.10.0a0.dist-info/entry_points.txt +2 -0
- sourcepack-1.10.0a0.dist-info/licenses/LICENSE +21 -0
- sourcepack-1.10.0a0.dist-info/top_level.txt +1 -0
sourcepack/cli.py
ADDED
|
@@ -0,0 +1,2991 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import importlib.resources as resources
|
|
5
|
+
import fnmatch
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
import tomllib
|
|
11
|
+
import webbrowser
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
from dataclasses import dataclass, asdict
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path, PurePosixPath
|
|
20
|
+
from typing import Iterable
|
|
21
|
+
from xml.sax.saxutils import escape as xml_escape
|
|
22
|
+
from .ecosystems.python import PY_IMPORT_ALIASES
|
|
23
|
+
from .paths import ensure_gitignore_entry, ensure_sourcepack_dirs, sourcepack_paths
|
|
24
|
+
from .reports.html import render_report_html
|
|
25
|
+
from .reports.json import normalized_finding, traffic_report, write_user_report
|
|
26
|
+
from .reports.markdown import LIGHT_BY_VERDICT, SEVERITY_ORDER, render_traffic
|
|
27
|
+
from .execution_ledger import clear_ledger, entry_to_json, execution_findings, iter_entries, run_and_record, find_repo_root
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
from . import __version__
|
|
31
|
+
except Exception:
|
|
32
|
+
__version__ = "1.10.0-alpha"
|
|
33
|
+
|
|
34
|
+
DEFAULT_IGNORED_DIRS = {
|
|
35
|
+
".git", "node_modules", ".venv", "venv", "__pycache__", "dist", "build",
|
|
36
|
+
".next", ".cache", "target", "coverage", ".pytest_cache", ".sourcepack"
|
|
37
|
+
}
|
|
38
|
+
DEFAULT_IGNORED_PATTERNS = {
|
|
39
|
+
".env", ".env.*", "*.pem", "*.key", "*.sqlite", "*.db", "*.png", "*.jpg",
|
|
40
|
+
"*.jpeg", "*.gif", "*.webp", "*.pdf", "*.zip", "*.tar", "*.gz", "*.exe",
|
|
41
|
+
"*.dll", "*.so", "*.dylib", "*.bin", "*.pyc"
|
|
42
|
+
}
|
|
43
|
+
DEFAULT_TEXT_EXTENSIONS = {
|
|
44
|
+
".txt", ".md", ".py", ".js", ".ts", ".tsx", ".jsx", ".json", ".yaml", ".yml",
|
|
45
|
+
".html", ".css", ".csv", ".toml", ".ini", ".sql", ".sh", ".bat", ".ps1", ".rs",
|
|
46
|
+
".go", ".java", ".c", ".cpp", ".h", ".hpp", ".rb", ".php", ".xml"
|
|
47
|
+
}
|
|
48
|
+
SECRET_PATTERNS = [
|
|
49
|
+
("openai_key", re.compile(r"sk-proj-[A-Za-z0-9_\-]{12,}|sk-[A-Za-z0-9]{24,}")),
|
|
50
|
+
("aws_access_key", re.compile(r"AKIA[0-9A-Z]{16}")),
|
|
51
|
+
("private_key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
|
|
52
|
+
("generic_api_key", re.compile(r"(?i)(api[_-]?key|secret|token)\s*[:=]\s*['\"]?[A-Za-z0-9_\-]{16,}")),
|
|
53
|
+
("github_token", re.compile(r"ghp_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}")),
|
|
54
|
+
("slack_token", re.compile(r"xox[baprs]-[A-Za-z0-9\-]{20,}")),
|
|
55
|
+
]
|
|
56
|
+
COMMON_DEPENDENCIES = ["fastapi", "flask", "django", "react", "vue", "svelte", "pytest", "typer", "click", "sqlalchemy", "prisma", "pydantic", "pyyaml", "pillow", "beautifulsoup4", "opencv-python", "scikit-learn", "python-dotenv", "pyjwt", "python-dateutil", "boto3", "requests"]
|
|
57
|
+
FEATURE_NAMES = ("pdf", "ocr", "web server", "react", "docker", "authentication", "database")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def utc_now() -> str:
|
|
61
|
+
return datetime.now(timezone.utc).isoformat()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def sha256_file(path: Path) -> str:
|
|
65
|
+
h = hashlib.sha256()
|
|
66
|
+
with path.open("rb") as f:
|
|
67
|
+
for block in iter(lambda: f.read(1024 * 1024), b""):
|
|
68
|
+
h.update(block)
|
|
69
|
+
return h.hexdigest()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def sha256_text(text: str) -> str:
|
|
73
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def estimate_tokens(text: str) -> int:
|
|
77
|
+
return (len(text) + 3) // 4
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def is_probably_binary(path: Path, sample_size: int = 4096) -> bool:
|
|
81
|
+
try:
|
|
82
|
+
data = path.read_bytes()[:sample_size]
|
|
83
|
+
except OSError:
|
|
84
|
+
return True
|
|
85
|
+
if b"\x00" in data:
|
|
86
|
+
return True
|
|
87
|
+
if not data:
|
|
88
|
+
return False
|
|
89
|
+
nonprintable = sum(1 for b in data if b < 9 or (13 < b < 32))
|
|
90
|
+
return (nonprintable / max(len(data), 1)) > 0.30
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def matches_any(name: str, patterns: Iterable[str]) -> bool:
|
|
94
|
+
return any(fnmatch.fnmatch(name, pattern) for pattern in patterns)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def redact_secrets(text: str):
|
|
98
|
+
redactions = []
|
|
99
|
+
redacted = text
|
|
100
|
+
for label, pattern in SECRET_PATTERNS:
|
|
101
|
+
def repl(match):
|
|
102
|
+
redactions.append({"pattern": label, "span_start": match.start(), "span_end": match.end()})
|
|
103
|
+
return f"[REDACTED:{label}]"
|
|
104
|
+
redacted = pattern.sub(repl, redacted)
|
|
105
|
+
return redacted, redactions
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class IncludedFile:
|
|
110
|
+
relative_path: str
|
|
111
|
+
absolute_path: str
|
|
112
|
+
size_bytes: int
|
|
113
|
+
sha256: str
|
|
114
|
+
source_sha256: str
|
|
115
|
+
packet_sha256: str
|
|
116
|
+
estimated_tokens: int
|
|
117
|
+
extension: str
|
|
118
|
+
content: str
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class IgnoredFile:
|
|
123
|
+
relative_path: str
|
|
124
|
+
reason: str
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class SourceScanner:
|
|
128
|
+
def __init__(self, input_path: str | Path, max_file_size: int = 1_000_000, include_hidden: bool = False, redact: bool = True):
|
|
129
|
+
self.input_path = Path(input_path).resolve()
|
|
130
|
+
self.max_file_size = max_file_size
|
|
131
|
+
self.include_hidden = include_hidden
|
|
132
|
+
self.redact = redact
|
|
133
|
+
self.included_files: list[IncludedFile] = []
|
|
134
|
+
self.ignored_files: list[IgnoredFile] = []
|
|
135
|
+
self.redactions: list[dict] = []
|
|
136
|
+
self.total_seen = 0
|
|
137
|
+
|
|
138
|
+
def ignore(self, path: Path, reason: str):
|
|
139
|
+
rel = str(path.relative_to(self.input_path)) if path.is_absolute() or self.input_path in path.parents else str(path)
|
|
140
|
+
self.ignored_files.append(IgnoredFile(rel, reason))
|
|
141
|
+
|
|
142
|
+
def scan(self):
|
|
143
|
+
if not self.input_path.exists():
|
|
144
|
+
raise FileNotFoundError(f"Input path does not exist: {self.input_path}")
|
|
145
|
+
if not self.input_path.is_dir():
|
|
146
|
+
raise NotADirectoryError(f"Input path is not a directory: {self.input_path}")
|
|
147
|
+
for root, dirs, files in os.walk(self.input_path, followlinks=False):
|
|
148
|
+
root_path = Path(root)
|
|
149
|
+
dirs[:] = sorted(dirs)
|
|
150
|
+
files = sorted(files)
|
|
151
|
+
kept_dirs = []
|
|
152
|
+
for d in dirs:
|
|
153
|
+
dpath = root_path / d
|
|
154
|
+
rel = dpath.relative_to(self.input_path)
|
|
155
|
+
if d in DEFAULT_IGNORED_DIRS:
|
|
156
|
+
self.ignored_files.append(IgnoredFile(str(rel) + "/", "ignored_directory"))
|
|
157
|
+
elif not self.include_hidden and d.startswith("."):
|
|
158
|
+
self.ignored_files.append(IgnoredFile(str(rel) + "/", "hidden_directory"))
|
|
159
|
+
elif dpath.is_symlink():
|
|
160
|
+
self.ignored_files.append(IgnoredFile(str(rel) + "/", "symlink_skipped"))
|
|
161
|
+
else:
|
|
162
|
+
kept_dirs.append(d)
|
|
163
|
+
dirs[:] = kept_dirs
|
|
164
|
+
for filename in files:
|
|
165
|
+
fp = root_path / filename
|
|
166
|
+
rel = fp.relative_to(self.input_path)
|
|
167
|
+
self.total_seen += 1
|
|
168
|
+
rel_str = str(rel)
|
|
169
|
+
if fp.is_symlink():
|
|
170
|
+
self.ignored_files.append(IgnoredFile(rel_str, "symlink_skipped")); continue
|
|
171
|
+
if not self.include_hidden and filename.startswith("."):
|
|
172
|
+
self.ignored_files.append(IgnoredFile(rel_str, "hidden_file")); continue
|
|
173
|
+
if matches_any(filename, DEFAULT_IGNORED_PATTERNS) or matches_any(rel_str, DEFAULT_IGNORED_PATTERNS):
|
|
174
|
+
self.ignored_files.append(IgnoredFile(rel_str, "ignored_pattern")); continue
|
|
175
|
+
try:
|
|
176
|
+
size = fp.stat().st_size
|
|
177
|
+
except OSError:
|
|
178
|
+
self.ignored_files.append(IgnoredFile(rel_str, "stat_error")); continue
|
|
179
|
+
if size > self.max_file_size:
|
|
180
|
+
self.ignored_files.append(IgnoredFile(rel_str, "max_file_size_exceeded")); continue
|
|
181
|
+
if fp.suffix and fp.suffix.lower() not in DEFAULT_TEXT_EXTENSIONS:
|
|
182
|
+
self.ignored_files.append(IgnoredFile(rel_str, "unsupported_extension")); continue
|
|
183
|
+
if is_probably_binary(fp):
|
|
184
|
+
self.ignored_files.append(IgnoredFile(rel_str, "binary_detected")); continue
|
|
185
|
+
try:
|
|
186
|
+
content = fp.read_text(encoding="utf-8")
|
|
187
|
+
except UnicodeDecodeError:
|
|
188
|
+
self.ignored_files.append(IgnoredFile(rel_str, "decode_error")); continue
|
|
189
|
+
except OSError:
|
|
190
|
+
self.ignored_files.append(IgnoredFile(rel_str, "read_error")); continue
|
|
191
|
+
source_sha256 = sha256_text(content)
|
|
192
|
+
if self.redact:
|
|
193
|
+
redacted, reds = redact_secrets(content)
|
|
194
|
+
for r in reds:
|
|
195
|
+
r["file"] = rel_str
|
|
196
|
+
self.redactions.extend(reds)
|
|
197
|
+
content = redacted
|
|
198
|
+
packet_sha256 = sha256_text(content)
|
|
199
|
+
self.included_files.append(IncludedFile(
|
|
200
|
+
relative_path=rel_str,
|
|
201
|
+
absolute_path=str(fp.resolve()),
|
|
202
|
+
size_bytes=size,
|
|
203
|
+
sha256=packet_sha256,
|
|
204
|
+
source_sha256=source_sha256,
|
|
205
|
+
packet_sha256=packet_sha256,
|
|
206
|
+
estimated_tokens=estimate_tokens(content),
|
|
207
|
+
extension=fp.suffix.lower(),
|
|
208
|
+
content=content,
|
|
209
|
+
))
|
|
210
|
+
self.included_files.sort(key=lambda x: x.relative_path)
|
|
211
|
+
self.ignored_files.sort(key=lambda x: x.relative_path)
|
|
212
|
+
return self
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _tracked_file_inventory(root: Path, included_records: list[dict]) -> dict:
|
|
216
|
+
included = {str(rec.get("relative_path", "")).replace("\\", "/") for rec in included_records}
|
|
217
|
+
files: list[dict] = []
|
|
218
|
+
source = "scanner_included_files"
|
|
219
|
+
try:
|
|
220
|
+
cp = subprocess.run(["git", "ls-files", "-z"], cwd=root, text=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
221
|
+
except (OSError, ValueError):
|
|
222
|
+
cp = None
|
|
223
|
+
if cp is not None and cp.returncode == 0:
|
|
224
|
+
raw_paths = [p.decode("utf-8", "surrogateescape") for p in cp.stdout.split(b"\0") if p]
|
|
225
|
+
source = "git_ls_files" if raw_paths else "scanner_included_files"
|
|
226
|
+
if not raw_paths:
|
|
227
|
+
raw_paths = sorted(included)
|
|
228
|
+
else:
|
|
229
|
+
raw_paths = sorted(included)
|
|
230
|
+
for raw in raw_paths:
|
|
231
|
+
rel = raw.replace("\\", "/")
|
|
232
|
+
path = root / rel
|
|
233
|
+
rec = {"relative_path": rel, "included_in_prompt_context": rel in included, "source": source}
|
|
234
|
+
try:
|
|
235
|
+
if path.exists() and path.is_file():
|
|
236
|
+
rec["sha256"] = sha256_file(path)
|
|
237
|
+
rec["file_type"] = "binary" if is_probably_binary(path) else "text"
|
|
238
|
+
else:
|
|
239
|
+
rec["file_type"] = "missing"
|
|
240
|
+
except OSError:
|
|
241
|
+
rec["file_type"] = "unreadable"
|
|
242
|
+
files.append(rec)
|
|
243
|
+
return {"schema_version": "sourcepack.file_inventory.v1", "generated_at": utc_now(), "source": source, "files": files}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class PacketWriter:
|
|
247
|
+
OUTPUT_FILES = ["manifest.json", "context.md", "context.xml", "file_tree.txt", "ignored_files.txt", "token_report.json", "redactions.json", "reality_map.json", "ai_instructions.md", "file_inventory.json"]
|
|
248
|
+
|
|
249
|
+
def __init__(self, out: str | Path, scanner: SourceScanner, force: bool = False):
|
|
250
|
+
self.out = Path(out)
|
|
251
|
+
self.scanner = scanner
|
|
252
|
+
self.force = force
|
|
253
|
+
|
|
254
|
+
def prepare_out(self):
|
|
255
|
+
if self.out.exists() and any(self.out.iterdir()):
|
|
256
|
+
if not self.force:
|
|
257
|
+
raise FileExistsError(f"Output directory is non-empty: {self.out}")
|
|
258
|
+
for child in self.out.iterdir():
|
|
259
|
+
if child.is_dir():
|
|
260
|
+
shutil.rmtree(child)
|
|
261
|
+
else:
|
|
262
|
+
child.unlink()
|
|
263
|
+
self.out.mkdir(parents=True, exist_ok=True)
|
|
264
|
+
|
|
265
|
+
def write_all(self):
|
|
266
|
+
self.prepare_out()
|
|
267
|
+
included_records = []
|
|
268
|
+
for f in self.scanner.included_files:
|
|
269
|
+
rec = asdict(f)
|
|
270
|
+
rec.pop("content")
|
|
271
|
+
included_records.append(rec)
|
|
272
|
+
ignored_records = [asdict(f) for f in self.scanner.ignored_files]
|
|
273
|
+
total_tokens = sum(f.estimated_tokens for f in self.scanner.included_files)
|
|
274
|
+
total_bytes = sum(f.size_bytes for f in self.scanner.included_files)
|
|
275
|
+
manifest = {
|
|
276
|
+
"input_path": str(self.scanner.input_path),
|
|
277
|
+
"generated_at": utc_now(),
|
|
278
|
+
"tool_version": __version__,
|
|
279
|
+
"total_files_seen": self.scanner.total_seen,
|
|
280
|
+
"total_files_included": len(included_records),
|
|
281
|
+
"total_files_ignored": len(ignored_records),
|
|
282
|
+
"total_bytes_included": total_bytes,
|
|
283
|
+
"total_estimated_tokens": total_tokens,
|
|
284
|
+
"included_files": included_records,
|
|
285
|
+
"ignored_files": ignored_records,
|
|
286
|
+
}
|
|
287
|
+
(self.out / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
|
288
|
+
(self.out / "file_inventory.json").write_text(json.dumps(_tracked_file_inventory(self.scanner.input_path, included_records), indent=2), encoding="utf-8")
|
|
289
|
+
md_parts = ["# SourcePack Context Packet", "", "## Source Manifest Summary", "", f"Input path: {manifest['input_path']}", f"Generated at: {manifest['generated_at']}", f"Files included: {len(included_records)}", f"Estimated tokens: {total_tokens}", ""]
|
|
290
|
+
for f in self.scanner.included_files:
|
|
291
|
+
md_parts.extend([
|
|
292
|
+
f"## File: {f.relative_path}", "", "Metadata:", f"- sha256: {f.sha256}", f"- bytes: {f.size_bytes}", f"- estimated_tokens: {f.estimated_tokens}", "", "Content:", "", f.content, "", "---", ""
|
|
293
|
+
])
|
|
294
|
+
(self.out / "context.md").write_text("\n".join(md_parts), encoding="utf-8")
|
|
295
|
+
xml_parts = ["<sourcepack>", " <files>"]
|
|
296
|
+
for f in self.scanner.included_files:
|
|
297
|
+
xml_parts.append(f' <file path="{xml_escape(f.relative_path)}" sha256="{f.sha256}" bytes="{f.size_bytes}" estimated_tokens="{f.estimated_tokens}">')
|
|
298
|
+
xml_parts.append(" <content>")
|
|
299
|
+
xml_parts.append(xml_escape(f.content))
|
|
300
|
+
xml_parts.append(" </content>")
|
|
301
|
+
xml_parts.append(" </file>")
|
|
302
|
+
xml_parts.extend([" </files>", "</sourcepack>"])
|
|
303
|
+
(self.out / "context.xml").write_text("\n".join(xml_parts), encoding="utf-8")
|
|
304
|
+
tree_lines = []
|
|
305
|
+
for f in self.scanner.included_files:
|
|
306
|
+
tree_lines.append(f"[INC] {f.relative_path}")
|
|
307
|
+
for f in self.scanner.ignored_files:
|
|
308
|
+
tree_lines.append(f"[IGN] {f.relative_path} - {f.reason}")
|
|
309
|
+
(self.out / "file_tree.txt").write_text("\n".join(sorted(tree_lines)) + "\n", encoding="utf-8")
|
|
310
|
+
(self.out / "ignored_files.txt").write_text("\n".join(f"{f.relative_path}\t{f.reason}" for f in self.scanner.ignored_files) + "\n", encoding="utf-8")
|
|
311
|
+
token_report = {
|
|
312
|
+
"total_estimated_tokens": total_tokens,
|
|
313
|
+
"warnings": [limit for limit in [32_000, 128_000, 200_000, 1_000_000] if total_tokens > limit],
|
|
314
|
+
"per_file": [{"relative_path": f.relative_path, "estimated_tokens": f.estimated_tokens} for f in self.scanner.included_files],
|
|
315
|
+
}
|
|
316
|
+
(self.out / "token_report.json").write_text(json.dumps(token_report, indent=2), encoding="utf-8")
|
|
317
|
+
(self.out / "redactions.json").write_text(json.dumps({"redactions": self.scanner.redactions}, indent=2), encoding="utf-8")
|
|
318
|
+
reality_map = generate_reality_map(manifest, self.out)
|
|
319
|
+
(self.out / "reality_map.json").write_text(json.dumps(reality_map, indent=2), encoding="utf-8")
|
|
320
|
+
(self.out / "ai_instructions.md").write_text(render_ai_instructions(reality_map), encoding="utf-8")
|
|
321
|
+
hashes = {name: sha256_file(self.out / name) for name in self.OUTPUT_FILES if (self.out / name).exists()}
|
|
322
|
+
receipt = {"generated_at": utc_now(), "tool_version": __version__, "hashes": hashes}
|
|
323
|
+
(self.out / "receipt.json").write_text(json.dumps(receipt, indent=2), encoding="utf-8")
|
|
324
|
+
return self.out
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _included_paths(manifest: dict) -> set[str]:
|
|
329
|
+
return {rec.get("relative_path", "").replace("\\", "/") for rec in manifest.get("included_files", [])}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _package_json_scripts(packet: Path) -> dict[str, str]:
|
|
333
|
+
contents = _packet_file_contents(packet)
|
|
334
|
+
for rel, content in contents.items():
|
|
335
|
+
if Path(rel).name.lower() == "package.json":
|
|
336
|
+
try:
|
|
337
|
+
package = json.loads(content)
|
|
338
|
+
except json.JSONDecodeError:
|
|
339
|
+
return {}
|
|
340
|
+
scripts = package.get("scripts")
|
|
341
|
+
return scripts if isinstance(scripts, dict) else {}
|
|
342
|
+
return {}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _is_poetry_project(packet: Path) -> bool:
|
|
346
|
+
for rel, content in _packet_file_contents(packet).items():
|
|
347
|
+
if Path(rel).name.lower() == "pyproject.toml" and re.search(r"(?m)^\s*\[tool\.poetry\]\s*$", content):
|
|
348
|
+
return True
|
|
349
|
+
return False
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _uses_unittest(packet: Path) -> bool:
|
|
353
|
+
for rel, content in _packet_file_contents(packet).items():
|
|
354
|
+
if Path(rel).suffix.lower() == ".py" and re.search(r"(?m)^\s*(import\s+unittest|from\s+unittest\s+import\s+)", content):
|
|
355
|
+
return True
|
|
356
|
+
return False
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def generate_reality_map(manifest: dict, packet: Path) -> dict:
|
|
360
|
+
files = _included_paths(manifest)
|
|
361
|
+
lower_files = {f.lower() for f in files}
|
|
362
|
+
deps = dependency_inventory(manifest, packet)
|
|
363
|
+
features = feature_inventory(manifest, packet, deps)
|
|
364
|
+
scripts = _package_json_scripts(packet)
|
|
365
|
+
project_types = []
|
|
366
|
+
package_managers = []
|
|
367
|
+
frameworks = []
|
|
368
|
+
supported_commands = []
|
|
369
|
+
test_commands = []
|
|
370
|
+
build_commands = []
|
|
371
|
+
run_commands = []
|
|
372
|
+
if "pyproject.toml" in lower_files:
|
|
373
|
+
project_types.append("python")
|
|
374
|
+
if any(Path(f).name.lower().startswith("requirements") and f.endswith(".txt") for f in lower_files):
|
|
375
|
+
project_types.append("python")
|
|
376
|
+
package_managers.append("pip")
|
|
377
|
+
if _is_poetry_project(packet):
|
|
378
|
+
package_managers.append("poetry")
|
|
379
|
+
if "package.json" in lower_files:
|
|
380
|
+
project_types.append("node")
|
|
381
|
+
package_managers.append("npm")
|
|
382
|
+
for name in sorted(scripts):
|
|
383
|
+
cmd = "npm test" if name == "test" else f"npm run {name}"
|
|
384
|
+
supported_commands.append(cmd)
|
|
385
|
+
if name == "test": test_commands.append(cmd)
|
|
386
|
+
elif name in {"build", "compile"}: build_commands.append(cmd)
|
|
387
|
+
elif name in {"start", "dev", "serve"}: run_commands.append(cmd)
|
|
388
|
+
if any(Path(f).name.lower() == "dockerfile" for f in files):
|
|
389
|
+
supported_commands.append("docker build")
|
|
390
|
+
build_commands.append("docker build")
|
|
391
|
+
if any(Path(f).name.lower() in {"docker-compose.yml", "compose.yaml", "compose.yml"} for f in files):
|
|
392
|
+
supported_commands.append("docker compose up")
|
|
393
|
+
run_commands.append("docker compose up")
|
|
394
|
+
if "pytest" in deps or any(f == "tests" or f.startswith("tests/") for f in lower_files):
|
|
395
|
+
supported_commands.append("pytest")
|
|
396
|
+
test_commands.append("pytest")
|
|
397
|
+
if _uses_unittest(packet):
|
|
398
|
+
supported_commands.append("python -m unittest")
|
|
399
|
+
test_commands.append("python -m unittest")
|
|
400
|
+
framework_map = {"fastapi": "FastAPI", "flask": "Flask", "django": "Django", "react": "React"}
|
|
401
|
+
for dep, label in framework_map.items():
|
|
402
|
+
if dep in deps or (dep == "react" and "react" in features):
|
|
403
|
+
frameworks.append(label)
|
|
404
|
+
ignored = manifest.get("ignored_files", [])
|
|
405
|
+
ignored_reasons = {}
|
|
406
|
+
for rec in ignored:
|
|
407
|
+
reason = rec.get("reason", "unknown")
|
|
408
|
+
ignored_reasons[reason] = ignored_reasons.get(reason, 0) + 1
|
|
409
|
+
included_count = len(manifest.get("included_files", []))
|
|
410
|
+
safe_claims = [
|
|
411
|
+
f"This packet includes {included_count} source files.",
|
|
412
|
+
f"SourcePack scanned input path: {manifest.get('input_path', '')}.",
|
|
413
|
+
]
|
|
414
|
+
for name in ["pyproject.toml", "package.json", "Dockerfile"]:
|
|
415
|
+
present = name.lower() in {Path(f).name.lower() for f in files}
|
|
416
|
+
safe_claims.append(f"The project {'contains' if present else 'does not include'} {name}.")
|
|
417
|
+
if "react" not in deps and "react" not in features:
|
|
418
|
+
safe_claims.append("No React dependency was detected.")
|
|
419
|
+
if "pdf" not in features:
|
|
420
|
+
safe_claims.append("No PDF parsing capability was detected.")
|
|
421
|
+
if ignored:
|
|
422
|
+
safe_claims.append("The packet includes ignored file records for safety or relevance reasons.")
|
|
423
|
+
claim_boundaries = [
|
|
424
|
+
"SourcePack did not execute the application.",
|
|
425
|
+
"SourcePack did not prove semantic correctness.",
|
|
426
|
+
"SourcePack did not verify external services.",
|
|
427
|
+
"SourcePack did not prove security.",
|
|
428
|
+
"SourcePack did not prove production readiness.",
|
|
429
|
+
"Absence of evidence means unknown, not impossible.",
|
|
430
|
+
"Unsupported claims should be treated as ungrounded.",
|
|
431
|
+
]
|
|
432
|
+
return {
|
|
433
|
+
"reality_map_schema_version": "1.0",
|
|
434
|
+
"tool_version": __version__,
|
|
435
|
+
"generated_at": utc_now(),
|
|
436
|
+
"input_path": manifest.get("input_path", ""),
|
|
437
|
+
"project_types": sorted(set(project_types)),
|
|
438
|
+
"package_managers": sorted(set(package_managers)),
|
|
439
|
+
"frameworks": sorted(set(frameworks)),
|
|
440
|
+
"entry_points": sorted(f for f in files if Path(f).name in {"main.py", "app.py", "server.py", "cli.py"}),
|
|
441
|
+
"test_commands": sorted(set(test_commands)),
|
|
442
|
+
"build_commands": sorted(set(build_commands)),
|
|
443
|
+
"run_commands": sorted(set(run_commands)),
|
|
444
|
+
"supported_commands": sorted(set(supported_commands)),
|
|
445
|
+
"detected_dependencies": sorted(deps),
|
|
446
|
+
"supported_capabilities": sorted(features),
|
|
447
|
+
"excluded_files_summary": {"total": len(ignored), "reasons": ignored_reasons, "records": ignored[:25]},
|
|
448
|
+
"included_file_count": included_count,
|
|
449
|
+
"confirmed_files": sorted(files),
|
|
450
|
+
"ignored_file_count": len(ignored),
|
|
451
|
+
"safe_claims": safe_claims,
|
|
452
|
+
"unknowns": [
|
|
453
|
+
"Runtime behavior was not executed.",
|
|
454
|
+
"Semantic correctness was not proven.",
|
|
455
|
+
"External services were not verified.",
|
|
456
|
+
"Capabilities not present in structural evidence must be treated as unknown.",
|
|
457
|
+
"Missing files must not be invented.",
|
|
458
|
+
],
|
|
459
|
+
"claim_boundaries": claim_boundaries,
|
|
460
|
+
"ai_constraints": [
|
|
461
|
+
"Use only the packet and reality map as project evidence.",
|
|
462
|
+
"Do not invent files, commands, dependencies, frameworks, services, or capabilities.",
|
|
463
|
+
"If a required file is missing, say it is missing.",
|
|
464
|
+
"If a command is unsupported by detected evidence, say it is unsupported.",
|
|
465
|
+
"If a capability is not in supported_capabilities, treat it as unknown or unsupported.",
|
|
466
|
+
"Cite file paths when making project-specific claims.",
|
|
467
|
+
"Do not claim SourcePack proves semantic truth.",
|
|
468
|
+
"Ask for missing files rather than hallucinating them.",
|
|
469
|
+
],
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def render_ai_instructions(reality_map: dict) -> str:
|
|
474
|
+
lines = [
|
|
475
|
+
"# AI Instructions for This SourcePack Packet", "",
|
|
476
|
+
"Use only the packet and `reality_map.json` as project evidence.",
|
|
477
|
+
"Do not invent files, commands, dependencies, frameworks, services, or capabilities.",
|
|
478
|
+
"If a required file is missing, say it is missing and ask for it rather than hallucinating it.",
|
|
479
|
+
"If a command is unsupported by detected evidence, say it is unsupported.",
|
|
480
|
+
"If a capability is not listed in `supported_capabilities`, treat it as unknown or unsupported.",
|
|
481
|
+
"If you introduce a new external dependency, modify the appropriate dependency manifest in the same patch and list it under Dependency Changes.",
|
|
482
|
+
"Only recommend commands listed under Supported Commands unless your patch also adds the project file that defines the new command.",
|
|
483
|
+
"Before referencing a file as existing, it must appear in Confirmed Files; label intentional creations as NEW FILE.",
|
|
484
|
+
"If required evidence is missing, say UNKNOWN and ask for the missing file/output instead of guessing.",
|
|
485
|
+
"Cite file paths when making project-specific claims.",
|
|
486
|
+
"Do not claim SourcePack proves semantic truth, security, production readiness, or external service behavior.", "",
|
|
487
|
+
"## Supported Commands", "",
|
|
488
|
+
]
|
|
489
|
+
cmds = reality_map.get("supported_commands", [])
|
|
490
|
+
lines.extend([f"- `{cmd}`" for cmd in cmds] or ["- None detected"])
|
|
491
|
+
lines.extend(["", "## Supported Capabilities", ""])
|
|
492
|
+
caps = reality_map.get("supported_capabilities", [])
|
|
493
|
+
lines.extend([f"- {cap}" for cap in caps] or ["- None detected"])
|
|
494
|
+
lines.extend(["", "## Confirmed Files", ""])
|
|
495
|
+
lines.extend(f"- `{path}`" for path in reality_map.get("confirmed_files", [])[:200])
|
|
496
|
+
lines.extend(["", "## Required Answer Contract", "", "- Files to modify", "- New files", "- Dependency changes", "- Commands to run", "- Assumptions/unknowns", "- Patch or code", "", "## Claim Boundaries", ""])
|
|
497
|
+
lines.extend(f"- {boundary}" for boundary in reality_map.get("claim_boundaries", []))
|
|
498
|
+
return "\n".join(lines) + "\n"
|
|
499
|
+
|
|
500
|
+
def load_manifest(packet: Path) -> dict:
|
|
501
|
+
return json.loads((packet / "manifest.json").read_text(encoding="utf-8"))
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def verify_packet(packet_path: str | Path, against: str | Path | None = None) -> bool:
|
|
505
|
+
packet = Path(packet_path)
|
|
506
|
+
ok = True
|
|
507
|
+
receipt_path = packet / "receipt.json"
|
|
508
|
+
if not receipt_path.exists():
|
|
509
|
+
print("FAIL receipt.json missing")
|
|
510
|
+
return False
|
|
511
|
+
receipt = json.loads(receipt_path.read_text(encoding="utf-8"))
|
|
512
|
+
for name, expected in receipt.get("hashes", {}).items():
|
|
513
|
+
path = packet / name
|
|
514
|
+
if not path.exists():
|
|
515
|
+
print(f"FAIL {name} missing")
|
|
516
|
+
ok = False
|
|
517
|
+
continue
|
|
518
|
+
actual = sha256_file(path)
|
|
519
|
+
if actual == expected:
|
|
520
|
+
print(f"PASS {name}")
|
|
521
|
+
else:
|
|
522
|
+
print(f"FAIL {name} hash mismatch")
|
|
523
|
+
ok = False
|
|
524
|
+
if against:
|
|
525
|
+
manifest = load_manifest(packet)
|
|
526
|
+
source = Path(against).resolve()
|
|
527
|
+
included = {rec["relative_path"]: rec for rec in manifest.get("included_files", [])}
|
|
528
|
+
for rel, rec in included.items():
|
|
529
|
+
source_file = source / rel
|
|
530
|
+
if not source_file.exists():
|
|
531
|
+
print(f"FAIL source missing {rel}")
|
|
532
|
+
ok = False
|
|
533
|
+
elif is_probably_binary(source_file):
|
|
534
|
+
print(f"WARN source now binary {rel}")
|
|
535
|
+
else:
|
|
536
|
+
try:
|
|
537
|
+
content = source_file.read_text(encoding="utf-8")
|
|
538
|
+
except Exception:
|
|
539
|
+
print(f"FAIL source unreadable {rel}")
|
|
540
|
+
ok = False
|
|
541
|
+
continue
|
|
542
|
+
expected_source_hash = rec.get("source_sha256")
|
|
543
|
+
if expected_source_hash is None:
|
|
544
|
+
expected_source_hash = rec.get("sha256")
|
|
545
|
+
redacted, _ = redact_secrets(content)
|
|
546
|
+
content_hash = sha256_text(redacted)
|
|
547
|
+
else:
|
|
548
|
+
content_hash = sha256_text(content)
|
|
549
|
+
if content_hash != expected_source_hash:
|
|
550
|
+
print(f"FAIL source changed {rel}")
|
|
551
|
+
ok = False
|
|
552
|
+
current_files = []
|
|
553
|
+
for root, dirs, files in os.walk(source, followlinks=False):
|
|
554
|
+
dirs[:] = [d for d in sorted(dirs) if d not in DEFAULT_IGNORED_DIRS and not d.startswith(".")]
|
|
555
|
+
for filename in sorted(files):
|
|
556
|
+
fp = Path(root) / filename
|
|
557
|
+
if filename.startswith(".") or fp.suffix.lower() not in DEFAULT_TEXT_EXTENSIONS:
|
|
558
|
+
continue
|
|
559
|
+
rel = str(fp.relative_to(source))
|
|
560
|
+
if rel not in included:
|
|
561
|
+
current_files.append(rel)
|
|
562
|
+
for rel in current_files:
|
|
563
|
+
print(f"WARN new source file not in packet {rel}")
|
|
564
|
+
print("OVERALL", "PASS" if ok else "FAIL")
|
|
565
|
+
return ok
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
PATHLIKE_EXTENSIONS = {".py", ".js", ".jsx", ".ts", ".tsx", ".json", ".toml", ".yaml", ".yml", ".md", ".txt", ".cfg", ".ini", ".css", ".html", ".rs", ".go", ".java", ".rb", ".php", ".sh"}
|
|
569
|
+
PROJECT_PATH_PREFIXES = {"src", "sourcepack", "tests", "test", "frontend", "backend", "docs", "app", "lib", "packages", "public", "config", "scripts"}
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _normalize_ai_ref(ref: str) -> str | None:
|
|
573
|
+
ref = ref.strip().strip("`'\".,;)")
|
|
574
|
+
ref = ref.replace("\\", "/")
|
|
575
|
+
if ref.endswith(":"):
|
|
576
|
+
ref = ref[:-1]
|
|
577
|
+
while ref.startswith("./"):
|
|
578
|
+
ref = ref[2:]
|
|
579
|
+
if not ref or ref.startswith("/") or re.match(r"^[A-Za-z]:/", ref):
|
|
580
|
+
return None
|
|
581
|
+
normalized, unsafe = _normalize_diff_path(ref)
|
|
582
|
+
if unsafe or not normalized:
|
|
583
|
+
return None
|
|
584
|
+
return normalized
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _looks_like_ai_file_ref(ref: str) -> bool:
|
|
588
|
+
normalized = ref.replace("\\", "/")
|
|
589
|
+
name = PurePosixPath(normalized).name
|
|
590
|
+
if name in {"Dockerfile", "docker-compose.yml", "compose.yaml", "compose.yml", "pyproject.toml", "package.json", "requirements.txt"}:
|
|
591
|
+
return True
|
|
592
|
+
suffix = PurePosixPath(normalized).suffix.lower()
|
|
593
|
+
if suffix not in PATHLIKE_EXTENSIONS:
|
|
594
|
+
return False
|
|
595
|
+
parts = [p for p in PurePosixPath(normalized).parts if p not in {"."}]
|
|
596
|
+
return "/" in normalized or (parts and parts[0] in PROJECT_PATH_PREFIXES)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def extract_refs(text: str) -> set[str]:
|
|
600
|
+
refs: set[str] = set()
|
|
601
|
+
token = r"(?:\./)?[A-Za-z0-9_.-]+(?:[\\/][A-Za-z0-9_.-]+)*\.[A-Za-z0-9_.-]+:?|Dockerfile"
|
|
602
|
+
patterns = [rf"[`'\"]({token})[`'\"]", rf"(?m)^\s*[-*]\s+({token})\b", rf"\b(?:edit|open|update|modify|change|in|file)\s+({token})\b", rf"\b((?:\./)?(?:src|sourcepack|tests|test|frontend|backend|docs|app|lib|packages|public|config|scripts)[\\/][A-Za-z0-9_./\\-]+\.[A-Za-z0-9_.-]+:?)\b"]
|
|
603
|
+
for pattern in patterns:
|
|
604
|
+
for candidate in re.findall(pattern, text, re.I):
|
|
605
|
+
normalized = _normalize_ai_ref(candidate)
|
|
606
|
+
if normalized and _looks_like_ai_file_ref(normalized):
|
|
607
|
+
refs.add(normalized)
|
|
608
|
+
return refs
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _packet_file_contents(packet: Path) -> dict[str, str]:
|
|
612
|
+
context_path = packet / "context.md"
|
|
613
|
+
if not context_path.exists():
|
|
614
|
+
return {}
|
|
615
|
+
text = context_path.read_text(encoding="utf-8", errors="ignore")
|
|
616
|
+
contents: dict[str, str] = {}
|
|
617
|
+
current: str | None = None
|
|
618
|
+
body: list[str] = []
|
|
619
|
+
in_content = False
|
|
620
|
+
for line in text.splitlines():
|
|
621
|
+
if line.startswith("## File: "):
|
|
622
|
+
if current is not None:
|
|
623
|
+
contents[current] = "\n".join(body).rstrip("\n")
|
|
624
|
+
current = line.removeprefix("## File: ").strip()
|
|
625
|
+
body = []
|
|
626
|
+
in_content = False
|
|
627
|
+
elif current is not None and line == "Content:":
|
|
628
|
+
in_content = True
|
|
629
|
+
body = []
|
|
630
|
+
elif current is not None and in_content and line == "---":
|
|
631
|
+
contents[current] = "\n".join(body).rstrip("\n")
|
|
632
|
+
current = None
|
|
633
|
+
body = []
|
|
634
|
+
in_content = False
|
|
635
|
+
elif current is not None and in_content:
|
|
636
|
+
body.append(line)
|
|
637
|
+
if current is not None:
|
|
638
|
+
contents[current] = "\n".join(body).rstrip("\n")
|
|
639
|
+
return contents
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _normalize_dependency_name(name: str) -> str:
|
|
643
|
+
return name.strip().lower().replace("_", "-")
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _dependency_name_for_import(name: str) -> str:
|
|
647
|
+
normalized = _normalize_dependency_name(name)
|
|
648
|
+
return PY_IMPORT_ALIASES.get(normalized, normalized)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _js_package_root(imported: str) -> str:
|
|
652
|
+
imported = imported.strip().lower()
|
|
653
|
+
parts = imported.split("/")
|
|
654
|
+
if imported.startswith("@") and len(parts) >= 2 and parts[0] != "@":
|
|
655
|
+
return "/".join(parts[:2])
|
|
656
|
+
if imported.startswith("@/"):
|
|
657
|
+
return imported
|
|
658
|
+
return parts[0]
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _python_dependency_names_from_requirement_lines(text: str) -> set[str]:
|
|
662
|
+
deps: set[str] = set()
|
|
663
|
+
for line in text.splitlines():
|
|
664
|
+
cleaned = line.split("#", 1)[0].strip()
|
|
665
|
+
if cleaned and not cleaned.startswith(("-", "--")):
|
|
666
|
+
deps.add(_normalize_dependency_name(re.split(r"[<>=!~;\[]", cleaned, maxsplit=1)[0]))
|
|
667
|
+
return deps
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _python_dependency_names_from_pyproject(content: str) -> set[str]:
|
|
671
|
+
try:
|
|
672
|
+
data = tomllib.loads(content)
|
|
673
|
+
except tomllib.TOMLDecodeError:
|
|
674
|
+
return set()
|
|
675
|
+
deps: set[str] = set()
|
|
676
|
+
|
|
677
|
+
def add_requirement(req: object) -> None:
|
|
678
|
+
if isinstance(req, str):
|
|
679
|
+
name = re.split(r"[<>=!~;\[]", req.strip(), maxsplit=1)[0]
|
|
680
|
+
if name:
|
|
681
|
+
deps.add(_normalize_dependency_name(name))
|
|
682
|
+
|
|
683
|
+
project = data.get("project", {})
|
|
684
|
+
if isinstance(project, dict):
|
|
685
|
+
for req in project.get("dependencies", []) if isinstance(project.get("dependencies"), list) else []:
|
|
686
|
+
add_requirement(req)
|
|
687
|
+
optional = project.get("optional-dependencies", {})
|
|
688
|
+
if isinstance(optional, dict):
|
|
689
|
+
for group in optional.values():
|
|
690
|
+
if isinstance(group, list):
|
|
691
|
+
for req in group:
|
|
692
|
+
add_requirement(req)
|
|
693
|
+
|
|
694
|
+
tool = data.get("tool", {})
|
|
695
|
+
if isinstance(tool, dict):
|
|
696
|
+
poetry = tool.get("poetry", {})
|
|
697
|
+
if isinstance(poetry, dict):
|
|
698
|
+
for section_name in ("dependencies", "dev-dependencies"):
|
|
699
|
+
section = poetry.get(section_name, {})
|
|
700
|
+
if isinstance(section, dict):
|
|
701
|
+
for dep in section:
|
|
702
|
+
if dep.lower() != "python":
|
|
703
|
+
deps.add(_normalize_dependency_name(dep))
|
|
704
|
+
group = poetry.get("group", {})
|
|
705
|
+
if isinstance(group, dict):
|
|
706
|
+
for group_data in group.values():
|
|
707
|
+
if isinstance(group_data, dict):
|
|
708
|
+
section = group_data.get("dependencies", {})
|
|
709
|
+
if isinstance(section, dict):
|
|
710
|
+
deps.update(_normalize_dependency_name(dep) for dep in section)
|
|
711
|
+
for tool_name in ("pdm", "uv"):
|
|
712
|
+
tool_data = tool.get(tool_name, {})
|
|
713
|
+
if isinstance(tool_data, dict):
|
|
714
|
+
for key in ("dev-dependencies", "dependency-groups"):
|
|
715
|
+
groups = tool_data.get(key, {})
|
|
716
|
+
if isinstance(groups, dict):
|
|
717
|
+
for group in groups.values():
|
|
718
|
+
if isinstance(group, list):
|
|
719
|
+
for req in group:
|
|
720
|
+
add_requirement(req)
|
|
721
|
+
dependency_groups = data.get("dependency-groups", {})
|
|
722
|
+
if isinstance(dependency_groups, dict):
|
|
723
|
+
for group in dependency_groups.values():
|
|
724
|
+
if isinstance(group, list):
|
|
725
|
+
for req in group:
|
|
726
|
+
add_requirement(req)
|
|
727
|
+
return deps
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _add_common_dependency(deps: set[str], name: str):
|
|
731
|
+
normalized = _normalize_dependency_name(name)
|
|
732
|
+
for dep in COMMON_DEPENDENCIES:
|
|
733
|
+
if normalized == _normalize_dependency_name(dep):
|
|
734
|
+
deps.add(dep.lower())
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def dependency_inventory(manifest: dict, packet: Path) -> set[str]:
|
|
738
|
+
deps: set[str] = set()
|
|
739
|
+
contents = _packet_file_contents(packet)
|
|
740
|
+
for rec in manifest.get("included_files", []):
|
|
741
|
+
rel = rec.get("relative_path", "")
|
|
742
|
+
content = contents.get(rel, "")
|
|
743
|
+
name = Path(rel).name.lower()
|
|
744
|
+
suffix = Path(rel).suffix.lower()
|
|
745
|
+
if name == "pyproject.toml":
|
|
746
|
+
for dep in _python_dependency_names_from_pyproject(content):
|
|
747
|
+
_add_common_dependency(deps, dep)
|
|
748
|
+
elif name.startswith("requirements") and name.endswith(".txt"):
|
|
749
|
+
for dep in _python_dependency_names_from_requirement_lines(content):
|
|
750
|
+
_add_common_dependency(deps, dep)
|
|
751
|
+
elif name == "package.json":
|
|
752
|
+
try:
|
|
753
|
+
package = json.loads(content)
|
|
754
|
+
except json.JSONDecodeError:
|
|
755
|
+
package = {}
|
|
756
|
+
for section in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
|
|
757
|
+
section_deps = package.get(section)
|
|
758
|
+
if isinstance(section_deps, dict):
|
|
759
|
+
for dep_name in section_deps:
|
|
760
|
+
_add_common_dependency(deps, dep_name)
|
|
761
|
+
elif suffix == ".py":
|
|
762
|
+
for imported in re.findall(r"(?m)^\s*(?:import|from)\s+([A-Za-z_][A-Za-z0-9_]*)", content):
|
|
763
|
+
_add_common_dependency(deps, imported)
|
|
764
|
+
elif suffix in {".js", ".jsx", ".ts", ".tsx"}:
|
|
765
|
+
for imported in re.findall(r"""(?:from\s+["']|import\s*\(\s*["']|require\s*\(\s*["'])(@?[A-Za-z0-9_.-]+)""", content):
|
|
766
|
+
_add_common_dependency(deps, _js_package_root(imported))
|
|
767
|
+
return deps
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _has_import(content: str, *modules: str) -> bool:
|
|
771
|
+
module_pattern = "|".join(re.escape(module) for module in modules)
|
|
772
|
+
return bool(re.search(rf"(?m)^\s*(?:import|from)\s+({module_pattern})(?:\b|[._])", content))
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
PDF_DEPENDENCIES = {"pypdf", "pdfplumber", "fitz", "pymupdf"}
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _declares_pdf_dependency(rel: str, content: str) -> bool:
|
|
779
|
+
name = Path(rel).name.lower()
|
|
780
|
+
if name == "pyproject.toml":
|
|
781
|
+
return any(dep in PDF_DEPENDENCIES for dep in _python_dependency_names_from_pyproject(content))
|
|
782
|
+
if name.startswith("requirements") and name.endswith(".txt"):
|
|
783
|
+
return any(dep in PDF_DEPENDENCIES for dep in _python_dependency_names_from_requirement_lines(content))
|
|
784
|
+
return False
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def feature_inventory(manifest: dict, packet: Path, deps: set[str] | None = None) -> set[str]:
|
|
788
|
+
if deps is None:
|
|
789
|
+
deps = dependency_inventory(manifest, packet)
|
|
790
|
+
contents = _packet_file_contents(packet)
|
|
791
|
+
files = {rec.get("relative_path", "").replace("\\", "/") for rec in manifest.get("included_files", [])}
|
|
792
|
+
lower_files = {rel.lower() for rel in files}
|
|
793
|
+
features: set[str] = set()
|
|
794
|
+
|
|
795
|
+
if any(Path(rel).name.lower() in {"dockerfile", "docker-compose.yml", "compose.yaml", "compose.yml"} for rel in files):
|
|
796
|
+
features.add("docker")
|
|
797
|
+
if any(rel.endswith(("/pdf_parser.py", "pdf_parser.py")) for rel in lower_files):
|
|
798
|
+
features.add("pdf")
|
|
799
|
+
if any(_declares_pdf_dependency(rel, content) for rel, content in contents.items()):
|
|
800
|
+
features.add("pdf")
|
|
801
|
+
if "react" in deps or any(rel in {"frontend/app.tsx", "frontend/app.jsx"} for rel in lower_files):
|
|
802
|
+
features.add("react")
|
|
803
|
+
if deps & {"fastapi", "flask", "django"} or any(Path(rel).name.lower() in {"server.py", "app.py"} for rel in files):
|
|
804
|
+
features.add("web server")
|
|
805
|
+
if deps & {"sqlalchemy", "prisma"} or any("/migrations/" in f"/{rel}/" or Path(rel).name.lower() in {"schema.prisma", "schema.sql"} for rel in files):
|
|
806
|
+
features.add("database")
|
|
807
|
+
if any(part == "auth" or part.startswith("auth_") for rel in lower_files for part in Path(rel).parts):
|
|
808
|
+
features.add("authentication")
|
|
809
|
+
|
|
810
|
+
for rel, content in contents.items():
|
|
811
|
+
suffix = Path(rel).suffix.lower()
|
|
812
|
+
if suffix == ".py":
|
|
813
|
+
if _has_import(content, "pypdf", "pdfplumber", "fitz"):
|
|
814
|
+
features.add("pdf")
|
|
815
|
+
if _has_import(content, "fastapi", "flask", "django") or re.search(r"(?m)^\s*@\w+\.(?:route|get|post|put|patch|delete)\(", content):
|
|
816
|
+
features.add("web server")
|
|
817
|
+
if _has_import(content, "sqlalchemy", "prisma") or re.search(r"(?i)\b(sqlite|postgres(?:ql)?|mysql)://", content):
|
|
818
|
+
features.add("database")
|
|
819
|
+
if _has_import(content, "jwt", "oauthlib", "authlib") or re.search(r"(?i)@\w+\.(?:route|get|post)\([^)]*login", content):
|
|
820
|
+
features.add("authentication")
|
|
821
|
+
if _has_import(content, "pytesseract", "easyocr"):
|
|
822
|
+
features.add("ocr")
|
|
823
|
+
elif suffix in {".js", ".jsx", ".ts", ".tsx"}:
|
|
824
|
+
if re.search(r"""(?:from\s+["']react["']|require\s*\(\s*["']react["']|import\s+React\b)""", content):
|
|
825
|
+
features.add("react")
|
|
826
|
+
if re.search(r"(?i)\b(jwt|oauth|session|login)\b", content):
|
|
827
|
+
features.add("authentication")
|
|
828
|
+
elif Path(rel).name.lower() == "package.json":
|
|
829
|
+
if re.search(r'"react"\s*:', content):
|
|
830
|
+
features.add("react")
|
|
831
|
+
return features
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
PROTECTED_PACKET_ARTIFACTS = {"manifest.json", "receipt.json", "reality_map.json", "ai_instructions.md"}
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def _normalize_inventory_path(value: object) -> str | None:
|
|
838
|
+
if not isinstance(value, str):
|
|
839
|
+
return None
|
|
840
|
+
rel, unsafe = _normalize_diff_path(value)
|
|
841
|
+
if unsafe or not rel:
|
|
842
|
+
return None
|
|
843
|
+
return rel
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _baseline_inventory_from_packet(packet: str | Path, manifest: dict | None = None) -> tuple[set[str], bool]:
|
|
847
|
+
"""Return authoritative enforcement baseline paths when a packet has them.
|
|
848
|
+
|
|
849
|
+
Prompt context manifests may be selective, so diff enforcement must prefer the
|
|
850
|
+
baseline file inventory artifact when it exists. The boolean is True only
|
|
851
|
+
when a full inventory artifact was loaded successfully.
|
|
852
|
+
"""
|
|
853
|
+
packet = Path(packet)
|
|
854
|
+
for name in ("file_inventory.json", "inventory.json", "baseline_inventory.json"):
|
|
855
|
+
path = packet / name
|
|
856
|
+
if not path.exists():
|
|
857
|
+
continue
|
|
858
|
+
try:
|
|
859
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
860
|
+
except (OSError, json.JSONDecodeError):
|
|
861
|
+
continue
|
|
862
|
+
raw_files = data.get("files") if isinstance(data, dict) else data
|
|
863
|
+
if not isinstance(raw_files, list):
|
|
864
|
+
continue
|
|
865
|
+
files: set[str] = set()
|
|
866
|
+
for item in raw_files:
|
|
867
|
+
raw_path = item.get("relative_path") if isinstance(item, dict) else item
|
|
868
|
+
rel = _normalize_inventory_path(raw_path)
|
|
869
|
+
if rel:
|
|
870
|
+
files.add(rel)
|
|
871
|
+
return files, True
|
|
872
|
+
return _included_paths(manifest or load_manifest(packet)), False
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def known_files(manifest: dict, packet_path: str | Path | None = None) -> set[str]:
|
|
876
|
+
if packet_path is not None:
|
|
877
|
+
files, _ = _baseline_inventory_from_packet(packet_path, manifest)
|
|
878
|
+
return files
|
|
879
|
+
return _included_paths(manifest)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def supported_commands_inventory(reality_map: dict) -> set[str]:
|
|
883
|
+
return set(reality_map.get("supported_commands", []))
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def docker_evidence(files: set[str]) -> dict[str, bool]:
|
|
887
|
+
names = {Path(f).name.lower() for f in files}
|
|
888
|
+
return {
|
|
889
|
+
"dockerfile": "dockerfile" in names,
|
|
890
|
+
"compose": bool(names & {"docker-compose.yml", "compose.yaml", "compose.yml"}),
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def python_project_evidence(files: set[str], deps: set[str]) -> dict[str, bool]:
|
|
895
|
+
lower = {f.lower() for f in files}
|
|
896
|
+
return {
|
|
897
|
+
"python_project": "pyproject.toml" in lower or any(Path(f).name.lower().startswith("requirements") and f.endswith(".txt") for f in lower),
|
|
898
|
+
"tests": any(f == "tests" or f.startswith("tests/") for f in lower),
|
|
899
|
+
"pytest": "pytest" in deps,
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def node_project_evidence(files: set[str], scripts: dict[str, str]) -> dict[str, bool]:
|
|
904
|
+
return {"package_json": "package.json" in {f.lower() for f in files}, "scripts": bool(scripts)}
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def extract_js_import_specifiers_from_text(text: str) -> set[str]:
|
|
908
|
+
specifiers: set[str] = set()
|
|
909
|
+
patterns = [
|
|
910
|
+
r"""\bimport\s+(?:[^"'()]+?\s+from\s+)?["']([^"']+)["']""",
|
|
911
|
+
r"""\bexport\s+[^"']*?\s+from\s+["']([^"']+)["']""",
|
|
912
|
+
r"""\bimport\s*\(\s*["']([^"']+)["']\s*\)""",
|
|
913
|
+
r"""\brequire\s*\(\s*["']([^"']+)["']\s*\)""",
|
|
914
|
+
]
|
|
915
|
+
for pattern in patterns:
|
|
916
|
+
specifiers.update(m.strip() for m in re.findall(pattern, text) if m.strip())
|
|
917
|
+
return {s.lower() for s in specifiers}
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def extract_imports_from_text(text: str, suffix: str = ".py") -> set[str]:
|
|
921
|
+
imports: set[str] = set()
|
|
922
|
+
if suffix == ".py":
|
|
923
|
+
imports |= set(re.findall(r"(?m)^\s*(?:import|from)\s+([A-Za-z_][A-Za-z0-9_]*)", text))
|
|
924
|
+
elif suffix in JS_EXTS:
|
|
925
|
+
imports |= extract_js_import_specifiers_from_text(text)
|
|
926
|
+
return {i.lower() for i in imports}
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
@dataclass
|
|
930
|
+
class PatchFileChange:
|
|
931
|
+
path: str
|
|
932
|
+
old_path: str | None
|
|
933
|
+
new_file: bool = False
|
|
934
|
+
deleted_file: bool = False
|
|
935
|
+
added_lines: list[str] | None = None
|
|
936
|
+
diff_lines: list[str] | None = None
|
|
937
|
+
unsafe_path: bool = False
|
|
938
|
+
operation: str = "modify"
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _normalize_diff_path(path: str) -> tuple[str, bool]:
|
|
942
|
+
raw = path.strip().replace("\\", "/")
|
|
943
|
+
if raw.startswith("a/") or raw.startswith("b/"):
|
|
944
|
+
raw = raw[2:]
|
|
945
|
+
if not raw or raw in {"a/", "b/"}:
|
|
946
|
+
return raw, True
|
|
947
|
+
if raw.startswith("/") or re.match(r"^[A-Za-z]:/", raw):
|
|
948
|
+
return raw, True
|
|
949
|
+
parts: list[str] = []
|
|
950
|
+
unsafe = False
|
|
951
|
+
for part in PurePosixPath(raw).parts:
|
|
952
|
+
if part in {"", "."}:
|
|
953
|
+
continue
|
|
954
|
+
if part == "..":
|
|
955
|
+
if not parts:
|
|
956
|
+
unsafe = True
|
|
957
|
+
else:
|
|
958
|
+
parts.pop()
|
|
959
|
+
continue
|
|
960
|
+
parts.append(part)
|
|
961
|
+
return "/".join(parts), unsafe
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def parse_unified_diff(text: str) -> list[PatchFileChange]:
|
|
965
|
+
changes: list[PatchFileChange] = []
|
|
966
|
+
current: PatchFileChange | None = None
|
|
967
|
+
old_path: str | None = None
|
|
968
|
+
new_path: str | None = None
|
|
969
|
+
new_file = False
|
|
970
|
+
deleted_file = False
|
|
971
|
+
operation = "modify"
|
|
972
|
+
|
|
973
|
+
malformed = False
|
|
974
|
+
|
|
975
|
+
def clean(path: str) -> tuple[str, bool]:
|
|
976
|
+
path = path.strip().split("\t", 1)[0]
|
|
977
|
+
return _normalize_diff_path(path)
|
|
978
|
+
|
|
979
|
+
def flush():
|
|
980
|
+
nonlocal current
|
|
981
|
+
if current is not None:
|
|
982
|
+
changes.append(current)
|
|
983
|
+
current = None
|
|
984
|
+
|
|
985
|
+
for line in text.splitlines():
|
|
986
|
+
if line.startswith("diff --git "):
|
|
987
|
+
flush(); old_path = new_path = None; new_file = deleted_file = False; operation = "modify"
|
|
988
|
+
parts = line.split()
|
|
989
|
+
if len(parts) >= 4:
|
|
990
|
+
old_path, old_unsafe = clean(parts[2]); new_path, new_unsafe = clean(parts[3])
|
|
991
|
+
if old_unsafe or new_unsafe:
|
|
992
|
+
malformed = True
|
|
993
|
+
else:
|
|
994
|
+
malformed = True
|
|
995
|
+
elif line.startswith("new file mode"):
|
|
996
|
+
new_file = True
|
|
997
|
+
elif line.startswith("deleted file mode"):
|
|
998
|
+
deleted_file = True
|
|
999
|
+
elif line.startswith("rename from "):
|
|
1000
|
+
old_path, unsafe = clean(line.removeprefix("rename from "))
|
|
1001
|
+
operation = "rename"
|
|
1002
|
+
malformed = malformed or unsafe
|
|
1003
|
+
elif line.startswith("rename to "):
|
|
1004
|
+
new_path, unsafe = clean(line.removeprefix("rename to "))
|
|
1005
|
+
operation = "rename"
|
|
1006
|
+
malformed = malformed or unsafe
|
|
1007
|
+
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)
|
|
1008
|
+
elif line.startswith("copy from "):
|
|
1009
|
+
old_path, unsafe = clean(line.removeprefix("copy from "))
|
|
1010
|
+
operation = "copy"
|
|
1011
|
+
malformed = malformed or unsafe
|
|
1012
|
+
elif line.startswith("copy to "):
|
|
1013
|
+
new_path, unsafe = clean(line.removeprefix("copy to "))
|
|
1014
|
+
operation = "copy"
|
|
1015
|
+
malformed = malformed or unsafe
|
|
1016
|
+
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)
|
|
1017
|
+
elif line.startswith("--- "):
|
|
1018
|
+
val = line[4:].strip()
|
|
1019
|
+
if val == "/dev/null":
|
|
1020
|
+
old_path = None
|
|
1021
|
+
else:
|
|
1022
|
+
old_path, unsafe = clean(val)
|
|
1023
|
+
malformed = malformed or unsafe
|
|
1024
|
+
elif line.startswith("+++ "):
|
|
1025
|
+
val = line[4:].strip()
|
|
1026
|
+
if val == "/dev/null":
|
|
1027
|
+
new_path = None
|
|
1028
|
+
unsafe = False
|
|
1029
|
+
else:
|
|
1030
|
+
new_path, unsafe = clean(val)
|
|
1031
|
+
malformed = malformed or unsafe
|
|
1032
|
+
path = new_path or old_path or ""
|
|
1033
|
+
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)
|
|
1034
|
+
elif line.startswith("@@ ") and current is None:
|
|
1035
|
+
malformed = True
|
|
1036
|
+
elif current is not None and line.startswith("+") and not line.startswith("+++"):
|
|
1037
|
+
current.added_lines.append(line[1:])
|
|
1038
|
+
current.diff_lines.append(line)
|
|
1039
|
+
elif current is not None and (line.startswith("-") or line.startswith(" ") or line.startswith("@@")):
|
|
1040
|
+
current.diff_lines.append(line)
|
|
1041
|
+
flush()
|
|
1042
|
+
if malformed:
|
|
1043
|
+
changes.append(PatchFileChange(path="", old_path=None, added_lines=[], diff_lines=[], unsafe_path=True))
|
|
1044
|
+
return changes
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def _dependency_additions_from_patch(changes: list[PatchFileChange]) -> set[str]:
|
|
1048
|
+
return set()
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def analyze_patch(packet_path: str | Path, patch_text: str, changes: list[PatchFileChange] | None = None) -> dict:
|
|
1052
|
+
packet = Path(packet_path)
|
|
1053
|
+
manifest = load_manifest(packet)
|
|
1054
|
+
reality = json.loads((packet / "reality_map.json").read_text(encoding="utf-8")) if (packet / "reality_map.json").exists() else generate_reality_map(manifest, packet)
|
|
1055
|
+
files, baseline_inventory_loaded = _baseline_inventory_from_packet(packet, manifest)
|
|
1056
|
+
deps = dependency_inventory(manifest, packet)
|
|
1057
|
+
scripts = _package_json_scripts(packet)
|
|
1058
|
+
if changes is None:
|
|
1059
|
+
changes = parse_unified_diff(patch_text)
|
|
1060
|
+
patch_deps = _dependency_additions_from_patch(changes)
|
|
1061
|
+
report = {
|
|
1062
|
+
"patch_judgment_schema_version": "1.0",
|
|
1063
|
+
"verdict": "PASS",
|
|
1064
|
+
"modified_files": [], "missing_modified_files": [], "new_files": [], "deleted_files": [],
|
|
1065
|
+
"unsupported_dependencies": [], "unsupported_commands": [], "protected_artifact_modifications": [], "git_path_modifications": [], "warnings": [],
|
|
1066
|
+
}
|
|
1067
|
+
if any(ch.unsafe_path for ch in changes):
|
|
1068
|
+
report["path_escape"] = True
|
|
1069
|
+
all_added = []
|
|
1070
|
+
for ch in changes:
|
|
1071
|
+
report["modified_files"].append(ch.path)
|
|
1072
|
+
if ch.new_file:
|
|
1073
|
+
report["new_files"].append(ch.path)
|
|
1074
|
+
elif ch.operation in {"rename", "copy"}:
|
|
1075
|
+
pass
|
|
1076
|
+
elif ch.path not in files:
|
|
1077
|
+
if baseline_inventory_loaded or ch.path in _included_paths(manifest):
|
|
1078
|
+
report["missing_modified_files"].append(ch.path)
|
|
1079
|
+
else:
|
|
1080
|
+
report.setdefault("uncertain_modified_files", []).append(ch.path)
|
|
1081
|
+
if ch.deleted_file:
|
|
1082
|
+
report["deleted_files"].append(ch.path)
|
|
1083
|
+
protected = ch.path.startswith(".sourcepack/")
|
|
1084
|
+
git_internal = ch.path == ".git" or ch.path.startswith(".git/")
|
|
1085
|
+
workflow = ch.path.startswith(".github/workflows/")
|
|
1086
|
+
if protected:
|
|
1087
|
+
report["protected_artifact_modifications"].append(ch.path)
|
|
1088
|
+
if git_internal:
|
|
1089
|
+
report.setdefault("git_path_modifications", []).append(ch.path)
|
|
1090
|
+
if workflow:
|
|
1091
|
+
report.setdefault("uncertainties", []).append({"id": "workflow_change", "message": f"{ch.path} changes repository automation and requires review", "path": ch.path, "evidence": ch.path})
|
|
1092
|
+
if ch.operation in {"rename", "copy"}:
|
|
1093
|
+
report.setdefault("uncertainties", []).append({"id": "unsupported_rename_copy", "message": f"{ch.operation} semantics for {ch.path} require review", "path": ch.path, "evidence": ch.old_path or ch.path})
|
|
1094
|
+
added = "\n".join(ch.added_lines or [])
|
|
1095
|
+
all_added.append(added)
|
|
1096
|
+
for imported in extract_imports_from_text(added, Path(ch.path).suffix.lower()):
|
|
1097
|
+
for dep in COMMON_DEPENDENCIES:
|
|
1098
|
+
if _normalize_dependency_name(imported) == _normalize_dependency_name(dep) and dep not in deps and dep not in patch_deps:
|
|
1099
|
+
report["unsupported_dependencies"].append(dep)
|
|
1100
|
+
added_text = "\n".join(all_added)
|
|
1101
|
+
supported = supported_commands_inventory(reality)
|
|
1102
|
+
added_paths = {ch.path for ch in changes}
|
|
1103
|
+
compose_added = any(Path(path).name.lower() in {"docker-compose.yml", "compose.yaml", "compose.yml"} for path in added_paths)
|
|
1104
|
+
if re.search(r"docker\s+compose\s+up", added_text, re.I):
|
|
1105
|
+
evidence = docker_evidence(files)
|
|
1106
|
+
if compose_added:
|
|
1107
|
+
report["warnings"].append("Patch adds Docker Compose support used by commands; review the new support.")
|
|
1108
|
+
report.setdefault("declared_commands", []).append("docker compose up")
|
|
1109
|
+
elif not evidence["compose"]:
|
|
1110
|
+
report["unsupported_commands"].append("docker compose up")
|
|
1111
|
+
patch_scripts = set()
|
|
1112
|
+
command_uncertainties = []
|
|
1113
|
+
for ch in changes:
|
|
1114
|
+
if Path(ch.path).name.lower() != "package.json":
|
|
1115
|
+
continue
|
|
1116
|
+
base = _packet_file_contents(packet).get(ch.old_path or ch.path, "")
|
|
1117
|
+
post = _apply_patch_change_to_text(base, ch)
|
|
1118
|
+
if post is None:
|
|
1119
|
+
command_uncertainties.append({"id": "command_manifest_uncertain", "message": f"Could not reconstruct {ch.path} safely", "path": ch.path})
|
|
1120
|
+
continue
|
|
1121
|
+
try:
|
|
1122
|
+
package = json.loads(post)
|
|
1123
|
+
except json.JSONDecodeError:
|
|
1124
|
+
command_uncertainties.append({"id": "command_manifest_uncertain", "message": f"Could not parse {ch.path} as JSON", "path": ch.path})
|
|
1125
|
+
continue
|
|
1126
|
+
package_scripts = package.get("scripts")
|
|
1127
|
+
if isinstance(package_scripts, dict):
|
|
1128
|
+
patch_scripts.update(str(script) for script in package_scripts if isinstance(script, str) and script not in scripts)
|
|
1129
|
+
if command_uncertainties:
|
|
1130
|
+
report.setdefault("uncertainties", []).extend(command_uncertainties)
|
|
1131
|
+
for cmd in sorted(set(re.findall(r"npm\s+(?:run\s+)?[A-Za-z0-9:_-]+", added_text))):
|
|
1132
|
+
normalized = cmd if cmd == "npm test" else cmd
|
|
1133
|
+
if normalized.startswith("npm run "):
|
|
1134
|
+
script = normalized.removeprefix("npm run ").strip()
|
|
1135
|
+
if script in patch_scripts:
|
|
1136
|
+
report["warnings"].append(f"Patch adds npm script {script} used by commands; review the new support.")
|
|
1137
|
+
report.setdefault("declared_commands", []).append(normalized)
|
|
1138
|
+
elif script not in scripts:
|
|
1139
|
+
report["unsupported_commands"].append(normalized)
|
|
1140
|
+
elif normalized == "npm test" and "test" not in scripts:
|
|
1141
|
+
report["unsupported_commands"].append(normalized)
|
|
1142
|
+
if re.search(r"\b(pytest|python\s+-m\s+pytest)\b", added_text, re.I):
|
|
1143
|
+
py = python_project_evidence(files, deps)
|
|
1144
|
+
if not (py["pytest"] or py["tests"] or "pytest" in supported):
|
|
1145
|
+
report["unsupported_commands"].append("pytest")
|
|
1146
|
+
if not baseline_inventory_loaded:
|
|
1147
|
+
outside_context = sorted({
|
|
1148
|
+
ch.path for ch in changes
|
|
1149
|
+
if not ch.new_file
|
|
1150
|
+
and not ch.deleted_file
|
|
1151
|
+
and ch.path not in _included_paths(manifest)
|
|
1152
|
+
})
|
|
1153
|
+
if outside_context:
|
|
1154
|
+
report.setdefault("uncertainties", []).append({"id": "baseline_inventory_missing", "message": "Baseline packet lacks full file inventory; modified files outside prompt context could not be checked against tracked repo inventory.", "evidence": ", ".join(outside_context)})
|
|
1155
|
+
if report["new_files"]:
|
|
1156
|
+
report["warnings"].append("Patch creates new files that were not part of the original packet reality.")
|
|
1157
|
+
fail_keys = ["missing_modified_files", "unsupported_dependencies", "unsupported_commands", "protected_artifact_modifications", "git_path_modifications", "path_escape"]
|
|
1158
|
+
if any(report.get(k) for k in fail_keys):
|
|
1159
|
+
report["verdict"] = "FAIL"
|
|
1160
|
+
elif report["new_files"] or report["warnings"] or report.get("uncertainties"):
|
|
1161
|
+
report["verdict"] = "WARN"
|
|
1162
|
+
for key in ["modified_files", "missing_modified_files", "new_files", "deleted_files", "unsupported_dependencies", "unsupported_commands", "protected_artifact_modifications", "git_path_modifications", "warnings"]:
|
|
1163
|
+
report[key] = sorted(set(report[key]))
|
|
1164
|
+
return report
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def render_patch_judgment_report(report: dict) -> str:
|
|
1168
|
+
traffic = report.get("traffic") if isinstance(report.get("traffic"), dict) else patch_report_to_traffic(report, "patch_judgment_report.json")
|
|
1169
|
+
lines = ["# SourcePack Patch Judgment Report", "", f"Verdict: {traffic.get('verdict', report.get('verdict', 'WARN'))}", f"Report: {report.get('report_path', 'patch_judgment_report.json')}", "", f"Next action: {traffic.get('next_action')}", ""]
|
|
1170
|
+
grouped = [("blockers", "Blockers"), ("warnings", "Review warnings"), ("uncertainties", "Uncertainties")]
|
|
1171
|
+
for key, title in grouped:
|
|
1172
|
+
lines.extend([f"## {title}", ""])
|
|
1173
|
+
lines.extend([f"- {f.get('id')}: {f.get('message')}" for f in report.get(key, [])] or ["None"])
|
|
1174
|
+
lines.append("")
|
|
1175
|
+
for key, title in [("checked_categories", "Checked"), ("not_checked", "Not checked")]:
|
|
1176
|
+
lines.extend([f"## {title}", ""])
|
|
1177
|
+
lines.extend([f"- {item}" for item in report.get(key, [])] or ["None"])
|
|
1178
|
+
lines.append("")
|
|
1179
|
+
lines.extend(["## Raw Patch Sections", ""])
|
|
1180
|
+
sections = [("modified_files", "Modified Files"), ("missing_modified_files", "Missing Modified Files"), ("new_files", "New Files"), ("deleted_files", "Deleted Files"), ("unsupported_dependencies", "Unsupported Dependencies"), ("unsupported_commands", "Unsupported Commands"), ("protected_artifact_modifications", "Protected Packet Artifact Modifications"), ("git_path_modifications", "Git Path Modifications"), ("binary_diffs", "Binary Diffs"), ("binary_diff_blockers", "Binary Diff Blockers"), ("declared_dependencies", "Declared Dependencies"), ("declared_commands", "Declared Commands"), ("warnings_text", "Legacy Warnings")]
|
|
1181
|
+
legacy = dict(report); legacy["warnings_text"] = report.get("legacy_warnings", report.get("warnings", []))
|
|
1182
|
+
for key, title in sections:
|
|
1183
|
+
lines.extend([f"### {title}"])
|
|
1184
|
+
lines.extend([f"- {item}" for item in legacy.get(key, [])] or ["None"])
|
|
1185
|
+
lines.append("")
|
|
1186
|
+
return "\n".join(lines)
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def judge_patch(packet_path: str | Path, patch_path: str | Path, out_dir: str | Path) -> dict:
|
|
1190
|
+
try:
|
|
1191
|
+
patch_text = Path(patch_path).read_text(encoding="utf-8")
|
|
1192
|
+
except UnicodeDecodeError:
|
|
1193
|
+
report = {"verdict": "FAIL", "modified_files": [], "missing_modified_files": [], "new_files": [], "deleted_files": [], "unsupported_dependencies": [], "unsupported_commands": [], "protected_artifact_modifications": [], "warnings": [], "malformed_diff": True}
|
|
1194
|
+
else:
|
|
1195
|
+
report = judge_patch_text(packet_path, patch_text)
|
|
1196
|
+
out = Path(out_dir); out.mkdir(parents=True, exist_ok=True)
|
|
1197
|
+
report_path = str(out / "patch_judgment_report.json")
|
|
1198
|
+
traffic = patch_report_to_traffic(report, report_path)
|
|
1199
|
+
enriched = dict(report)
|
|
1200
|
+
enriched["legacy_warnings"] = list(report.get("warnings", []))
|
|
1201
|
+
enriched.update({
|
|
1202
|
+
"schema_version": "patch_judgment_report.v1",
|
|
1203
|
+
"sourcepack_version": __version__,
|
|
1204
|
+
"generated_at": utc_now(),
|
|
1205
|
+
"light": traffic.get("light"),
|
|
1206
|
+
"reason_type": traffic.get("reason_type"),
|
|
1207
|
+
"commit_policy": traffic.get("commit_policy"),
|
|
1208
|
+
"findings": traffic.get("findings", []),
|
|
1209
|
+
"blockers": traffic.get("blockers", []),
|
|
1210
|
+
"warnings": [f for f in traffic.get("warnings", []) if f.get("category") != "uncertainty"],
|
|
1211
|
+
"uncertainties": [f for f in traffic.get("warnings", []) if f.get("category") == "uncertainty"],
|
|
1212
|
+
"checked_categories": traffic.get("checked_categories", []),
|
|
1213
|
+
"not_checked": traffic.get("not_checked", []),
|
|
1214
|
+
"next_action": traffic.get("next_action"),
|
|
1215
|
+
"report_path": report_path,
|
|
1216
|
+
"traffic": traffic,
|
|
1217
|
+
})
|
|
1218
|
+
text = render_patch_judgment_report(enriched)
|
|
1219
|
+
(out / "patch_judgment_report.md").write_text(text, encoding="utf-8")
|
|
1220
|
+
(out / "patch_judgment_report.json").write_text(json.dumps(enriched, indent=2), encoding="utf-8")
|
|
1221
|
+
print(render_traffic(traffic, verbose=True), end="")
|
|
1222
|
+
return enriched
|
|
1223
|
+
|
|
1224
|
+
def _has_negation_before(text: str, start: int) -> bool:
|
|
1225
|
+
window = text[max(0, start - 48):start].lower()
|
|
1226
|
+
return bool(re.search(r"\b(do not|don't|avoid|not|no|without|unless|until|does not|is no|will not)\b", window))
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
def _ai_dependency_actions(text: str, dep: str) -> bool:
|
|
1230
|
+
dep_pat = re.escape(dep)
|
|
1231
|
+
aliases = [dep_pat]
|
|
1232
|
+
for imported, package in PY_IMPORT_ALIASES.items():
|
|
1233
|
+
if package == _normalize_dependency_name(dep):
|
|
1234
|
+
aliases.append(re.escape(imported))
|
|
1235
|
+
alias_pat = "(?:" + "|".join(sorted(set(aliases), key=len, reverse=True)) + ")"
|
|
1236
|
+
patterns = [
|
|
1237
|
+
rf"\bimport\s+{alias_pat}\b",
|
|
1238
|
+
rf"\bfrom\s+{alias_pat}\s+import\b",
|
|
1239
|
+
rf"\b(?:pip install|python\s+-m\s+pip\s+install|poetry add|uv add|pdm add|add|use|install|import)\s+{dep_pat}\b",
|
|
1240
|
+
]
|
|
1241
|
+
for pattern in patterns:
|
|
1242
|
+
for m in re.finditer(pattern, text, re.I):
|
|
1243
|
+
if not _has_negation_before(text, m.start()):
|
|
1244
|
+
return True
|
|
1245
|
+
return False
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def _ai_js_dependency_actions(text: str, dep: str) -> bool:
|
|
1249
|
+
dep_pat = re.escape(dep)
|
|
1250
|
+
patterns = [
|
|
1251
|
+
rf"\bimport\s+[^\n;]*?from\s+[`'\"]{dep_pat}(?:/[^`'\"]*)?[`'\"]",
|
|
1252
|
+
rf"\brequire\s*\(\s*[`'\"]{dep_pat}(?:/[^`'\"]*)?[`'\"]\s*\)",
|
|
1253
|
+
rf"\b(?:npm install|npm i|pnpm add|yarn add|add|use|install|import)\s+{dep_pat}\b",
|
|
1254
|
+
]
|
|
1255
|
+
for pattern in patterns:
|
|
1256
|
+
for m in re.finditer(pattern, text, re.I):
|
|
1257
|
+
if not _has_negation_before(text, m.start()):
|
|
1258
|
+
return True
|
|
1259
|
+
return False
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def _ai_command_instructions(text: str, command_pattern: str) -> list[str]:
|
|
1263
|
+
found = []
|
|
1264
|
+
for m in re.finditer(command_pattern, text, re.I):
|
|
1265
|
+
before = text[max(0, m.start() - 32):m.start()].lower()
|
|
1266
|
+
line_start = text.rfind("\n", 0, m.start()) + 1
|
|
1267
|
+
line_prefix = text[line_start:m.start()].strip().lower()
|
|
1268
|
+
backticked = m.start() > 0 and m.end() < len(text) and text[m.start() - 1] == "`" and text[m.end()] == "`"
|
|
1269
|
+
instruction = bool(re.search(r"\b(run|then|execute|use|uses|start with)\s+$", before)) or line_prefix in {"-", "*", "1.", "2.", "3."} or backticked
|
|
1270
|
+
if instruction and not _has_negation_before(text, m.start()):
|
|
1271
|
+
found.append(re.sub(r"\s+", " ", m.group(0).strip()).lower())
|
|
1272
|
+
return found
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
def judge_ai_answer(packet_path: str | Path, ai_answer_path: str | Path, out_dir: str | Path | None = None) -> dict:
|
|
1276
|
+
packet = Path(packet_path)
|
|
1277
|
+
manifest = load_manifest(packet)
|
|
1278
|
+
known_files = {rec["relative_path"] for rec in manifest.get("included_files", [])}
|
|
1279
|
+
ai_text = Path(ai_answer_path).read_text(encoding="utf-8")
|
|
1280
|
+
refs = extract_refs(ai_text)
|
|
1281
|
+
deps = dependency_inventory(manifest, packet)
|
|
1282
|
+
scripts = _package_json_scripts(packet)
|
|
1283
|
+
files_lower = {f.lower() for f in known_files}
|
|
1284
|
+
report = {"sourcepack_version": __version__, "supported_files": [], "missing_files": [], "unsupported_dependencies": [], "unsupported_commands": [], "unsupported_capabilities": []}
|
|
1285
|
+
for ref in sorted(refs):
|
|
1286
|
+
if ref in known_files:
|
|
1287
|
+
report["supported_files"].append(ref)
|
|
1288
|
+
else:
|
|
1289
|
+
report["missing_files"].append(ref)
|
|
1290
|
+
for dep in COMMON_DEPENDENCIES:
|
|
1291
|
+
dep_norm = dep.lower()
|
|
1292
|
+
action = _ai_js_dependency_actions(ai_text, dep_norm) if dep_norm in {"react", "vue", "svelte", "prisma"} else _ai_dependency_actions(ai_text, dep_norm)
|
|
1293
|
+
if action and dep_norm not in deps:
|
|
1294
|
+
if dep_norm != "pytest" or not any(f.startswith("tests/") for f in known_files):
|
|
1295
|
+
report["unsupported_dependencies"].append(dep)
|
|
1296
|
+
if _ai_command_instructions(ai_text, r"docker\s+compose\s+up"):
|
|
1297
|
+
if not any(Path(f).name.lower() in {"docker-compose.yml", "compose.yaml", "compose.yml"} for f in known_files):
|
|
1298
|
+
report["unsupported_commands"].append("docker compose up")
|
|
1299
|
+
for cmd in sorted(set(_ai_command_instructions(ai_text, r"npm\s+(?:run\s+)?[A-Za-z0-9:_-]+"))):
|
|
1300
|
+
normalized = cmd
|
|
1301
|
+
if normalized.startswith("npm run "):
|
|
1302
|
+
script = normalized.removeprefix("npm run ").strip()
|
|
1303
|
+
if script not in scripts:
|
|
1304
|
+
report["unsupported_commands"].append(normalized)
|
|
1305
|
+
elif normalized == "npm test" and "test" not in scripts:
|
|
1306
|
+
report["unsupported_commands"].append("npm test")
|
|
1307
|
+
if _ai_command_instructions(ai_text, r"(?:python\s+-m\s+pytest|pytest)"):
|
|
1308
|
+
if not ({"pyproject.toml", "pytest.ini"} & files_lower or any(f.startswith("tests/") for f in known_files) or "pytest" in deps):
|
|
1309
|
+
report["unsupported_commands"].append("pytest")
|
|
1310
|
+
lower_text = ai_text.lower()
|
|
1311
|
+
supported_features = feature_inventory(manifest, packet, deps)
|
|
1312
|
+
for feature in FEATURE_NAMES:
|
|
1313
|
+
for m in re.finditer(rf"\b{re.escape(feature)}\b", lower_text):
|
|
1314
|
+
if feature not in supported_features and not _has_negation_before(lower_text, m.start()):
|
|
1315
|
+
report["unsupported_capabilities"].append(feature)
|
|
1316
|
+
break
|
|
1317
|
+
report["unsupported_dependencies"] = sorted(set(report["unsupported_dependencies"]))
|
|
1318
|
+
report["unsupported_commands"] = sorted(set(report["unsupported_commands"]))
|
|
1319
|
+
report["unsupported_capabilities"] = sorted(set(report["unsupported_capabilities"]))
|
|
1320
|
+
report["verdict"] = "FAIL" if any(report[k] for k in ["missing_files", "unsupported_dependencies", "unsupported_commands", "unsupported_capabilities"]) else "PASS"
|
|
1321
|
+
lines = ["# SourcePack Judgment Report", "", "Verdict: " + report["verdict"], ""]
|
|
1322
|
+
for section, label in [("supported_files", "Supported File References"), ("missing_files", "Missing File References"), ("unsupported_dependencies", "Unsupported Dependencies"), ("unsupported_commands", "Unsupported Commands"), ("unsupported_capabilities", "Unsupported Capabilities")]:
|
|
1323
|
+
lines.append(f"## {label}")
|
|
1324
|
+
items = report[section]
|
|
1325
|
+
if not items:
|
|
1326
|
+
lines.append("None")
|
|
1327
|
+
else:
|
|
1328
|
+
for item in items:
|
|
1329
|
+
prefix = "SUPPORTED" if section == "supported_files" else "NOT FOUND" if section == "missing_files" else "UNSUPPORTED"
|
|
1330
|
+
lines.append(f"- [{prefix}] {item}")
|
|
1331
|
+
lines.append("")
|
|
1332
|
+
if out_dir:
|
|
1333
|
+
out = Path(out_dir); out.mkdir(parents=True, exist_ok=True)
|
|
1334
|
+
(out / "judgment_report.md").write_text("\n".join(lines), encoding="utf-8")
|
|
1335
|
+
(out / "judgment_report.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
|
|
1336
|
+
print("\n".join(lines))
|
|
1337
|
+
return report
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
LIGHT_BY_VERDICT = {"PASS": "GREEN LIGHT", "WARN": "YELLOW LIGHT", "FAIL": "RED LIGHT"}
|
|
1341
|
+
SEVERITY_ORDER = {"error": 0, "warn": 1, "info": 2}
|
|
1342
|
+
PY_STDLIB = set(getattr(sys, "stdlib_module_names", set())) | {"typing", "pathlib", "json", "os", "sys", "re", "subprocess", "datetime", "unittest"}
|
|
1343
|
+
PY_DEP_FILES = {"requirements.txt", "pyproject.toml", "setup.py", "setup.cfg"}
|
|
1344
|
+
JS_EXTS = {".js", ".jsx", ".ts", ".tsx"}
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
def _latest_report_html_path(repo: str | Path) -> Path:
|
|
1349
|
+
return ensure_sourcepack_dirs(repo)["latest_html"]
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def cli_report_path(args) -> int:
|
|
1353
|
+
print(_latest_report_html_path(Path(args.repo).resolve()))
|
|
1354
|
+
return 0
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
def cli_report_open(args) -> int:
|
|
1358
|
+
repo = Path(args.repo).resolve()
|
|
1359
|
+
paths = ensure_sourcepack_dirs(repo)
|
|
1360
|
+
if not paths["latest_json"].exists():
|
|
1361
|
+
print(f"ERROR: no SourcePack report found at {paths['latest_json']}", file=sys.stderr)
|
|
1362
|
+
return 1
|
|
1363
|
+
try:
|
|
1364
|
+
report = json.loads(paths["latest_json"].read_text(encoding="utf-8"))
|
|
1365
|
+
paths["latest_html"].write_text(render_report_html(report), encoding="utf-8")
|
|
1366
|
+
except Exception as exc:
|
|
1367
|
+
print(f"ERROR: could not prepare SourcePack HTML report at {paths['latest_html']}: {exc}", file=sys.stderr)
|
|
1368
|
+
return 1
|
|
1369
|
+
uri = paths["latest_html"].resolve().as_uri()
|
|
1370
|
+
opened = webbrowser.open(uri)
|
|
1371
|
+
print(f"Report HTML: {paths['latest_html']}")
|
|
1372
|
+
if not opened:
|
|
1373
|
+
print("Browser open was not confirmed; open the path above manually.")
|
|
1374
|
+
return 0
|
|
1375
|
+
|
|
1376
|
+
|
|
1377
|
+
def finalize_diff_report(repo: str | Path | None, report: dict, args, stem: str = "diff") -> dict:
|
|
1378
|
+
full = dict(report)
|
|
1379
|
+
if getattr(args, "ci", False):
|
|
1380
|
+
full["ci"] = True
|
|
1381
|
+
if repo is not None:
|
|
1382
|
+
try:
|
|
1383
|
+
write_user_report(repo, full, stem)
|
|
1384
|
+
except Exception as exc:
|
|
1385
|
+
print(f"WARNING: could not write SourcePack report artifacts: {exc}", file=sys.stderr)
|
|
1386
|
+
return full
|
|
1387
|
+
|
|
1388
|
+
def emit_diff_report(report: dict, args, added: bool = False, note: str | None = None) -> int:
|
|
1389
|
+
if getattr(args, "ci", False):
|
|
1390
|
+
args.json = True
|
|
1391
|
+
report["ci"] = True
|
|
1392
|
+
if getattr(args, "json", False):
|
|
1393
|
+
print(json.dumps(report, indent=2))
|
|
1394
|
+
else:
|
|
1395
|
+
if added:
|
|
1396
|
+
print("Added .sourcepack/ to .gitignore.")
|
|
1397
|
+
if note:
|
|
1398
|
+
print(note)
|
|
1399
|
+
print(render_traffic(report, getattr(args, "verbose", False)), end="")
|
|
1400
|
+
verdict = report.get("verdict")
|
|
1401
|
+
return 0 if (verdict == "PASS" or (verdict == "WARN" and not (getattr(args, "strict", False) or getattr(args, "ci", False)))) else 1
|
|
1402
|
+
|
|
1403
|
+
def git_metadata(repo: str | Path) -> dict:
|
|
1404
|
+
root = Path(repo)
|
|
1405
|
+
head = run_git(root, ["rev-parse", "HEAD"])
|
|
1406
|
+
branch = run_git(root, ["rev-parse", "--abbrev-ref", "HEAD"])
|
|
1407
|
+
dirty, dirty_state = git_worktree_dirty(root)
|
|
1408
|
+
return {
|
|
1409
|
+
"branch": branch.stdout.strip() if branch.returncode == 0 else None,
|
|
1410
|
+
"head_commit": head.stdout.strip() if head.returncode == 0 else None,
|
|
1411
|
+
"dirty": dirty if dirty_state is None else None,
|
|
1412
|
+
"dirty_state": dirty_state,
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
def scanner_config_hash() -> str:
|
|
1417
|
+
payload = {
|
|
1418
|
+
"ignored_dirs": sorted(DEFAULT_IGNORED_DIRS),
|
|
1419
|
+
"ignored_patterns": sorted(DEFAULT_IGNORED_PATTERNS),
|
|
1420
|
+
"text_extensions": sorted(DEFAULT_TEXT_EXTENSIONS),
|
|
1421
|
+
"max_file_size": 1_000_000,
|
|
1422
|
+
"include_hidden": False,
|
|
1423
|
+
"redact": True,
|
|
1424
|
+
}
|
|
1425
|
+
return sha256_text(json.dumps(payload, sort_keys=True))
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
class BaselineLockError(RuntimeError):
|
|
1429
|
+
pass
|
|
1430
|
+
|
|
1431
|
+
|
|
1432
|
+
def _rel_to_repo(repo: Path, path: Path | None) -> str | None:
|
|
1433
|
+
if path is None:
|
|
1434
|
+
return None
|
|
1435
|
+
try:
|
|
1436
|
+
return str(path.resolve().relative_to(repo.resolve())).replace("\\", "/")
|
|
1437
|
+
except Exception:
|
|
1438
|
+
return str(path)
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
def _read_json_file(path: Path) -> tuple[dict | None, str | None]:
|
|
1442
|
+
try:
|
|
1443
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
1444
|
+
except json.JSONDecodeError as exc:
|
|
1445
|
+
return None, f"malformed JSON: {exc}"
|
|
1446
|
+
except OSError as exc:
|
|
1447
|
+
return None, f"unreadable: {exc}"
|
|
1448
|
+
if not isinstance(data, dict):
|
|
1449
|
+
return None, "JSON root is not an object"
|
|
1450
|
+
return data, None
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
def baseline_corrupt_result(repo: Path, message: str, details: dict | None = None, packet_path: Path | None = None, metadata_path: Path | None = None, active_pointer_path: Path | None = None, mode: str = "none", active_build_id: str | None = None) -> dict:
|
|
1454
|
+
return {"ok": False, "state": "corrupt", "finding_id": "baseline_corrupt", "message": "Trusted SourcePack baseline is corrupt or unverifiable. Recreate the baseline only after verifying the current repo state should be trusted.", "details": {"reason": message, **(details or {})}, "packet_path": _rel_to_repo(repo, packet_path), "metadata_path": _rel_to_repo(repo, metadata_path), "active_pointer_path": _rel_to_repo(repo, active_pointer_path), "mode": mode, "active_build_id": active_build_id}
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
def resolve_active_baseline(repo: str | Path) -> dict:
|
|
1458
|
+
repo = Path(repo).resolve(); paths = sourcepack_paths(repo); pointer = paths["active_pointer"]
|
|
1459
|
+
if pointer.exists():
|
|
1460
|
+
data, err = _read_json_file(pointer)
|
|
1461
|
+
if err:
|
|
1462
|
+
return baseline_corrupt_result(repo, f"active.json {err}", active_pointer_path=pointer, mode="pointer")
|
|
1463
|
+
build_id = data.get("active_build_id")
|
|
1464
|
+
if not isinstance(build_id, str) or not build_id or "/" in build_id or "\\" in build_id or build_id in {".", ".."}:
|
|
1465
|
+
return baseline_corrupt_result(repo, "active.json has invalid active_build_id", active_pointer_path=pointer, mode="pointer")
|
|
1466
|
+
build_dir = (paths["builds"] / build_id).resolve(); builds_dir = paths["builds"].resolve()
|
|
1467
|
+
try:
|
|
1468
|
+
build_dir.relative_to(builds_dir)
|
|
1469
|
+
except ValueError:
|
|
1470
|
+
return baseline_corrupt_result(repo, "active.json points outside baseline builds", active_pointer_path=pointer, mode="pointer", active_build_id=build_id)
|
|
1471
|
+
packet = build_dir / "packet"; meta = build_dir / "metadata.json"
|
|
1472
|
+
if not build_dir.exists() or not packet.exists():
|
|
1473
|
+
return baseline_corrupt_result(repo, "active.json points to a missing build", packet_path=packet, metadata_path=meta, active_pointer_path=pointer, mode="pointer", active_build_id=build_id)
|
|
1474
|
+
return {"ok": True, "state": "resolved", "mode": "pointer", "packet_path": _rel_to_repo(repo, packet), "metadata_path": _rel_to_repo(repo, meta), "active_pointer_path": _rel_to_repo(repo, pointer), "active_build_id": build_id, "details": {}}
|
|
1475
|
+
legacy = paths["packet"]
|
|
1476
|
+
if legacy.exists():
|
|
1477
|
+
legacy_artifacts = {"manifest.json", "receipt.json", "reality_map.json", "context.md", "ai_instructions.md"}
|
|
1478
|
+
present = {child.name for child in legacy.iterdir()} if legacy.is_dir() else set()
|
|
1479
|
+
if (legacy / "manifest.json").exists():
|
|
1480
|
+
return {"ok": True, "state": "resolved", "mode": "legacy", "packet_path": _rel_to_repo(repo, legacy), "metadata_path": _rel_to_repo(repo, paths["baseline_meta"]), "active_pointer_path": None, "active_build_id": None, "details": {}}
|
|
1481
|
+
if present & legacy_artifacts:
|
|
1482
|
+
return baseline_corrupt_result(repo, "legacy baseline packet has baseline artifacts but is missing manifest.json", packet_path=legacy, mode="legacy")
|
|
1483
|
+
return {"ok": False, "state": "missing", "finding_id": "baseline_missing", "message": "No trusted SourcePack baseline exists while changes are present.", "details": {}, "packet_path": None, "metadata_path": None, "active_pointer_path": None, "mode": "none", "active_build_id": None}
|
|
1484
|
+
|
|
1485
|
+
|
|
1486
|
+
def _validate_packet_artifacts(repo: Path, packet: Path) -> dict | None:
|
|
1487
|
+
required = ["manifest.json", "receipt.json", "reality_map.json"]
|
|
1488
|
+
for name in required:
|
|
1489
|
+
if not (packet / name).exists():
|
|
1490
|
+
return baseline_corrupt_result(repo, f"active packet missing {name}", packet_path=packet)
|
|
1491
|
+
for name in ["manifest.json", "receipt.json", "reality_map.json", "token_report.json", "redactions.json"]:
|
|
1492
|
+
path = packet / name
|
|
1493
|
+
if path.exists():
|
|
1494
|
+
_, err = _read_json_file(path)
|
|
1495
|
+
if err:
|
|
1496
|
+
return baseline_corrupt_result(repo, f"{name} {err}", packet_path=packet)
|
|
1497
|
+
receipt, err = _read_json_file(packet / "receipt.json")
|
|
1498
|
+
if err:
|
|
1499
|
+
return baseline_corrupt_result(repo, f"receipt.json {err}", packet_path=packet)
|
|
1500
|
+
hashes = receipt.get("hashes")
|
|
1501
|
+
if not isinstance(hashes, dict) or not hashes:
|
|
1502
|
+
return baseline_corrupt_result(repo, "receipt.json has no hashes", packet_path=packet)
|
|
1503
|
+
for name, expected in hashes.items():
|
|
1504
|
+
if not isinstance(name, str) or not isinstance(expected, str):
|
|
1505
|
+
return baseline_corrupt_result(repo, "receipt.json contains invalid hash entry", packet_path=packet)
|
|
1506
|
+
if Path(name).is_absolute() or ".." in Path(name).parts:
|
|
1507
|
+
return baseline_corrupt_result(repo, "receipt.json tracks unsafe artifact path", packet_path=packet)
|
|
1508
|
+
packet_root = packet.resolve()
|
|
1509
|
+
path = (packet / name).resolve()
|
|
1510
|
+
try:
|
|
1511
|
+
path.relative_to(packet_root)
|
|
1512
|
+
except ValueError:
|
|
1513
|
+
return baseline_corrupt_result(repo, "receipt.json tracks path outside packet", packet_path=packet)
|
|
1514
|
+
if not path.exists():
|
|
1515
|
+
return baseline_corrupt_result(repo, f"receipt-tracked artifact missing: {name}", packet_path=packet)
|
|
1516
|
+
try:
|
|
1517
|
+
actual = sha256_file(path)
|
|
1518
|
+
except OSError as exc:
|
|
1519
|
+
return baseline_corrupt_result(repo, f"receipt-tracked artifact unreadable: {name}: {exc}", packet_path=packet)
|
|
1520
|
+
if actual != expected:
|
|
1521
|
+
return baseline_corrupt_result(repo, f"receipt hash mismatch: {name}", packet_path=packet)
|
|
1522
|
+
return None
|
|
1523
|
+
|
|
1524
|
+
|
|
1525
|
+
def validate_baseline(repo: str | Path) -> dict:
|
|
1526
|
+
repo = Path(repo).resolve(); resolved = resolve_active_baseline(repo)
|
|
1527
|
+
if resolved.get("state") == "corrupt":
|
|
1528
|
+
return resolved
|
|
1529
|
+
if resolved.get("state") == "missing":
|
|
1530
|
+
return resolved
|
|
1531
|
+
packet = repo / resolved["packet_path"] if resolved.get("packet_path") else None
|
|
1532
|
+
meta = repo / resolved["metadata_path"] if resolved.get("metadata_path") else None
|
|
1533
|
+
corrupt = _validate_packet_artifacts(repo, packet)
|
|
1534
|
+
if corrupt:
|
|
1535
|
+
corrupt.update({"mode": resolved.get("mode", "none"), "metadata_path": resolved.get("metadata_path"), "active_pointer_path": resolved.get("active_pointer_path"), "active_build_id": resolved.get("active_build_id")})
|
|
1536
|
+
return corrupt
|
|
1537
|
+
if meta and meta.exists():
|
|
1538
|
+
_, err = _read_json_file(meta)
|
|
1539
|
+
if err:
|
|
1540
|
+
return baseline_corrupt_result(repo, f"metadata.json {err}", packet_path=packet, metadata_path=meta, active_pointer_path=repo / resolved["active_pointer_path"] if resolved.get("active_pointer_path") else None, mode=resolved.get("mode", "none"), active_build_id=resolved.get("active_build_id"))
|
|
1541
|
+
paths = sourcepack_paths(repo); stale = paths["stale_marker"].exists()
|
|
1542
|
+
stale_details = None
|
|
1543
|
+
if stale:
|
|
1544
|
+
stale_details, err = _read_json_file(paths["stale_marker"])
|
|
1545
|
+
if err:
|
|
1546
|
+
stale_details = {"reason": "unreadable"}
|
|
1547
|
+
return {"ok": True, "state": "stale" if stale else "present", "finding_id": "baseline_stale" if stale else None, "message": "Trusted SourcePack baseline may not match current repo state." if stale else "Trusted SourcePack baseline is present.", "details": {"stale_details": stale_details} if stale else {}, "packet_path": resolved.get("packet_path"), "metadata_path": resolved.get("metadata_path"), "active_pointer_path": resolved.get("active_pointer_path"), "mode": resolved.get("mode"), "active_build_id": resolved.get("active_build_id")}
|
|
1548
|
+
|
|
1549
|
+
|
|
1550
|
+
def acquire_baseline_lock(repo: str | Path, command: str | None = None) -> tuple[Path, int]:
|
|
1551
|
+
paths = ensure_sourcepack_dirs(repo); lock = paths["baseline_lock"]
|
|
1552
|
+
try:
|
|
1553
|
+
fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
1554
|
+
except FileExistsError as exc:
|
|
1555
|
+
raise BaselineLockError("Another SourcePack baseline operation is already in progress.") from exc
|
|
1556
|
+
payload = {"pid": os.getpid(), "command": command, "started_at": utc_now()}
|
|
1557
|
+
os.write(fd, json.dumps(payload).encode("utf-8"))
|
|
1558
|
+
os.fsync(fd)
|
|
1559
|
+
return lock, fd
|
|
1560
|
+
|
|
1561
|
+
|
|
1562
|
+
def release_baseline_lock(lock: Path, fd: int) -> None:
|
|
1563
|
+
try:
|
|
1564
|
+
os.close(fd)
|
|
1565
|
+
finally:
|
|
1566
|
+
try:
|
|
1567
|
+
lock.unlink()
|
|
1568
|
+
except FileNotFoundError:
|
|
1569
|
+
pass
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
def _write_json_atomic(path: Path, payload: dict) -> None:
|
|
1573
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
1574
|
+
with tmp.open("w", encoding="utf-8") as f:
|
|
1575
|
+
json.dump(payload, f, indent=2)
|
|
1576
|
+
f.write("\n")
|
|
1577
|
+
f.flush(); os.fsync(f.fileno())
|
|
1578
|
+
os.replace(tmp, path)
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
def _unique_build_id() -> str:
|
|
1582
|
+
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") + f"-{os.getpid()}"
|
|
1583
|
+
|
|
1584
|
+
|
|
1585
|
+
def build_current_baseline(repo: str | Path, quiet: bool = False, fail_stage: str | None = None) -> tuple[dict, bool]:
|
|
1586
|
+
repo = Path(repo).resolve(); paths = ensure_sourcepack_dirs(repo)
|
|
1587
|
+
previous = validate_baseline(repo); created = previous.get("state") == "missing"
|
|
1588
|
+
lock = fd = None; build_dir = None
|
|
1589
|
+
try:
|
|
1590
|
+
lock, fd = acquire_baseline_lock(repo, "baseline")
|
|
1591
|
+
build_id = _unique_build_id(); build_dir = paths["builds"] / build_id; packet = build_dir / "packet"
|
|
1592
|
+
build_dir.mkdir(parents=True, exist_ok=False)
|
|
1593
|
+
PacketWriter(packet, SourceScanner(repo).scan(), force=True).write_all()
|
|
1594
|
+
if not quiet and not verify_packet(packet):
|
|
1595
|
+
raise RuntimeError("packet verification returned FAIL")
|
|
1596
|
+
candidate = _validate_packet_artifacts(repo, packet)
|
|
1597
|
+
if candidate:
|
|
1598
|
+
raise RuntimeError(candidate["details"].get("reason", "candidate baseline invalid"))
|
|
1599
|
+
meta = {"created_at": utc_now(), "packet_path": _rel_to_repo(repo, packet), "scanner_config_hash": scanner_config_hash(), **git_metadata(repo)}
|
|
1600
|
+
(build_dir / "metadata.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
|
1601
|
+
meta_check, meta_err = _read_json_file(build_dir / "metadata.json")
|
|
1602
|
+
if meta_err:
|
|
1603
|
+
raise RuntimeError(f"metadata.json {meta_err}")
|
|
1604
|
+
if fail_stage == "before_pointer_replace":
|
|
1605
|
+
raise RuntimeError("injected failure before pointer replacement")
|
|
1606
|
+
pointer = {"schema_version": "baseline_pointer.v1", "active_build_id": build_id, "activated_at": utc_now(), "packet_path": _rel_to_repo(repo, packet), "metadata_path": _rel_to_repo(repo, build_dir / "metadata.json")}
|
|
1607
|
+
_write_json_atomic(paths["active_pointer"], pointer)
|
|
1608
|
+
if fail_stage == "after_pointer_replace":
|
|
1609
|
+
raise RuntimeError("injected failure after pointer replacement")
|
|
1610
|
+
# Enforcement state is active.json -> builds/<id>/packet. Legacy packet copies are intentionally not updated after pointer activation.
|
|
1611
|
+
if paths["stale_marker"].exists():
|
|
1612
|
+
paths["stale_marker"].unlink()
|
|
1613
|
+
return paths, created
|
|
1614
|
+
except Exception:
|
|
1615
|
+
if build_dir is not None:
|
|
1616
|
+
active = None
|
|
1617
|
+
try:
|
|
1618
|
+
if paths["active_pointer"].exists():
|
|
1619
|
+
active = json.loads(paths["active_pointer"].read_text(encoding="utf-8")).get("active_build_id")
|
|
1620
|
+
except Exception:
|
|
1621
|
+
active = None
|
|
1622
|
+
if active != build_dir.name:
|
|
1623
|
+
shutil.rmtree(build_dir, ignore_errors=True)
|
|
1624
|
+
raise
|
|
1625
|
+
finally:
|
|
1626
|
+
if lock is not None and fd is not None:
|
|
1627
|
+
release_baseline_lock(lock, fd)
|
|
1628
|
+
|
|
1629
|
+
|
|
1630
|
+
def build_prompt_context(repo: str | Path) -> dict:
|
|
1631
|
+
paths = ensure_sourcepack_dirs(repo)
|
|
1632
|
+
PacketWriter(paths["prompt_packet"], SourceScanner(repo).scan(), force=True).write_all()
|
|
1633
|
+
shutil.copy2(paths["prompt_packet"] / "reality_map.json", paths["prompt_reality"])
|
|
1634
|
+
shutil.copy2(paths["prompt_packet"] / "ai_instructions.md", paths["prompt_instructions"])
|
|
1635
|
+
return paths
|
|
1636
|
+
|
|
1637
|
+
|
|
1638
|
+
def render_prompt(task: str, instructions: str, reality: dict) -> str:
|
|
1639
|
+
def bullets(items):
|
|
1640
|
+
return "\n".join(f"- {item}" for item in items) if items else "- None detected"
|
|
1641
|
+
return "\n".join(["# SourcePack Verified AI Prompt", "", "## User Task", "", task, "", "## AI Grounding Instructions", "", instructions.rstrip(), "", "## Compact Reality Map Summary", "", f"Project types: {', '.join(reality.get('project_types') or ['unknown'])}", f"Included files: {reality.get('included_file_count', 0)}", "", "## Supported Commands", "", bullets(reality.get('supported_commands', [])), "", "## Detected Dependencies", "", bullets(reality.get('detected_dependencies', [])), "", "## Supported Capabilities", "", bullets(reality.get('supported_capabilities', [])), "", "## Unknown and Unsupported Boundaries", "", bullets(reality.get('claim_boundaries', [])), "", "Cite exact file paths for project-specific claims.", "Do not invent files, dependencies, commands, services, or capabilities.", "Absence of evidence means unknown, not impossible.", ""])
|
|
1642
|
+
|
|
1643
|
+
|
|
1644
|
+
def copy_to_clipboard(text: str) -> bool:
|
|
1645
|
+
system = platform.system().lower()
|
|
1646
|
+
cmds = [["pbcopy"]] if system == "darwin" else [["clip"]] if system == "windows" else [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]]
|
|
1647
|
+
for cmd in cmds:
|
|
1648
|
+
if shutil.which(cmd[0]) is None:
|
|
1649
|
+
continue
|
|
1650
|
+
try:
|
|
1651
|
+
if subprocess.run(cmd, input=text, text=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5).returncode == 0:
|
|
1652
|
+
return True
|
|
1653
|
+
except Exception:
|
|
1654
|
+
pass
|
|
1655
|
+
return False
|
|
1656
|
+
|
|
1657
|
+
|
|
1658
|
+
def _is_local_python_import(name: str, path: str, files: set[str]) -> bool:
|
|
1659
|
+
candidates = {f"{name}.py", f"{name}/__init__.py", f"src/{name}.py", f"src/{name}/__init__.py"}
|
|
1660
|
+
parent = str(Path(path).parent).replace("\\", "/")
|
|
1661
|
+
if parent != ".":
|
|
1662
|
+
candidates |= {f"{parent}/{name}.py", f"{parent}/{name}/__init__.py"}
|
|
1663
|
+
return bool(candidates & files)
|
|
1664
|
+
|
|
1665
|
+
|
|
1666
|
+
JS_DEP_SECTIONS = {"dependencies", "devDependencies", "peerDependencies", "optionalDependencies"}
|
|
1667
|
+
|
|
1668
|
+
|
|
1669
|
+
def _package_json_declared_deps_from_added_lines(lines: list[str]) -> set[str]:
|
|
1670
|
+
added = "\n".join(lines)
|
|
1671
|
+
try:
|
|
1672
|
+
package = json.loads(added)
|
|
1673
|
+
except json.JSONDecodeError:
|
|
1674
|
+
package = None
|
|
1675
|
+
deps: set[str] = set()
|
|
1676
|
+
if isinstance(package, dict):
|
|
1677
|
+
for section in JS_DEP_SECTIONS:
|
|
1678
|
+
section_deps = package.get(section)
|
|
1679
|
+
if isinstance(section_deps, dict):
|
|
1680
|
+
deps.update(dep.lower() for dep in section_deps)
|
|
1681
|
+
if deps:
|
|
1682
|
+
return deps
|
|
1683
|
+
for section in JS_DEP_SECTIONS:
|
|
1684
|
+
for body in re.findall(rf'"{section}"\s*:\s*\{{(.*?)\}}', added, re.I | re.S):
|
|
1685
|
+
deps.update(m.lower() for m in re.findall(r'"(@?[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)?)"\s*:', body))
|
|
1686
|
+
return deps
|
|
1687
|
+
|
|
1688
|
+
|
|
1689
|
+
def _apply_patch_change_to_text(original: str, change: PatchFileChange) -> str | None:
|
|
1690
|
+
if change.deleted_file:
|
|
1691
|
+
return ""
|
|
1692
|
+
result = original.splitlines()
|
|
1693
|
+
if result and result[0] == "":
|
|
1694
|
+
result = result[1:]
|
|
1695
|
+
out: list[str] = []
|
|
1696
|
+
idx = 0
|
|
1697
|
+
saw_hunk = False
|
|
1698
|
+
for line in change.diff_lines or []:
|
|
1699
|
+
if line.startswith("@@"):
|
|
1700
|
+
m = re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@", line)
|
|
1701
|
+
if not m:
|
|
1702
|
+
return None
|
|
1703
|
+
old_start = max(int(m.group(1)) - 1, 0)
|
|
1704
|
+
if old_start < idx or old_start > len(result):
|
|
1705
|
+
return None
|
|
1706
|
+
out.extend(result[idx:old_start])
|
|
1707
|
+
idx = old_start
|
|
1708
|
+
saw_hunk = True
|
|
1709
|
+
elif line.startswith(" "):
|
|
1710
|
+
body = line[1:]
|
|
1711
|
+
if idx >= len(result) or result[idx] != body:
|
|
1712
|
+
return None
|
|
1713
|
+
out.append(result[idx])
|
|
1714
|
+
idx += 1
|
|
1715
|
+
elif line.startswith("-"):
|
|
1716
|
+
body = line[1:]
|
|
1717
|
+
if idx >= len(result) or result[idx] != body:
|
|
1718
|
+
return None
|
|
1719
|
+
idx += 1
|
|
1720
|
+
elif line.startswith("+"):
|
|
1721
|
+
out.append(line[1:])
|
|
1722
|
+
if not saw_hunk and not change.new_file:
|
|
1723
|
+
return None
|
|
1724
|
+
out.extend(result[idx:])
|
|
1725
|
+
return "\n".join(out) + ("\n" if original.endswith("\n") or change.new_file else "")
|
|
1726
|
+
|
|
1727
|
+
|
|
1728
|
+
def _python_dependency_names_by_scope_from_pyproject(content: str) -> dict[str, set[str]]:
|
|
1729
|
+
scopes = {"runtime": set(), "dev": set(), "optional": set()}
|
|
1730
|
+
try:
|
|
1731
|
+
data = tomllib.loads(content)
|
|
1732
|
+
except tomllib.TOMLDecodeError:
|
|
1733
|
+
return scopes
|
|
1734
|
+
|
|
1735
|
+
def add_req(target: set[str], req: object) -> None:
|
|
1736
|
+
if isinstance(req, str):
|
|
1737
|
+
name = re.split(r"[<>=!~;\[]", req.strip(), maxsplit=1)[0]
|
|
1738
|
+
if name:
|
|
1739
|
+
target.add(_normalize_dependency_name(name))
|
|
1740
|
+
|
|
1741
|
+
project = data.get("project", {})
|
|
1742
|
+
if isinstance(project, dict):
|
|
1743
|
+
for req in project.get("dependencies", []) if isinstance(project.get("dependencies"), list) else []:
|
|
1744
|
+
add_req(scopes["runtime"], req)
|
|
1745
|
+
optional = project.get("optional-dependencies", {})
|
|
1746
|
+
if isinstance(optional, dict):
|
|
1747
|
+
for group in optional.values():
|
|
1748
|
+
if isinstance(group, list):
|
|
1749
|
+
for req in group:
|
|
1750
|
+
add_req(scopes["optional"], req)
|
|
1751
|
+
tool = data.get("tool", {})
|
|
1752
|
+
if isinstance(tool, dict):
|
|
1753
|
+
poetry = tool.get("poetry", {})
|
|
1754
|
+
if isinstance(poetry, dict):
|
|
1755
|
+
section = poetry.get("dependencies", {})
|
|
1756
|
+
if isinstance(section, dict):
|
|
1757
|
+
for dep in section:
|
|
1758
|
+
if dep.lower() != "python":
|
|
1759
|
+
scopes["runtime"].add(_normalize_dependency_name(dep))
|
|
1760
|
+
for section_name in ("dev-dependencies",):
|
|
1761
|
+
section = poetry.get(section_name, {})
|
|
1762
|
+
if isinstance(section, dict):
|
|
1763
|
+
scopes["dev"].update(_normalize_dependency_name(dep) for dep in section)
|
|
1764
|
+
group = poetry.get("group", {})
|
|
1765
|
+
if isinstance(group, dict):
|
|
1766
|
+
for group_data in group.values():
|
|
1767
|
+
if isinstance(group_data, dict):
|
|
1768
|
+
section = group_data.get("dependencies", {})
|
|
1769
|
+
if isinstance(section, dict):
|
|
1770
|
+
scopes["dev"].update(_normalize_dependency_name(dep) for dep in section)
|
|
1771
|
+
for tool_name in ("pdm", "uv"):
|
|
1772
|
+
tool_data = tool.get(tool_name, {})
|
|
1773
|
+
if isinstance(tool_data, dict):
|
|
1774
|
+
for key in ("dev-dependencies", "dependency-groups"):
|
|
1775
|
+
groups = tool_data.get(key, {})
|
|
1776
|
+
if isinstance(groups, dict):
|
|
1777
|
+
for group in groups.values():
|
|
1778
|
+
if isinstance(group, list):
|
|
1779
|
+
for req in group:
|
|
1780
|
+
add_req(scopes["dev"], req)
|
|
1781
|
+
dependency_groups = data.get("dependency-groups", {})
|
|
1782
|
+
if isinstance(dependency_groups, dict):
|
|
1783
|
+
for group in dependency_groups.values():
|
|
1784
|
+
if isinstance(group, list):
|
|
1785
|
+
for req in group:
|
|
1786
|
+
add_req(scopes["dev"], req)
|
|
1787
|
+
return scopes
|
|
1788
|
+
|
|
1789
|
+
|
|
1790
|
+
def _declared_dependency_scopes_by_ecosystem(manifest: dict, packet: Path) -> dict[str, dict[str, set[str]]]:
|
|
1791
|
+
contents = _packet_file_contents(packet)
|
|
1792
|
+
scopes = {"python": {"runtime": set(), "dev": set(), "optional": set()}, "js": {"runtime": set(), "dev": set(), "optional": set()}}
|
|
1793
|
+
for rel, content in contents.items():
|
|
1794
|
+
name = Path(rel).name.lower()
|
|
1795
|
+
if name == "pyproject.toml":
|
|
1796
|
+
parsed = _python_dependency_names_by_scope_from_pyproject(content)
|
|
1797
|
+
for key, values in parsed.items():
|
|
1798
|
+
scopes["python"][key].update(values)
|
|
1799
|
+
elif name == "requirements.txt":
|
|
1800
|
+
scopes["python"]["runtime"].update(_python_dependency_names_from_requirement_lines(content))
|
|
1801
|
+
elif name.startswith("requirements") and name.endswith(".txt"):
|
|
1802
|
+
target = "dev" if any(x in name for x in ("dev", "test")) else "runtime"
|
|
1803
|
+
scopes["python"][target].update(_python_dependency_names_from_requirement_lines(content))
|
|
1804
|
+
elif name == "package.json":
|
|
1805
|
+
try:
|
|
1806
|
+
package = json.loads(content)
|
|
1807
|
+
except json.JSONDecodeError:
|
|
1808
|
+
package = {}
|
|
1809
|
+
section_map = {"dependencies": "runtime", "peerDependencies": "runtime", "optionalDependencies": "optional", "devDependencies": "dev"}
|
|
1810
|
+
for section, target in section_map.items():
|
|
1811
|
+
section_deps = package.get(section)
|
|
1812
|
+
if isinstance(section_deps, dict):
|
|
1813
|
+
scopes["js"][target].update(dep.lower() for dep in section_deps)
|
|
1814
|
+
return scopes
|
|
1815
|
+
|
|
1816
|
+
|
|
1817
|
+
def _is_test_path(path: str) -> bool:
|
|
1818
|
+
p = path.replace("\\", "/").lower()
|
|
1819
|
+
name = PurePosixPath(p).name
|
|
1820
|
+
return p.startswith(("tests/", "test/")) or "/__tests__/" in f"/{p}" or name.endswith("_test.py") or any(name.endswith(s) for s in (".test.js", ".test.ts", ".spec.js", ".spec.ts", ".test.jsx", ".test.tsx", ".spec.jsx", ".spec.tsx"))
|
|
1821
|
+
|
|
1822
|
+
|
|
1823
|
+
def _dependency_scope_status(dep: str, scopes: dict[str, set[str]], path: str) -> str:
|
|
1824
|
+
dep = _normalize_dependency_name(dep)
|
|
1825
|
+
if dep in scopes.get("runtime", set()):
|
|
1826
|
+
return "supported"
|
|
1827
|
+
if dep in scopes.get("dev", set()):
|
|
1828
|
+
return "supported" if _is_test_path(path) else "scope_review"
|
|
1829
|
+
if dep in scopes.get("optional", set()):
|
|
1830
|
+
return "scope_review"
|
|
1831
|
+
return "missing"
|
|
1832
|
+
|
|
1833
|
+
|
|
1834
|
+
def _declared_dependency_names_from_patch_by_ecosystem_structural(changes: list[PatchFileChange], contents: dict[str, str]) -> tuple[dict[str, set[str]], list[dict]]:
|
|
1835
|
+
deps = {"python": set(), "js": set()}
|
|
1836
|
+
uncertainties: list[dict] = []
|
|
1837
|
+
for ch in changes:
|
|
1838
|
+
name = Path(ch.path).name.lower()
|
|
1839
|
+
if name not in {"package.json", "pyproject.toml"} and not (name.startswith("requirements") and name.endswith(".txt")):
|
|
1840
|
+
continue
|
|
1841
|
+
base = contents.get(ch.old_path or ch.path, "")
|
|
1842
|
+
post = _apply_patch_change_to_text(base, ch)
|
|
1843
|
+
if post is None:
|
|
1844
|
+
uncertainties.append({"id": "dependency_manifest_uncertain", "message": f"Could not reconstruct {ch.path} safely", "path": ch.path})
|
|
1845
|
+
continue
|
|
1846
|
+
if name == "package.json":
|
|
1847
|
+
try:
|
|
1848
|
+
package = json.loads(post)
|
|
1849
|
+
except json.JSONDecodeError:
|
|
1850
|
+
uncertainties.append({"id": "dependency_manifest_uncertain", "message": f"Could not parse {ch.path} as JSON", "path": ch.path})
|
|
1851
|
+
continue
|
|
1852
|
+
for section in JS_DEP_SECTIONS:
|
|
1853
|
+
section_deps = package.get(section)
|
|
1854
|
+
if isinstance(section_deps, dict):
|
|
1855
|
+
deps["js"].update(dep.lower() for dep in section_deps)
|
|
1856
|
+
elif name == "pyproject.toml":
|
|
1857
|
+
parsed = _python_dependency_names_by_scope_from_pyproject(post)
|
|
1858
|
+
deps["python"].update(set().union(*parsed.values()))
|
|
1859
|
+
else:
|
|
1860
|
+
deps["python"].update(_python_dependency_names_from_requirement_lines(post))
|
|
1861
|
+
return deps, uncertainties
|
|
1862
|
+
|
|
1863
|
+
|
|
1864
|
+
def _declared_dependency_names_from_patch_by_ecosystem(changes: list[PatchFileChange]) -> dict[str, set[str]]:
|
|
1865
|
+
deps = {"python": set(), "js": set()}
|
|
1866
|
+
for ch in changes:
|
|
1867
|
+
added = "\n".join(ch.added_lines or [])
|
|
1868
|
+
name = Path(ch.path).name.lower()
|
|
1869
|
+
if name == "package.json":
|
|
1870
|
+
deps["js"].update(_package_json_declared_deps_from_added_lines(ch.added_lines or []))
|
|
1871
|
+
elif name == "pyproject.toml":
|
|
1872
|
+
deps["python"].update(_python_dependency_names_from_pyproject(added))
|
|
1873
|
+
elif name.startswith("requirements") and name.endswith(".txt"):
|
|
1874
|
+
deps["python"].update(_python_dependency_names_from_requirement_lines(added))
|
|
1875
|
+
return deps
|
|
1876
|
+
|
|
1877
|
+
|
|
1878
|
+
def _declared_dependency_names_from_patch(changes: list[PatchFileChange]) -> set[str]:
|
|
1879
|
+
scoped = _declared_dependency_names_from_patch_by_ecosystem(changes)
|
|
1880
|
+
return scoped["python"] | scoped["js"]
|
|
1881
|
+
|
|
1882
|
+
|
|
1883
|
+
def _declared_dependency_names_by_ecosystem(manifest: dict, packet: Path) -> dict[str, set[str]]:
|
|
1884
|
+
declared = {"python": set(), "js": set()}
|
|
1885
|
+
contents = _packet_file_contents(packet)
|
|
1886
|
+
for rec in manifest.get("included_files", []):
|
|
1887
|
+
rel = rec.get("relative_path", "")
|
|
1888
|
+
content = contents.get(rel, "")
|
|
1889
|
+
name = Path(rel).name.lower()
|
|
1890
|
+
if name == "pyproject.toml":
|
|
1891
|
+
declared["python"].update(_python_dependency_names_from_pyproject(content))
|
|
1892
|
+
elif name.startswith("requirements") and name.endswith(".txt"):
|
|
1893
|
+
declared["python"].update(_python_dependency_names_from_requirement_lines(content))
|
|
1894
|
+
elif name == "package.json":
|
|
1895
|
+
try:
|
|
1896
|
+
package = json.loads(content)
|
|
1897
|
+
except json.JSONDecodeError:
|
|
1898
|
+
package = {}
|
|
1899
|
+
for section in JS_DEP_SECTIONS:
|
|
1900
|
+
section_deps = package.get(section)
|
|
1901
|
+
if isinstance(section_deps, dict):
|
|
1902
|
+
declared["js"].update(dep.lower() for dep in section_deps)
|
|
1903
|
+
return declared
|
|
1904
|
+
|
|
1905
|
+
|
|
1906
|
+
def _declared_dependency_names(manifest: dict, packet: Path) -> set[str]:
|
|
1907
|
+
scoped = _declared_dependency_names_by_ecosystem(manifest, packet)
|
|
1908
|
+
return scoped["python"] | scoped["js"]
|
|
1909
|
+
|
|
1910
|
+
|
|
1911
|
+
def _workspace_package_names(packet: Path) -> set[str]:
|
|
1912
|
+
contents = _packet_file_contents(packet)
|
|
1913
|
+
root = {}
|
|
1914
|
+
try:
|
|
1915
|
+
root = json.loads(contents.get("package.json", "{}"))
|
|
1916
|
+
except json.JSONDecodeError:
|
|
1917
|
+
return set()
|
|
1918
|
+
workspaces = root.get("workspaces")
|
|
1919
|
+
patterns = workspaces if isinstance(workspaces, list) else workspaces.get("packages", []) if isinstance(workspaces, dict) else []
|
|
1920
|
+
names: set[str] = set()
|
|
1921
|
+
for pattern in patterns:
|
|
1922
|
+
if not isinstance(pattern, str) or not pattern.endswith("/*"):
|
|
1923
|
+
continue
|
|
1924
|
+
prefix = pattern[:-2].strip("/")
|
|
1925
|
+
for rel, content in contents.items():
|
|
1926
|
+
if Path(rel).name == "package.json" and rel.startswith(prefix + "/"):
|
|
1927
|
+
try:
|
|
1928
|
+
package = json.loads(content)
|
|
1929
|
+
except json.JSONDecodeError:
|
|
1930
|
+
continue
|
|
1931
|
+
name = package.get("name")
|
|
1932
|
+
if isinstance(name, str):
|
|
1933
|
+
names.add(name.lower())
|
|
1934
|
+
return names
|
|
1935
|
+
|
|
1936
|
+
|
|
1937
|
+
def _is_js_alias_specifier(imported: str) -> bool:
|
|
1938
|
+
return imported.startswith(("@/", "~/"))
|
|
1939
|
+
|
|
1940
|
+
|
|
1941
|
+
def _js_alias_local(imported: str, files: set[str], contents: dict[str, str]) -> bool | None:
|
|
1942
|
+
configs = []
|
|
1943
|
+
for cfg in ("tsconfig.json", "jsconfig.json"):
|
|
1944
|
+
if cfg in contents:
|
|
1945
|
+
try:
|
|
1946
|
+
configs.append(json.loads(contents[cfg]))
|
|
1947
|
+
except json.JSONDecodeError:
|
|
1948
|
+
return None
|
|
1949
|
+
for cfg in configs:
|
|
1950
|
+
opts = cfg.get("compilerOptions", {}) if isinstance(cfg, dict) else {}
|
|
1951
|
+
base = str(opts.get("baseUrl", ".")).strip("./")
|
|
1952
|
+
paths = opts.get("paths", {})
|
|
1953
|
+
candidates = []
|
|
1954
|
+
if isinstance(paths, dict):
|
|
1955
|
+
for alias, targets in paths.items():
|
|
1956
|
+
prefix = alias[:-1] if alias.endswith("*") else alias
|
|
1957
|
+
if imported.startswith(prefix):
|
|
1958
|
+
rest = imported[len(prefix):]
|
|
1959
|
+
for target in targets if isinstance(targets, list) else []:
|
|
1960
|
+
tprefix = target[:-1] if isinstance(target, str) and target.endswith("*") else target
|
|
1961
|
+
candidates.append((tprefix + rest).strip("/"))
|
|
1962
|
+
if base and not imported.startswith("@") and not imported.startswith("~"):
|
|
1963
|
+
candidates.append(f"{base}/{imported}".strip("/"))
|
|
1964
|
+
for c in candidates:
|
|
1965
|
+
variants = {c, f"{c}.ts", f"{c}.tsx", f"{c}.js", f"{c}.jsx", f"{c}/index.ts", f"{c}/index.tsx", f"{c}/index.js", f"{c}/index.jsx"}
|
|
1966
|
+
if variants & files:
|
|
1967
|
+
return True
|
|
1968
|
+
if candidates:
|
|
1969
|
+
return None
|
|
1970
|
+
return False
|
|
1971
|
+
|
|
1972
|
+
|
|
1973
|
+
def _is_high_risk_binary_path(rel: str) -> bool:
|
|
1974
|
+
normalized = rel.replace("\\", "/").lstrip("/")
|
|
1975
|
+
high_risk_prefixes = (".sourcepack/", ".git/", ".github/workflows/")
|
|
1976
|
+
high_risk_names = {"pyproject.toml", "package.json", "package-lock.json", "uv.lock", "poetry.lock"}
|
|
1977
|
+
return normalized.startswith(high_risk_prefixes) or Path(normalized).name in high_risk_names
|
|
1978
|
+
|
|
1979
|
+
|
|
1980
|
+
UNSUPPORTED_ECOSYSTEM_MARKERS = {
|
|
1981
|
+
"gemfile": ("Gemfile", "Ruby/Bundler dependency validation is not implemented"),
|
|
1982
|
+
"composer.json": ("composer.json", "PHP/Composer dependency validation is not implemented"),
|
|
1983
|
+
"main.tf": ("main.tf", "Terraform module/provider validation is not implemented"),
|
|
1984
|
+
"flake.nix": ("flake.nix", "Nix flake validation is not implemented"),
|
|
1985
|
+
"cargo.toml": ("Cargo.toml", "Rust dependency validation is not implemented"),
|
|
1986
|
+
"go.mod": ("go.mod", "Go module dependency validation is not implemented"),
|
|
1987
|
+
"pom.xml": ("pom.xml", "Maven dependency validation is not implemented"),
|
|
1988
|
+
"build.gradle": ("build.gradle", "Gradle dependency validation is not implemented"),
|
|
1989
|
+
"build.gradle.kts": ("build.gradle.kts", "Gradle dependency validation is not implemented"),
|
|
1990
|
+
"settings.gradle": ("settings.gradle", "Gradle workspace validation is not implemented"),
|
|
1991
|
+
"settings.gradle.kts": ("settings.gradle.kts", "Gradle workspace validation is not implemented"),
|
|
1992
|
+
"*.csproj": ("*.csproj", ".NET/NuGet dependency validation is not implemented"),
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
|
|
1996
|
+
def _unsupported_ecosystem_uncertainties(files: set[str], changes: list[PatchFileChange]) -> list[dict]:
|
|
1997
|
+
names = {Path(f).name.lower() for f in files}
|
|
1998
|
+
names.update(Path(ch.path).name.lower() for ch in changes)
|
|
1999
|
+
for ch in changes:
|
|
2000
|
+
if ch.path.lower().endswith(".csproj"):
|
|
2001
|
+
names.add("*.csproj")
|
|
2002
|
+
uncertainties = []
|
|
2003
|
+
for marker, (evidence, message) in sorted(UNSUPPORTED_ECOSYSTEM_MARKERS.items()):
|
|
2004
|
+
if marker in names:
|
|
2005
|
+
uncertainties.append({"id": "unsupported_ecosystem", "message": f"{evidence} detected, but {message}", "evidence": evidence})
|
|
2006
|
+
return uncertainties
|
|
2007
|
+
|
|
2008
|
+
def judge_patch_text(packet_path: str | Path, patch_text: str) -> dict:
|
|
2009
|
+
if re.search(r"(?m)^@@", patch_text) and "diff --git " not in patch_text:
|
|
2010
|
+
return {"verdict": "FAIL", "modified_files": [], "missing_modified_files": [], "new_files": [], "deleted_files": [], "unsupported_dependencies": [], "unsupported_commands": [], "protected_artifact_modifications": [], "warnings": [], "malformed_diff": True}
|
|
2011
|
+
if re.search(r"(?m)^@@(?! -\d+(?:,\d+)? \+\d+(?:,\d+)? @@)", patch_text):
|
|
2012
|
+
return {"verdict": "FAIL", "modified_files": [], "missing_modified_files": [], "new_files": [], "deleted_files": [], "unsupported_dependencies": [], "unsupported_commands": [], "protected_artifact_modifications": [], "warnings": [], "malformed_diff": True}
|
|
2013
|
+
changes = parse_unified_diff(patch_text)
|
|
2014
|
+
unsafe_paths = sorted({ch.path for ch in changes if ch.unsafe_path and ch.path})
|
|
2015
|
+
if any(ch.unsafe_path for ch in changes):
|
|
2016
|
+
return {"verdict": "FAIL", "modified_files": [], "missing_modified_files": [], "new_files": [], "deleted_files": [], "unsupported_dependencies": [], "unsupported_commands": [], "protected_artifact_modifications": [], "warnings": [], "path_escape": True, "path_escape_paths": unsafe_paths}
|
|
2017
|
+
if patch_text.strip() and not changes and "Binary files " not in patch_text:
|
|
2018
|
+
return {"verdict": "FAIL", "modified_files": [], "missing_modified_files": [], "new_files": [], "deleted_files": [], "unsupported_dependencies": [], "unsupported_commands": [], "protected_artifact_modifications": [], "warnings": [], "malformed_diff": True}
|
|
2019
|
+
report = analyze_patch(packet_path, patch_text, changes)
|
|
2020
|
+
packet = Path(packet_path); manifest = load_manifest(packet); files = known_files(manifest, packet); contents = _packet_file_contents(packet)
|
|
2021
|
+
existing_declared = _declared_dependency_names_by_ecosystem(manifest, packet)
|
|
2022
|
+
scopes = _declared_dependency_scopes_by_ecosystem(manifest, packet)
|
|
2023
|
+
patch_declared, manifest_uncertainties = _declared_dependency_names_from_patch_by_ecosystem_structural(changes, contents)
|
|
2024
|
+
if manifest_uncertainties:
|
|
2025
|
+
report.setdefault("uncertainties", []).extend(manifest_uncertainties)
|
|
2026
|
+
workspace_names = _workspace_package_names(packet)
|
|
2027
|
+
unsupported = set(report.get("unsupported_dependencies", []))
|
|
2028
|
+
for ch in changes:
|
|
2029
|
+
suffix = Path(ch.path).suffix.lower(); added = "\n".join(ch.added_lines or [])
|
|
2030
|
+
if suffix == ".py":
|
|
2031
|
+
for imported in extract_imports_from_text(added, suffix):
|
|
2032
|
+
if imported in PY_STDLIB or imported.startswith(".") or _is_local_python_import(imported, ch.path, files):
|
|
2033
|
+
continue
|
|
2034
|
+
dep_name = _dependency_name_for_import(imported)
|
|
2035
|
+
scope_status = _dependency_scope_status(dep_name, scopes["python"], ch.path)
|
|
2036
|
+
if scope_status == "scope_review":
|
|
2037
|
+
report.setdefault("uncertainties", []).append({"id": "dependency_scope_review", "message": f"{dep_name} is declared outside the runtime dependency scope", "path": ch.path, "evidence": dep_name})
|
|
2038
|
+
elif scope_status == "missing" and dep_name not in patch_declared["python"]:
|
|
2039
|
+
unsupported.add(imported)
|
|
2040
|
+
elif dep_name in patch_declared["python"]:
|
|
2041
|
+
unsupported.discard(imported)
|
|
2042
|
+
unsupported.discard(dep_name)
|
|
2043
|
+
elif suffix in JS_EXTS:
|
|
2044
|
+
for imported in extract_imports_from_text(added, suffix):
|
|
2045
|
+
if imported.startswith(".") or imported.startswith("/"):
|
|
2046
|
+
continue
|
|
2047
|
+
local_alias = _js_alias_local(imported, files, contents)
|
|
2048
|
+
pkg = _js_package_root(imported)
|
|
2049
|
+
if pkg in workspace_names or local_alias is True:
|
|
2050
|
+
continue
|
|
2051
|
+
if local_alias is None or (local_alias is False and _is_js_alias_specifier(imported)):
|
|
2052
|
+
report.setdefault("uncertainties", []).append({"id": "js_alias_uncertain", "message": f"{imported} could not be resolved safely", "path": ch.path, "evidence": imported})
|
|
2053
|
+
continue
|
|
2054
|
+
scope_status = _dependency_scope_status(pkg, scopes["js"], ch.path)
|
|
2055
|
+
if scope_status == "scope_review":
|
|
2056
|
+
report.setdefault("uncertainties", []).append({"id": "dependency_scope_review", "message": f"{pkg} is declared outside the runtime dependency scope", "path": ch.path, "evidence": pkg})
|
|
2057
|
+
elif scope_status == "missing" and pkg not in patch_declared["js"]:
|
|
2058
|
+
unsupported.add(pkg)
|
|
2059
|
+
elif pkg in patch_declared["js"]:
|
|
2060
|
+
unsupported.discard(pkg)
|
|
2061
|
+
declared = patch_declared["python"] | patch_declared["js"]
|
|
2062
|
+
existing_deps = existing_declared["python"] | existing_declared["js"]
|
|
2063
|
+
declared_only = {d for d in declared if d not in existing_deps}
|
|
2064
|
+
binary_paths = []
|
|
2065
|
+
binary_blockers = []
|
|
2066
|
+
for line in patch_text.splitlines():
|
|
2067
|
+
if line.startswith("Binary files "):
|
|
2068
|
+
m = re.search(r" b/(.+?) differ", line)
|
|
2069
|
+
rel = m.group(1) if m else "unknown"
|
|
2070
|
+
binary_paths.append(rel)
|
|
2071
|
+
if rel == "unknown" or _is_high_risk_binary_path(rel):
|
|
2072
|
+
binary_blockers.append(rel)
|
|
2073
|
+
if binary_paths:
|
|
2074
|
+
report["binary_diffs"] = sorted(set(binary_paths))
|
|
2075
|
+
if binary_blockers:
|
|
2076
|
+
report["binary_diff_blockers"] = sorted(set(binary_blockers))
|
|
2077
|
+
unsupported_ecosystems = _unsupported_ecosystem_uncertainties(files, changes)
|
|
2078
|
+
if unsupported_ecosystems:
|
|
2079
|
+
seen_uncertainties = set()
|
|
2080
|
+
merged_uncertainties = []
|
|
2081
|
+
for uncertainty in report.get("uncertainties", []) + unsupported_ecosystems:
|
|
2082
|
+
if isinstance(uncertainty, dict):
|
|
2083
|
+
key = (uncertainty.get("id"), uncertainty.get("message"), uncertainty.get("evidence"), uncertainty.get("path"))
|
|
2084
|
+
else:
|
|
2085
|
+
key = (str(uncertainty),)
|
|
2086
|
+
if key not in seen_uncertainties:
|
|
2087
|
+
seen_uncertainties.add(key)
|
|
2088
|
+
merged_uncertainties.append(uncertainty)
|
|
2089
|
+
report["uncertainties"] = merged_uncertainties
|
|
2090
|
+
report["unsupported_dependencies"] = sorted(unsupported)
|
|
2091
|
+
if declared_only:
|
|
2092
|
+
report.setdefault("warnings", []).append("Patch declares new dependencies that require review.")
|
|
2093
|
+
report["declared_dependencies"] = sorted(declared_only)
|
|
2094
|
+
fail_keys = ["missing_modified_files", "unsupported_dependencies", "unsupported_commands", "protected_artifact_modifications", "git_path_modifications", "binary_diff_blockers", "path_escape"]
|
|
2095
|
+
report["verdict"] = "FAIL" if any(report.get(k) for k in fail_keys) else "WARN" if (report.get("new_files") or report.get("deleted_files") or report.get("warnings") or declared_only or report.get("uncertainties") or report.get("binary_diffs")) else "PASS"
|
|
2096
|
+
return report
|
|
2097
|
+
|
|
2098
|
+
|
|
2099
|
+
def patch_report_to_traffic(report: dict, report_path: str = ".sourcepack/reports/latest.json") -> dict:
|
|
2100
|
+
findings=[]
|
|
2101
|
+
for p in report.get("missing_modified_files", []): findings.append(normalized_finding("missing_file", "error", "file", f"{p} not found in the trusted baseline.", p, suggestion="Restore the file, create it as a new file, or refresh the baseline only after accepting the current repo state."))
|
|
2102
|
+
for d in report.get("unsupported_dependencies", []): findings.append(normalized_finding("unsupported_dependency", "error", "dependency", f"{d} is imported but not declared in scanned dependency files.", evidence=d, suggestion=f"Either remove {d} usage or add it intentionally to the appropriate dependency manifest."))
|
|
2103
|
+
for c in report.get("unsupported_commands", []): findings.append(normalized_finding("unsupported_command", "error", "command", f"{c} is not supported by project evidence.", evidence=c, suggestion="Use a detected supported command or add the project file that defines this command."))
|
|
2104
|
+
if report.get("malformed_diff"):
|
|
2105
|
+
findings.append(normalized_finding("malformed_diff", "error", "diff", "SourcePack could not safely parse the diff artifact it was asked to judge."))
|
|
2106
|
+
if report.get("path_escape"):
|
|
2107
|
+
paths = report.get("path_escape_paths") or []
|
|
2108
|
+
if paths:
|
|
2109
|
+
for p in paths:
|
|
2110
|
+
findings.append(normalized_finding("path_escape", "error", "diff", "Diff path escapes the repository root or is absolute.", p, evidence=p))
|
|
2111
|
+
else:
|
|
2112
|
+
findings.append(normalized_finding("path_escape", "error", "diff", "Diff path escapes the repository root or is absolute."))
|
|
2113
|
+
for p in report.get("protected_artifact_modifications", []): findings.append(normalized_finding("protected_artifact", "error", "artifact", f"{p} is a protected SourcePack trust artifact.", p, evidence=p))
|
|
2114
|
+
for p in report.get("git_path_modifications", []): findings.append(normalized_finding("git_path_modification", "error", "artifact", f"{p} modifies Git internal state and is not safe to judge as a normal repository file.", p, evidence=p))
|
|
2115
|
+
for p in report.get("binary_diff_blockers", []): findings.append(normalized_finding("binary_diff", "error", "diff", f"Binary change at {p} crosses a SourcePack trust or high-risk control boundary.", p, evidence=p))
|
|
2116
|
+
for p in report.get("binary_diffs", []):
|
|
2117
|
+
if p not in set(report.get("binary_diff_blockers", [])):
|
|
2118
|
+
findings.append(normalized_finding("binary_diff", "warn", "uncertainty", f"Binary content was detected at {p} and was not semantically evaluated.", p, evidence=p))
|
|
2119
|
+
for p in report.get("new_files", []): findings.append(normalized_finding("new_file", "warn", "review", f"{p} was created by the patch.", p))
|
|
2120
|
+
for p in report.get("deleted_files", []): findings.append(normalized_finding("deleted_file", "warn", "review", f"{p} was deleted by the patch.", p))
|
|
2121
|
+
for d in report.get("declared_dependencies", []): findings.append(normalized_finding("declared_dependency", "warn", "review", f"{d} was added to dependency files.", evidence=d))
|
|
2122
|
+
for c in report.get("declared_commands", []): findings.append(normalized_finding("declared_command", "warn", "review", f"{c} was added in the same patch.", evidence=c))
|
|
2123
|
+
for w in report.get("uncertainties", []):
|
|
2124
|
+
if isinstance(w, dict):
|
|
2125
|
+
fid = str(w.get("id") or "uncertainty")
|
|
2126
|
+
message = str(w.get("message") or "SourcePack could not fully evaluate this change.")
|
|
2127
|
+
findings.append(normalized_finding(fid, "warn", "uncertainty", message, w.get("path"), w.get("evidence"), w.get("suggestion")))
|
|
2128
|
+
else:
|
|
2129
|
+
fid, _, detail = str(w).partition(":")
|
|
2130
|
+
fid = fid.strip() or "uncertainty"
|
|
2131
|
+
message = detail.strip() or str(w)
|
|
2132
|
+
findings.append(normalized_finding(fid, "warn", "uncertainty", message))
|
|
2133
|
+
return traffic_report(report.get("verdict", "PASS"), findings=findings, checked_categories=["file references", "Python imports", "JS/TS imports", "known project commands", "protected SourcePack artifacts"], report_path=report_path)
|
|
2134
|
+
|
|
2135
|
+
|
|
2136
|
+
def run_git(repo: Path, args: list[str]) -> subprocess.CompletedProcess:
|
|
2137
|
+
try:
|
|
2138
|
+
return subprocess.run(["git", *args], cwd=repo, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
2139
|
+
except FileNotFoundError:
|
|
2140
|
+
return subprocess.CompletedProcess(["git", *args], 127, "", "git executable not found")
|
|
2141
|
+
|
|
2142
|
+
|
|
2143
|
+
|
|
2144
|
+
def git_worktree_dirty(repo: str | Path) -> tuple[bool, str | None]:
|
|
2145
|
+
repo = Path(repo)
|
|
2146
|
+
cp = run_git(repo, ["rev-parse", "--show-toplevel"])
|
|
2147
|
+
if cp.returncode != 0:
|
|
2148
|
+
return False, "git_unavailable" if cp.returncode == 127 else "not_git"
|
|
2149
|
+
root = Path(cp.stdout.strip())
|
|
2150
|
+
for args in (["diff", "--quiet"], ["diff", "--staged", "--quiet"]):
|
|
2151
|
+
diff_cp = run_git(root, list(args))
|
|
2152
|
+
if diff_cp.returncode == 1:
|
|
2153
|
+
return True, None
|
|
2154
|
+
if diff_cp.returncode == 127:
|
|
2155
|
+
return False, "git_unavailable"
|
|
2156
|
+
untracked = run_git(root, ["ls-files", "--others", "--exclude-standard"])
|
|
2157
|
+
if untracked.returncode == 0 and untracked.stdout.strip():
|
|
2158
|
+
return True, None
|
|
2159
|
+
if untracked.returncode == 127:
|
|
2160
|
+
return False, "git_unavailable"
|
|
2161
|
+
return False, None
|
|
2162
|
+
|
|
2163
|
+
|
|
2164
|
+
|
|
2165
|
+
def _only_sourcepack_gitignore_change(repo: Path) -> bool:
|
|
2166
|
+
status = run_git(repo, ["status", "--porcelain", "--", ".gitignore"])
|
|
2167
|
+
others = run_git(repo, ["status", "--porcelain"])
|
|
2168
|
+
if status.returncode != 0 or others.returncode != 0:
|
|
2169
|
+
return False
|
|
2170
|
+
lines = [line for line in others.stdout.splitlines() if line.strip()]
|
|
2171
|
+
if not lines or any(not line.endswith(".gitignore") for line in lines):
|
|
2172
|
+
return False
|
|
2173
|
+
try:
|
|
2174
|
+
text = (repo / ".gitignore").read_text(encoding="utf-8")
|
|
2175
|
+
except OSError:
|
|
2176
|
+
return False
|
|
2177
|
+
tracked = run_git(repo, ["show", "HEAD:.gitignore"])
|
|
2178
|
+
before = tracked.stdout if tracked.returncode == 0 else ""
|
|
2179
|
+
added = [line.strip() for line in text.splitlines() if line.strip() and line.strip() not in {l.strip() for l in before.splitlines()}]
|
|
2180
|
+
return bool(added) and set(added) <= {".sourcepack", ".sourcepack/"}
|
|
2181
|
+
|
|
2182
|
+
def baseline_report_fields(status: dict) -> dict:
|
|
2183
|
+
return {
|
|
2184
|
+
"baseline_state": status.get("state"),
|
|
2185
|
+
"baseline_integrity_ok": bool(status.get("ok")) and status.get("state") in {"present", "stale"},
|
|
2186
|
+
"baseline_integrity_finding_id": status.get("finding_id"),
|
|
2187
|
+
"baseline_integrity_message": status.get("message"),
|
|
2188
|
+
"baseline_stale": status.get("state") == "stale",
|
|
2189
|
+
"baseline_stale_details": (status.get("details") or {}).get("stale_details"),
|
|
2190
|
+
"baseline_mode": status.get("mode"),
|
|
2191
|
+
"baseline_packet_path": status.get("packet_path"),
|
|
2192
|
+
"baseline_metadata_path": status.get("metadata_path"),
|
|
2193
|
+
"baseline_active_pointer_path": status.get("active_pointer_path"),
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
def cli_prompt(args) -> int:
|
|
2197
|
+
repo = Path(args.repo).resolve()
|
|
2198
|
+
if not repo.is_dir():
|
|
2199
|
+
rep = traffic_report("FAIL", "stop before trusting this output.", [normalized_finding("repo_not_directory", "error", "git", f"Repo path is not a directory: {args.repo}")])
|
|
2200
|
+
print(json.dumps(rep, indent=2) if args.json else render_traffic(rep, args.verbose), end=""); return 1
|
|
2201
|
+
paths = ensure_sourcepack_dirs(repo); added, err = ensure_gitignore_entry(repo)
|
|
2202
|
+
if err:
|
|
2203
|
+
rep = traffic_report("FAIL", "stop before trusting this output.", [normalized_finding("gitignore_unwritable", "error", "git", f"Cannot write .gitignore: {err}")])
|
|
2204
|
+
print(json.dumps(rep, indent=2) if args.json else render_traffic(rep, args.verbose), end=""); return 1
|
|
2205
|
+
try:
|
|
2206
|
+
build_prompt_context(repo)
|
|
2207
|
+
except Exception as exc:
|
|
2208
|
+
rep = traffic_report("FAIL", "could not generate prompt context.", [normalized_finding("prompt_context_failed", "error", "prompt", f"Prompt context generation failed: {exc}")])
|
|
2209
|
+
print(json.dumps(rep, indent=2) if args.json else render_traffic(rep, args.verbose), end=""); return 1
|
|
2210
|
+
task = args.task or "Explain how this project works and summarize its structure."
|
|
2211
|
+
reality = json.loads(paths["prompt_reality"].read_text(encoding="utf-8")); instructions = paths["prompt_instructions"].read_text(encoding="utf-8")
|
|
2212
|
+
prompt = render_prompt(task, instructions, reality); paths["prompt"].write_text(prompt, encoding="utf-8")
|
|
2213
|
+
copied = copy_to_clipboard(prompt) if args.copy else False
|
|
2214
|
+
dirty, dirty_state = git_worktree_dirty(repo)
|
|
2215
|
+
findings = []
|
|
2216
|
+
if args.copy and not copied:
|
|
2217
|
+
findings.append(normalized_finding("clipboard_unavailable", "warn", "clipboard", "clipboard unavailable."))
|
|
2218
|
+
if dirty:
|
|
2219
|
+
findings.append(normalized_finding("dirty_worktree", "warn", "prompt", "prompt context includes uncommitted working tree changes."))
|
|
2220
|
+
verdict = "WARN" if findings else "PASS"
|
|
2221
|
+
headline = "verified prompt copied to clipboard." if args.copy and copied else "clipboard unavailable." if args.copy and not copied else "verified prompt context saved."
|
|
2222
|
+
rep = traffic_report(verdict, headline, findings, ["prompt context", "file references", "known project commands"], "continue with the saved prompt; enforcement baseline was not changed.")
|
|
2223
|
+
write_user_report(repo, rep, "prompt")
|
|
2224
|
+
if args.json: print(json.dumps({**rep, "prompt_path": ".sourcepack/prompt/prompt.md", "clipboard_copied": copied}, indent=2)); return 0
|
|
2225
|
+
if added: print("Added .sourcepack/ to .gitignore.")
|
|
2226
|
+
print(f"{rep['light']}: {headline}\n\nPrompt saved: .sourcepack/prompt/prompt.md")
|
|
2227
|
+
return 0
|
|
2228
|
+
|
|
2229
|
+
|
|
2230
|
+
def cli_baseline(args) -> int:
|
|
2231
|
+
repo = Path(args.repo).resolve(); dirty, dirty_state = git_worktree_dirty(repo); paths = ensure_sourcepack_dirs(repo); added, err = ensure_gitignore_entry(repo)
|
|
2232
|
+
if err:
|
|
2233
|
+
rep=traffic_report("FAIL","could not create baseline.",[normalized_finding("gitignore_unwritable","error","git",f"Cannot write .gitignore: {err}")]); print(json.dumps(rep, indent=2) if args.json else render_traffic(rep,args.verbose), end=""); return 1
|
|
2234
|
+
existed = validate_baseline(repo).get("state") in {"present", "stale", "corrupt"}
|
|
2235
|
+
try:
|
|
2236
|
+
build_current_baseline(repo, quiet=getattr(args, "quiet", False)); refreshed = existed or args.refresh
|
|
2237
|
+
if dirty:
|
|
2238
|
+
headline = "baseline refreshed while uncommitted changes are present." if refreshed else "baseline created while uncommitted changes are present."
|
|
2239
|
+
rep=traffic_report("WARN", headline, [normalized_finding("dirty_worktree", "warn", "baseline", "baseline now includes current uncommitted changes.")], ["baseline","verify"], "Commit or discard unintended changes before relying on this baseline.")
|
|
2240
|
+
else:
|
|
2241
|
+
headline = "baseline refreshed." if refreshed else "baseline created."
|
|
2242
|
+
rep=traffic_report("PASS", headline, checked_categories=["baseline","verify"])
|
|
2243
|
+
write_user_report(repo, rep, "baseline")
|
|
2244
|
+
if args.json: print(json.dumps(rep, indent=2)); return 0
|
|
2245
|
+
if getattr(args, "quiet", False): return 0
|
|
2246
|
+
if added: print("Added .sourcepack/ to .gitignore.")
|
|
2247
|
+
print(render_traffic(rep,args.verbose), end="")
|
|
2248
|
+
return 0
|
|
2249
|
+
except BaselineLockError as exc:
|
|
2250
|
+
rep=traffic_report("WARN","baseline writer is locked.",[normalized_finding("baseline_locked","warn","tooling",str(exc))], ["baseline"], "try again after the other baseline operation finishes.", reason_type="tooling"); write_user_report(repo, rep, "baseline")
|
|
2251
|
+
print(json.dumps(rep, indent=2) if args.json else render_traffic(rep,args.verbose), end=""); return 1
|
|
2252
|
+
except Exception as exc:
|
|
2253
|
+
rep=traffic_report("FAIL","could not create baseline.",[normalized_finding("baseline_failed","error","baseline",f"Baseline verification failed: {exc}")]); write_user_report(repo, rep, "baseline")
|
|
2254
|
+
print(json.dumps(rep, indent=2) if args.json else render_traffic(rep,args.verbose), end=""); return 1
|
|
2255
|
+
|
|
2256
|
+
|
|
2257
|
+
def untracked_files_as_diff(repo: str | Path) -> str:
|
|
2258
|
+
repo = Path(repo)
|
|
2259
|
+
cp = run_git(repo, ["ls-files", "--others", "--exclude-standard"])
|
|
2260
|
+
if cp.returncode != 0:
|
|
2261
|
+
return ""
|
|
2262
|
+
chunks = []
|
|
2263
|
+
for rel in [line.strip() for line in cp.stdout.splitlines() if line.strip()]:
|
|
2264
|
+
path = repo / rel
|
|
2265
|
+
if rel == ".gitignore":
|
|
2266
|
+
try:
|
|
2267
|
+
ignore_lines = {line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()}
|
|
2268
|
+
except OSError:
|
|
2269
|
+
ignore_lines = set()
|
|
2270
|
+
if ignore_lines <= {".sourcepack", ".sourcepack/"}:
|
|
2271
|
+
continue
|
|
2272
|
+
safe_rel = rel.replace("\\", "/")
|
|
2273
|
+
chunks.extend([f"diff --git a/{safe_rel} b/{safe_rel}", "new file mode 100644", "--- /dev/null", f"+++ b/{safe_rel}"])
|
|
2274
|
+
if is_probably_binary(path):
|
|
2275
|
+
chunks.append(f"Binary files /dev/null and b/{safe_rel} differ")
|
|
2276
|
+
continue
|
|
2277
|
+
try:
|
|
2278
|
+
text = path.read_text(encoding="utf-8")
|
|
2279
|
+
except UnicodeDecodeError:
|
|
2280
|
+
chunks.append(f"Binary files /dev/null and b/{safe_rel} differ")
|
|
2281
|
+
continue
|
|
2282
|
+
except OSError:
|
|
2283
|
+
continue
|
|
2284
|
+
lines = text.splitlines()
|
|
2285
|
+
chunks.append(f"@@ -0,0 +1,{len(lines)} @@")
|
|
2286
|
+
chunks.extend(f"+{line}" for line in lines)
|
|
2287
|
+
return "\n".join(chunks) + ("\n" if chunks else "")
|
|
2288
|
+
|
|
2289
|
+
def build_repo_change_report(repo_path: str | Path, *, staged: bool = False, patch_text: str | None = None, ci: bool = False) -> dict:
|
|
2290
|
+
repo_arg = Path(repo_path).resolve(); cp = run_git(repo_arg, ["rev-parse", "--show-toplevel"])
|
|
2291
|
+
if cp.returncode != 0:
|
|
2292
|
+
message = "Git executable not found." if cp.returncode == 127 else "No git repository found. Run sourcepack prompt or sourcepack baseline for non-git use."
|
|
2293
|
+
return traffic_report("FAIL", "stop before trusting this output.", [normalized_finding("git_unavailable" if cp.returncode == 127 else "no_git_repo", "error", "git", message)])
|
|
2294
|
+
git_root = Path(cp.stdout.strip()).resolve()
|
|
2295
|
+
repo = repo_arg if validate_baseline(repo_arg).get("state") in {"present", "stale", "corrupt"} else git_root
|
|
2296
|
+
paths = ensure_sourcepack_dirs(repo); added, err = ensure_gitignore_entry(repo)
|
|
2297
|
+
if added:
|
|
2298
|
+
paths.setdefault("gitignore_added", True)
|
|
2299
|
+
if err:
|
|
2300
|
+
return traffic_report("FAIL", "stop before trusting this output.", [normalized_finding("gitignore_unwritable", "error", "git", f"Cannot write .gitignore: {err}")])
|
|
2301
|
+
if patch_text is None:
|
|
2302
|
+
diff_args = ["diff", "--staged"] if staged else ["diff"]
|
|
2303
|
+
if repo != git_root:
|
|
2304
|
+
diff_args.append("--relative")
|
|
2305
|
+
cp = run_git(repo, diff_args); diff_text = cp.stdout
|
|
2306
|
+
if cp.returncode == 127:
|
|
2307
|
+
return traffic_report("FAIL", "stop before trusting this output.", [normalized_finding("git_unavailable", "error", "git", "Git executable not found.")])
|
|
2308
|
+
if not staged:
|
|
2309
|
+
extra = untracked_files_as_diff(repo)
|
|
2310
|
+
if extra and not (added and _only_sourcepack_gitignore_change(repo)):
|
|
2311
|
+
diff_text = (diff_text + "\n" + extra).strip() + "\n"
|
|
2312
|
+
else:
|
|
2313
|
+
diff_text = patch_text
|
|
2314
|
+
baseline_status = validate_baseline(repo)
|
|
2315
|
+
if baseline_status["state"] == "corrupt":
|
|
2316
|
+
rep = traffic_report("FAIL", "trusted baseline is corrupt.", [normalized_finding("baseline_corrupt", "error", "baseline", baseline_status["message"])], ["baseline", "diff"], "Recreate the baseline only after verifying the current repo state should be trusted.")
|
|
2317
|
+
rep.update(baseline_report_fields(baseline_status)); return rep
|
|
2318
|
+
if baseline_status["state"] == "missing":
|
|
2319
|
+
dirty_now, dirty_state_now = git_worktree_dirty(repo)
|
|
2320
|
+
if ci:
|
|
2321
|
+
rep = traffic_report("FAIL", "trusted baseline is missing in CI.", [normalized_finding("baseline_missing", "error", "baseline", "No trusted SourcePack baseline exists; CI must not establish trust.")], ["baseline", "diff"], "create the baseline locally only after deciding the current repo state should be trusted.")
|
|
2322
|
+
rep.update(baseline_report_fields(baseline_status)); return rep
|
|
2323
|
+
if diff_text.strip() or (dirty_now and not _only_sourcepack_gitignore_change(repo)):
|
|
2324
|
+
rep = traffic_report("FAIL", "baseline missing while changes are present.", [normalized_finding("baseline_missing", "error", "baseline", "No trusted SourcePack baseline exists while changes are present.")], ["baseline", "diff"], "run sourcepack baseline only after deciding the current repo state should be trusted.")
|
|
2325
|
+
rep.update(baseline_report_fields(baseline_status)); return rep
|
|
2326
|
+
try:
|
|
2327
|
+
build_current_baseline(repo, quiet=True); baseline_status = validate_baseline(repo)
|
|
2328
|
+
rep_note = "Created SourcePack baseline because none existed and no diff was present."
|
|
2329
|
+
except BaselineLockError as exc:
|
|
2330
|
+
return traffic_report("WARN", "baseline writer is locked.", [normalized_finding("baseline_locked", "warn", "tooling", str(exc))], ["baseline", "diff"], "try again after the other baseline operation finishes.", reason_type="tooling")
|
|
2331
|
+
except Exception as exc:
|
|
2332
|
+
return traffic_report("FAIL", "stop before trusting this output.", [normalized_finding("baseline_failed", "error", "baseline", f"Baseline verification failed: {exc}")])
|
|
2333
|
+
else:
|
|
2334
|
+
rep_note = None
|
|
2335
|
+
stale_findings = []
|
|
2336
|
+
if baseline_status["state"] == "stale":
|
|
2337
|
+
stale_findings.append(normalized_finding("baseline_stale", "warn", "uncertainty", "Trusted SourcePack baseline may not match current repo state."))
|
|
2338
|
+
if not diff_text.strip():
|
|
2339
|
+
verdict = "WARN" if stale_findings else "PASS"
|
|
2340
|
+
rep = traffic_report(verdict, "SourcePack could not fully evaluate this change." if stale_findings else "good to continue.", [normalized_finding("no_diff", "info", "diff", "No uncommitted changes detected."), *stale_findings], ["diff", "baseline freshness"])
|
|
2341
|
+
else:
|
|
2342
|
+
raw = judge_patch_text(repo / baseline_status["packet_path"], diff_text); rep = patch_report_to_traffic(raw); rep["raw_patch_judgment"] = raw
|
|
2343
|
+
if stale_findings and rep["verdict"] != "FAIL":
|
|
2344
|
+
rep = traffic_report("WARN", "SourcePack could not fully evaluate this change.", rep.get("findings", []) + stale_findings, rep.get("checked_categories", []), rep.get("next_action"), reason_type="uncertainty"); rep["raw_patch_judgment"] = raw
|
|
2345
|
+
elif stale_findings:
|
|
2346
|
+
rep = traffic_report("FAIL", rep.get("headline"), rep.get("findings", []) + stale_findings, rep.get("checked_categories", []), rep.get("next_action")); rep["raw_patch_judgment"] = raw
|
|
2347
|
+
rep.update(baseline_report_fields(baseline_status))
|
|
2348
|
+
if baseline_status.get("metadata_path"):
|
|
2349
|
+
try:
|
|
2350
|
+
rep["baseline"] = json.loads((repo / baseline_status["metadata_path"]).read_text(encoding="utf-8"))
|
|
2351
|
+
except Exception:
|
|
2352
|
+
pass
|
|
2353
|
+
rep["current_git"] = git_metadata(repo)
|
|
2354
|
+
if rep_note:
|
|
2355
|
+
rep["note"] = rep_note
|
|
2356
|
+
rep["repo_path"] = str(repo)
|
|
2357
|
+
return rep
|
|
2358
|
+
|
|
2359
|
+
|
|
2360
|
+
def cli_diff(args) -> int:
|
|
2361
|
+
from .judgment import judge_repo_change
|
|
2362
|
+
from .policy import PolicyMode
|
|
2363
|
+
if getattr(args, "ci", False):
|
|
2364
|
+
args.json = True
|
|
2365
|
+
mode = PolicyMode.CI if getattr(args, "ci", False) else PolicyMode.STRICT if getattr(args, "strict", False) else PolicyMode.LOCAL
|
|
2366
|
+
judgment = judge_repo_change(args.repo, staged=args.staged, policy_mode=mode)
|
|
2367
|
+
report = finalize_diff_report(Path(judgment.report.get("repo_path", args.repo)), judgment.report, args)
|
|
2368
|
+
return emit_diff_report(report, args, note=report.get("note"))
|
|
2369
|
+
|
|
2370
|
+
def hook_text(strict: bool) -> str:
|
|
2371
|
+
strict_block = """
|
|
2372
|
+
if grep -q 'YELLOW LIGHT' .git/SOURCEPACK_LAST_DIFF 2>/dev/null; then
|
|
2373
|
+
echo 'SourcePack strict mode blocks YELLOW LIGHT.'
|
|
2374
|
+
echo 'To bypass manually: git commit --no-verify'
|
|
2375
|
+
exit 1
|
|
2376
|
+
fi""" if strict else ""
|
|
2377
|
+
return """#!/bin/sh
|
|
2378
|
+
# === SOURCEPACK BEGIN ===
|
|
2379
|
+
# SourcePack hook version: 1
|
|
2380
|
+
repo_root="$(git rev-parse --show-toplevel 2>/dev/null)"
|
|
2381
|
+
if [ -z "$repo_root" ]; then
|
|
2382
|
+
echo 'RED LIGHT: SourcePack could not locate git repository root.'
|
|
2383
|
+
echo 'To bypass manually: git commit --no-verify'
|
|
2384
|
+
exit 1
|
|
2385
|
+
fi
|
|
2386
|
+
cd "$repo_root" || exit 1
|
|
2387
|
+
sourcepack diff . --staged > .git/SOURCEPACK_LAST_DIFF
|
|
2388
|
+
sp_status=$?
|
|
2389
|
+
cat .git/SOURCEPACK_LAST_DIFF
|
|
2390
|
+
if [ $sp_status -ne 0 ]; then
|
|
2391
|
+
echo 'To bypass manually: git commit --no-verify'
|
|
2392
|
+
exit $sp_status
|
|
2393
|
+
fi""" + strict_block + """
|
|
2394
|
+
# === SOURCEPACK END ===
|
|
2395
|
+
"""
|
|
2396
|
+
|
|
2397
|
+
|
|
2398
|
+
|
|
2399
|
+
def post_commit_hook_text() -> str:
|
|
2400
|
+
return """#!/bin/sh
|
|
2401
|
+
# === SOURCEPACK POST-COMMIT BEGIN ===
|
|
2402
|
+
# SourcePack hook version: 1
|
|
2403
|
+
repo_root="$(git rev-parse --show-toplevel 2>/dev/null)"
|
|
2404
|
+
if [ -z "$repo_root" ]; then
|
|
2405
|
+
exit 0
|
|
2406
|
+
fi
|
|
2407
|
+
cd "$repo_root" || exit 0
|
|
2408
|
+
if git diff --quiet && git diff --staged --quiet && [ -z "$(git ls-files --others --exclude-standard)" ]; then
|
|
2409
|
+
sourcepack baseline . --refresh --quiet >/dev/null 2>&1 || echo 'YELLOW LIGHT: SourcePack post-commit baseline refresh failed.'
|
|
2410
|
+
else
|
|
2411
|
+
mkdir -p .sourcepack/state
|
|
2412
|
+
current_head="$(git rev-parse HEAD 2>/dev/null)"
|
|
2413
|
+
cat > .sourcepack/state/baseline_stale.json <<EOF
|
|
2414
|
+
{"reason": "post_commit_dirty_worktree", "detected_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)", "current_head": "$current_head", "dirty_worktree": true}
|
|
2415
|
+
EOF
|
|
2416
|
+
echo 'YELLOW LIGHT: SourcePack baseline is stale because uncommitted changes remain after commit.'
|
|
2417
|
+
fi
|
|
2418
|
+
# === SOURCEPACK POST-COMMIT END ===
|
|
2419
|
+
"""
|
|
2420
|
+
|
|
2421
|
+
|
|
2422
|
+
def install_post_commit_hook(repo: Path) -> bool:
|
|
2423
|
+
cp = run_git(repo, ["rev-parse", "--show-toplevel"])
|
|
2424
|
+
if cp.returncode != 0:
|
|
2425
|
+
return False
|
|
2426
|
+
root = Path(cp.stdout.strip())
|
|
2427
|
+
hooks = root / ".git" / "hooks"
|
|
2428
|
+
post = hooks / "post-commit"
|
|
2429
|
+
hooks.mkdir(parents=True, exist_ok=True)
|
|
2430
|
+
text = post.read_text(encoding="utf-8", errors="ignore") if post.exists() else ""
|
|
2431
|
+
block = post_commit_hook_text()
|
|
2432
|
+
if "# === SOURCEPACK POST-COMMIT BEGIN ===" in text:
|
|
2433
|
+
text = re.sub(r"#!/bin/sh\n?# === SOURCEPACK POST-COMMIT BEGIN ===.*?# === SOURCEPACK POST-COMMIT END ===\n?", block, text, flags=re.S)
|
|
2434
|
+
elif text.strip():
|
|
2435
|
+
text = text.rstrip() + "\n" + block
|
|
2436
|
+
else:
|
|
2437
|
+
text = block
|
|
2438
|
+
post.write_text(text, encoding="utf-8")
|
|
2439
|
+
post.chmod(0o755)
|
|
2440
|
+
return True
|
|
2441
|
+
|
|
2442
|
+
def hook_chain_text(strict: bool) -> str:
|
|
2443
|
+
return hook_text(strict) + """
|
|
2444
|
+
orig="$(git rev-parse --git-path hooks/pre-commit.sourcepack.orig 2>/dev/null)"
|
|
2445
|
+
if [ -n "$orig" ] && [ -x "$orig" ]; then
|
|
2446
|
+
"$orig" "$@"
|
|
2447
|
+
exit $?
|
|
2448
|
+
fi
|
|
2449
|
+
exit 0
|
|
2450
|
+
"""
|
|
2451
|
+
|
|
2452
|
+
|
|
2453
|
+
def hook_is_sourcepack(text: str) -> bool:
|
|
2454
|
+
return "# === SOURCEPACK BEGIN ===" in text and "# === SOURCEPACK END ===" in text
|
|
2455
|
+
|
|
2456
|
+
|
|
2457
|
+
def cli_install_hook(args) -> int:
|
|
2458
|
+
repo=Path(args.repo).resolve(); cp=run_git(repo,["rev-parse","--show-toplevel"])
|
|
2459
|
+
if cp.returncode!=0:
|
|
2460
|
+
message = "Git executable not found." if cp.returncode == 127 else "No git repository found."
|
|
2461
|
+
print(f"RED LIGHT: SourcePack pre-commit hook install failed.\n\n{message}"); return 1
|
|
2462
|
+
root=Path(cp.stdout.strip()); hooks=root/".git"/"hooks"; pre=hooks/"pre-commit"; post=hooks/"post-commit"; orig=hooks/"pre-commit.sourcepack.orig"
|
|
2463
|
+
try:
|
|
2464
|
+
hooks.mkdir(parents=True, exist_ok=True)
|
|
2465
|
+
if pre.exists():
|
|
2466
|
+
text=pre.read_text(encoding="utf-8", errors="ignore")
|
|
2467
|
+
if hook_is_sourcepack(text):
|
|
2468
|
+
pre.write_text(hook_chain_text(args.strict) if orig.exists() else hook_text(args.strict) + "\nexit 0\n", encoding="utf-8")
|
|
2469
|
+
else:
|
|
2470
|
+
if not orig.exists(): shutil.copy2(pre, orig)
|
|
2471
|
+
pre.write_text(hook_chain_text(args.strict), encoding="utf-8")
|
|
2472
|
+
else:
|
|
2473
|
+
pre.write_text(hook_text(args.strict) + "\nexit 0\n", encoding="utf-8")
|
|
2474
|
+
pre.chmod(0o755); install_post_commit_hook(root); print("GREEN LIGHT: SourcePack pre-commit and post-commit hooks installed."); return 0
|
|
2475
|
+
except Exception as exc:
|
|
2476
|
+
print(f"RED LIGHT: SourcePack pre-commit hook install failed.\n\n{exc}"); return 1
|
|
2477
|
+
|
|
2478
|
+
def cli_uninstall_hook(args) -> int:
|
|
2479
|
+
repo=Path(args.repo).resolve(); cp=run_git(repo,["rev-parse","--show-toplevel"])
|
|
2480
|
+
if cp.returncode!=0:
|
|
2481
|
+
message = "Git executable not found." if cp.returncode == 127 else "No git repository found."
|
|
2482
|
+
print(f"RED LIGHT: SourcePack pre-commit hook uninstall failed.\n\n{message}"); return 1
|
|
2483
|
+
root=Path(cp.stdout.strip()); hooks=root/".git"/"hooks"; pre=hooks/"pre-commit"; post=hooks/"post-commit"; orig=hooks/"pre-commit.sourcepack.orig"
|
|
2484
|
+
try:
|
|
2485
|
+
restored_original = False
|
|
2486
|
+
if orig.exists():
|
|
2487
|
+
shutil.move(str(orig), str(pre)); pre.chmod(0o755); restored_original = True
|
|
2488
|
+
elif pre.exists():
|
|
2489
|
+
text=pre.read_text(encoding="utf-8", errors="ignore")
|
|
2490
|
+
if not hook_is_sourcepack(text):
|
|
2491
|
+
print("RED LIGHT: Cannot safely uninstall SourcePack hook: SourcePack block not found."); return 1
|
|
2492
|
+
pre.write_text(re.sub(r"# === SOURCEPACK BEGIN ===.*?# === SOURCEPACK END ===\n?", "", text, flags=re.S), encoding="utf-8")
|
|
2493
|
+
if post.exists():
|
|
2494
|
+
post_text=post.read_text(encoding="utf-8", errors="ignore")
|
|
2495
|
+
if "# === SOURCEPACK POST-COMMIT BEGIN ===" in post_text:
|
|
2496
|
+
post.write_text(re.sub(r"#!/bin/sh\n?# === SOURCEPACK POST-COMMIT BEGIN ===.*?# === SOURCEPACK POST-COMMIT END ===\n?", "", post_text, flags=re.S), encoding="utf-8")
|
|
2497
|
+
print("GREEN LIGHT: SourcePack hooks uninstalled." if not restored_original else "GREEN LIGHT: SourcePack hooks uninstalled and original pre-commit hook restored."); return 0
|
|
2498
|
+
except Exception as exc:
|
|
2499
|
+
print(f"RED LIGHT: SourcePack pre-commit hook uninstall failed.\n\n{exc}"); return 1
|
|
2500
|
+
|
|
2501
|
+
def cli_status(args) -> int:
|
|
2502
|
+
repo=Path(args.repo).resolve(); paths=ensure_sourcepack_dirs(repo)
|
|
2503
|
+
current=paths["base"].exists(); baseline_status=validate_baseline(repo); baseline=baseline_status["state"] in {"present", "stale"}; last=None
|
|
2504
|
+
if baseline_status.get("packet_path"):
|
|
2505
|
+
receipt=repo / baseline_status["packet_path"] / "receipt.json"
|
|
2506
|
+
if receipt.exists():
|
|
2507
|
+
try: last=json.loads(receipt.read_text()).get("generated_at")
|
|
2508
|
+
except Exception: last=None
|
|
2509
|
+
cp=run_git(repo,["rev-parse","--show-toplevel"]); git_repo=cp.returncode==0; root=Path(cp.stdout.strip()) if git_repo else repo
|
|
2510
|
+
pre=root/".git"/"hooks"/"pre-commit"; post=root/".git"/"hooks"/"post-commit"; hook_installed=False; post_hook_installed=False; strict=False
|
|
2511
|
+
if pre.exists():
|
|
2512
|
+
text=pre.read_text(encoding="utf-8", errors="ignore"); hook_installed=hook_is_sourcepack(text); strict="strict mode blocks YELLOW LIGHT" in text
|
|
2513
|
+
if post.exists():
|
|
2514
|
+
post_hook_installed="# === SOURCEPACK POST-COMMIT BEGIN ===" in post.read_text(encoding="utf-8", errors="ignore")
|
|
2515
|
+
ignored=False; cig=run_git(repo,["check-ignore",".sourcepack/"])
|
|
2516
|
+
if cig.returncode==0: ignored=True
|
|
2517
|
+
elif (repo/".gitignore").exists(): ignored=any(line.strip() in {".sourcepack",".sourcepack/"} for line in (repo/".gitignore").read_text(errors="ignore").splitlines())
|
|
2518
|
+
last_report=None; last_light=None
|
|
2519
|
+
if paths["latest_json"].exists():
|
|
2520
|
+
try:
|
|
2521
|
+
lr=json.loads(paths["latest_json"].read_text()); last_report=lr.get("verdict"); last_light=lr.get("light")
|
|
2522
|
+
except Exception: pass
|
|
2523
|
+
dirty, dirty_state = git_worktree_dirty(repo)
|
|
2524
|
+
stale = baseline_status["state"] == "stale"
|
|
2525
|
+
stale_data = (baseline_status.get("details") or {}).get("stale_details")
|
|
2526
|
+
prompt_exists = paths["prompt"].exists()
|
|
2527
|
+
automatic = current and baseline and hook_installed and post_hook_installed and ignored
|
|
2528
|
+
data={"schema_version":"sourcepack_status.v1","sourcepack_version":__version__,"generated_at":utc_now(),"automatic_mode_enabled":automatic,"local_storage_exists":current,"baseline_exists":baseline,"prompt_context_exists":prompt_exists,"pre_commit_hook_installed":hook_installed,"post_commit_hook_installed":post_hook_installed,"hook_strict_mode":strict,"hook_policy":"RED blocks, YELLOW blocks" if strict else "RED blocks, YELLOW warns","sourcepack_gitignored":ignored,"last_report_verdict":last_report,"last_report_light":last_light,"dirty_worktree":dirty if dirty_state is None else None,"git_repo":git_repo,"last_baseline_update":last}
|
|
2529
|
+
data.update(baseline_report_fields(baseline_status))
|
|
2530
|
+
if args.json: print(json.dumps(data, indent=2)); return 0
|
|
2531
|
+
print(f"SourcePack status for {repo}\n")
|
|
2532
|
+
print(f"Automatic mode: {'enabled' if automatic else 'not enabled'}")
|
|
2533
|
+
print(f"Baseline: {baseline_status['state']}")
|
|
2534
|
+
print(f"Prompt context: {'present' if prompt_exists else 'missing'}")
|
|
2535
|
+
print(f"Pre-commit hook: {'installed' if hook_installed else 'not installed'}")
|
|
2536
|
+
print(f"Post-commit baseline hook: {'installed' if post_hook_installed else 'not installed'}")
|
|
2537
|
+
print(f"Hook policy: {data['hook_policy']}")
|
|
2538
|
+
print(f".sourcepack/ gitignored: {'yes' if ignored else 'no'}")
|
|
2539
|
+
print(f"Working tree: {'dirty' if dirty else 'clean' if dirty_state is None else 'unknown'}")
|
|
2540
|
+
print(f"Last report: {last_light or last_report or 'none'}")
|
|
2541
|
+
return 0
|
|
2542
|
+
|
|
2543
|
+
def init_workspace(path: str | Path):
|
|
2544
|
+
p = Path(path); p.mkdir(parents=True, exist_ok=True)
|
|
2545
|
+
ignore = p / ".sourcepackignore"
|
|
2546
|
+
config = p / "sourcepack.config.json"
|
|
2547
|
+
if not ignore.exists():
|
|
2548
|
+
ignore.write_text("# SourcePack ignore rules\n.env\nnode_modules/\ndist/\nbuild/\n", encoding="utf-8")
|
|
2549
|
+
if not config.exists():
|
|
2550
|
+
config.write_text(json.dumps({"max_file_size": 1_000_000, "include_hidden": False, "redact_secrets": True}, indent=2), encoding="utf-8")
|
|
2551
|
+
print(f"Initialized SourcePack workspace at {p}")
|
|
2552
|
+
|
|
2553
|
+
|
|
2554
|
+
|
|
2555
|
+
def write_auto_report(repo: Path, report: dict, details: dict) -> None:
|
|
2556
|
+
payload = dict(report)
|
|
2557
|
+
payload.update(details)
|
|
2558
|
+
write_user_report(repo, payload, "auto")
|
|
2559
|
+
|
|
2560
|
+
|
|
2561
|
+
def cli_init(args) -> int:
|
|
2562
|
+
repo = Path(args.path).resolve()
|
|
2563
|
+
if not getattr(args, "auto", False):
|
|
2564
|
+
init_workspace(repo)
|
|
2565
|
+
return 0
|
|
2566
|
+
initial_dirty, initial_dirty_state = git_worktree_dirty(repo)
|
|
2567
|
+
init_workspace(repo)
|
|
2568
|
+
findings: list[dict] = []
|
|
2569
|
+
details = {"baseline_created": False, "baseline_refreshed": False, "hook_installed": False, "strict_mode": bool(args.strict), "sourcepack_gitignored": False, "dirty_worktree": False, "next_action": "continue."}
|
|
2570
|
+
paths = ensure_sourcepack_dirs(repo)
|
|
2571
|
+
added, err = ensure_gitignore_entry(repo)
|
|
2572
|
+
if err:
|
|
2573
|
+
rep = traffic_report("FAIL", "SourcePack automatic mode could not be enabled.", [normalized_finding("gitignore_unwritable", "error", "git", f"Cannot write .gitignore: {err}")])
|
|
2574
|
+
write_auto_report(repo, rep, details)
|
|
2575
|
+
print(render_traffic(rep), end=""); return 1
|
|
2576
|
+
details["sourcepack_gitignored"] = True
|
|
2577
|
+
dirty, dirty_state = initial_dirty, initial_dirty_state
|
|
2578
|
+
details["dirty_worktree"] = dirty
|
|
2579
|
+
baseline_exists = validate_baseline(repo).get("state") in {"present", "stale", "corrupt"}
|
|
2580
|
+
if args.refresh_baseline or (not baseline_exists and not dirty):
|
|
2581
|
+
try:
|
|
2582
|
+
_, created = build_current_baseline(repo)
|
|
2583
|
+
details["baseline_created"] = created
|
|
2584
|
+
details["baseline_refreshed"] = not created or args.refresh_baseline
|
|
2585
|
+
if dirty:
|
|
2586
|
+
findings.append(normalized_finding("dirty_worktree", "warn", "baseline", "dirty_worktree: baseline includes current uncommitted changes."))
|
|
2587
|
+
except BaselineLockError as exc:
|
|
2588
|
+
findings.append(normalized_finding("baseline_locked", "warn", "tooling", str(exc)))
|
|
2589
|
+
details["next_action"] = "Try again after the other baseline operation finishes."
|
|
2590
|
+
except Exception as exc:
|
|
2591
|
+
findings.append(normalized_finding("baseline_failed", "error", "baseline", f"Baseline verification failed: {exc}"))
|
|
2592
|
+
elif not baseline_exists and dirty:
|
|
2593
|
+
findings.append(normalized_finding("dirty_worktree", "warn", "baseline", "dirty_worktree: working tree has uncommitted changes, so baseline was not created."))
|
|
2594
|
+
findings.append(normalized_finding("baseline_missing", "warn", "baseline", "baseline_missing: run sourcepack baseline --refresh to accept current repo state."))
|
|
2595
|
+
details["next_action"] = "Run sourcepack init . --auto --refresh-baseline or sourcepack baseline --refresh to accept current repo state."
|
|
2596
|
+
if args.install_hygiene_hooks:
|
|
2597
|
+
findings.append(normalized_finding("hygiene_hooks_deferred", "warn", "hook", "baseline hygiene hooks are not installed by this release."))
|
|
2598
|
+
cp = run_git(repo, ["rev-parse", "--show-toplevel"])
|
|
2599
|
+
if args.no_hook:
|
|
2600
|
+
pass
|
|
2601
|
+
elif cp.returncode != 0:
|
|
2602
|
+
findings.append(normalized_finding("no_git_repo" if cp.returncode != 127 else "git_unavailable", "warn", "git", "no_git_repo: pre-commit hook was not installed because this is not a git repository." if cp.returncode != 127 else "Git executable not found."))
|
|
2603
|
+
else:
|
|
2604
|
+
class HookArgs: pass
|
|
2605
|
+
h = HookArgs(); h.repo = str(repo); h.strict = bool(args.strict)
|
|
2606
|
+
rc = cli_install_hook(h)
|
|
2607
|
+
details["hook_installed"] = rc == 0
|
|
2608
|
+
if rc != 0:
|
|
2609
|
+
findings.append(normalized_finding("hook_install_failed", "warn", "hook", "pre-commit hook could not be installed."))
|
|
2610
|
+
verdict = "FAIL" if any(f["severity"] == "error" for f in findings) else "WARN" if findings else "PASS"
|
|
2611
|
+
headline = "SourcePack automatic mode enabled." if verdict == "PASS" else "SourcePack automatic mode partially enabled." if verdict == "WARN" else "SourcePack automatic mode could not be enabled."
|
|
2612
|
+
rep = traffic_report(verdict, headline, findings, ["init", "baseline", "hook"], details.get("next_action", "continue."))
|
|
2613
|
+
write_auto_report(repo, rep, details)
|
|
2614
|
+
if args.json:
|
|
2615
|
+
print(json.dumps({**rep, **details}, indent=2)); return 0 if verdict != "FAIL" else 1
|
|
2616
|
+
print(f"{rep['light']}: {headline}\n")
|
|
2617
|
+
if findings:
|
|
2618
|
+
print("Warnings:" if verdict == "WARN" else "Blockers:")
|
|
2619
|
+
for f in findings: print(f"* {f['id']}: {f['message']}")
|
|
2620
|
+
print()
|
|
2621
|
+
print(f"Baseline: {'created' if details['baseline_created'] else 'refreshed' if details['baseline_refreshed'] else 'present' if baseline_exists else 'missing'}")
|
|
2622
|
+
print(f"Pre-commit hook: {'skipped' if args.no_hook else 'installed' if details['hook_installed'] else 'not installed'}")
|
|
2623
|
+
print(f".sourcepack/ gitignored: {'yes' if details['sourcepack_gitignored'] else 'no'}")
|
|
2624
|
+
return 0 if verdict != "FAIL" else 1
|
|
2625
|
+
|
|
2626
|
+
def _health_check_rows() -> list[tuple[str, str, str]]:
|
|
2627
|
+
rows: list[tuple[str, str, str]] = []
|
|
2628
|
+
rows.append(("version", "PASS" if __version__ else "FAIL", __version__ or "missing package version"))
|
|
2629
|
+
rows.append(("python", "PASS" if sys.version_info >= (3, 11) else "FAIL", platform.python_version()))
|
|
2630
|
+
rows.append(("platform", "PASS", platform.platform()))
|
|
2631
|
+
rows.append(("git", "PASS" if shutil.which("git") else "WARN", shutil.which("git") or "not found on PATH; git-backed checks and hooks will be limited"))
|
|
2632
|
+
rows.append(("secret_signatures", "PASS" if SECRET_PATTERNS else "FAIL", str(len(SECRET_PATTERNS))))
|
|
2633
|
+
|
|
2634
|
+
required_assets = ("audit_template.md", "packet_instructions.md")
|
|
2635
|
+
try:
|
|
2636
|
+
asset_root = resources.files("sourcepack.assets")
|
|
2637
|
+
missing_assets = [name for name in required_assets if not (asset_root / name).is_file()]
|
|
2638
|
+
except (FileNotFoundError, ModuleNotFoundError, AttributeError, TypeError) as exc:
|
|
2639
|
+
missing_assets = list(required_assets)
|
|
2640
|
+
rows.append(("package_assets", "FAIL", f"could not inspect packaged assets: {exc}"))
|
|
2641
|
+
else:
|
|
2642
|
+
rows.append(("package_assets", "PASS" if not missing_assets else "FAIL", "all required assets present" if not missing_assets else "missing: " + ", ".join(missing_assets)))
|
|
2643
|
+
|
|
2644
|
+
report_renderers = (render_report_html, render_traffic, write_user_report)
|
|
2645
|
+
rows.append(("report_renderers", "PASS" if all(callable(fn) for fn in report_renderers) else "FAIL", "html, markdown, and json renderers importable"))
|
|
2646
|
+
return rows
|
|
2647
|
+
|
|
2648
|
+
|
|
2649
|
+
def doctor(strict: bool = False) -> int:
|
|
2650
|
+
rows = _health_check_rows()
|
|
2651
|
+
print("--- SourcePack Health Check ---")
|
|
2652
|
+
for name, status, detail in rows:
|
|
2653
|
+
print(f"{status:4} {name}: {detail}")
|
|
2654
|
+
has_fail = any(status == "FAIL" for _, status, _ in rows)
|
|
2655
|
+
has_warn = any(status == "WARN" for _, status, _ in rows)
|
|
2656
|
+
if has_fail or (strict and has_warn):
|
|
2657
|
+
print("Status: NOT READY")
|
|
2658
|
+
return 1
|
|
2659
|
+
print("Status: READY")
|
|
2660
|
+
return 0
|
|
2661
|
+
|
|
2662
|
+
|
|
2663
|
+
|
|
2664
|
+
def cli_exec(args) -> int:
|
|
2665
|
+
entry = run_and_record(args.exec_command, cwd=".")
|
|
2666
|
+
print(entry.stdout_excerpt, end="")
|
|
2667
|
+
if entry.stderr_excerpt:
|
|
2668
|
+
print(entry.stderr_excerpt, end="", file=sys.stderr)
|
|
2669
|
+
print(f"SourcePack evidence entry: {entry.entry_id}", file=sys.stderr)
|
|
2670
|
+
return entry.exit_code
|
|
2671
|
+
|
|
2672
|
+
|
|
2673
|
+
def cli_evidence(args) -> int:
|
|
2674
|
+
repo = find_repo_root(".")
|
|
2675
|
+
if args.evidence_command == "clear":
|
|
2676
|
+
clear_ledger(repo)
|
|
2677
|
+
print("Cleared SourcePack execution evidence ledger.")
|
|
2678
|
+
return 0
|
|
2679
|
+
if args.evidence_command == "list":
|
|
2680
|
+
entries = list(iter_entries(repo))
|
|
2681
|
+
if args.json:
|
|
2682
|
+
print(json.dumps({"schema_version": "sourcepack.execution_ledger.list.v1", "entries": entries}, indent=2))
|
|
2683
|
+
return 0
|
|
2684
|
+
for entry in entries:
|
|
2685
|
+
print(f"{entry.get('entry_id')} exit={entry.get('exit_code')} command={' '.join(entry.get('command') or [])}")
|
|
2686
|
+
return 0
|
|
2687
|
+
if args.evidence_command == "show":
|
|
2688
|
+
for entry in iter_entries(repo):
|
|
2689
|
+
if entry.get("entry_id") == args.entry_id:
|
|
2690
|
+
print(json.dumps(entry, indent=2, sort_keys=True))
|
|
2691
|
+
return 0
|
|
2692
|
+
print(f"ERROR: evidence entry not found: {args.entry_id}", file=sys.stderr)
|
|
2693
|
+
return 1
|
|
2694
|
+
if args.evidence_command == "export":
|
|
2695
|
+
print(json.dumps({"schema_version": "sourcepack.execution_ledger.export.v1", "entries": list(iter_entries(repo))}, indent=2))
|
|
2696
|
+
return 0
|
|
2697
|
+
return 1
|
|
2698
|
+
|
|
2699
|
+
REASON_EXPLANATIONS = {
|
|
2700
|
+
"unsupported_dependency": "A changed file imports a dependency that SourcePack could not find in local dependency manifests.",
|
|
2701
|
+
"unsupported_command": "A changed instruction references a project command that SourcePack could not find in local command manifests.",
|
|
2702
|
+
"declared_command": "The same patch declares command support and uses it; SourcePack requires review instead of treating it as established baseline evidence.",
|
|
2703
|
+
"command_manifest_missing": "A command check needed a local manifest/config file, but none was available.",
|
|
2704
|
+
"command_check_inconclusive": "SourcePack recognized the command family but could not safely infer support from dynamic or ambiguous config.",
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
def _policy_dir(repo: Path) -> Path:
|
|
2708
|
+
path = repo / ".sourcepack" / "policy"
|
|
2709
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
2710
|
+
return path
|
|
2711
|
+
|
|
2712
|
+
def _policy_file(repo: Path) -> Path:
|
|
2713
|
+
return _policy_dir(repo) / "allow.jsonl"
|
|
2714
|
+
|
|
2715
|
+
def _policy_entries(repo: Path) -> list[dict]:
|
|
2716
|
+
path = _policy_file(repo)
|
|
2717
|
+
if not path.exists(): return []
|
|
2718
|
+
entries=[]
|
|
2719
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
2720
|
+
try: entries.append(json.loads(line))
|
|
2721
|
+
except Exception: pass
|
|
2722
|
+
return entries
|
|
2723
|
+
|
|
2724
|
+
def cli_explain(args) -> int:
|
|
2725
|
+
code = args.reason_code.strip()
|
|
2726
|
+
print(f"{code}: {REASON_EXPLANATIONS.get(code, 'See docs/reason-codes.md and src/sourcepack/reason_codes.py for the canonical SourcePack reason-code vocabulary.')}")
|
|
2727
|
+
return 0
|
|
2728
|
+
|
|
2729
|
+
def cli_allow(args) -> int:
|
|
2730
|
+
repo = Path(".").resolve(); reason = getattr(args, "reason", None)
|
|
2731
|
+
if not reason:
|
|
2732
|
+
print("ERROR: --reason is required", file=sys.stderr); return 2
|
|
2733
|
+
scope_type = args.allow_type; value = args.value
|
|
2734
|
+
protected = value.startswith(".git/") or value == ".git" or value.startswith(".sourcepack/")
|
|
2735
|
+
if protected and not getattr(args, "high_risk", False):
|
|
2736
|
+
print("ERROR: protected artifacts require --high-risk and .git/** cannot be overridden", file=sys.stderr); return 1
|
|
2737
|
+
if value.startswith(".git/") or value == ".git":
|
|
2738
|
+
print("ERROR: .git/** cannot be overridden", file=sys.stderr); return 1
|
|
2739
|
+
entry = {"schema_version":"sourcepack.policy.allow.v1", "id": sha256_text(f'{scope_type}:{value}:{utc_now()}')[:12], "scope": scope_type, "value": value, "reason": reason, "created_at": utc_now(), "expires_at": getattr(args, "expires", None), "high_risk": bool(getattr(args, "high_risk", False))}
|
|
2740
|
+
with _policy_file(repo).open("a", encoding="utf-8") as f:
|
|
2741
|
+
f.write(json.dumps(entry, sort_keys=True)+"\n")
|
|
2742
|
+
print(json.dumps(entry, indent=2))
|
|
2743
|
+
return 0
|
|
2744
|
+
|
|
2745
|
+
def cli_policy(args) -> int:
|
|
2746
|
+
repo = Path(".").resolve()
|
|
2747
|
+
if args.policy_command == "list":
|
|
2748
|
+
print(json.dumps({"schema_version":"sourcepack.policy.list.v1", "policies": _policy_entries(repo)}, indent=2)); return 0
|
|
2749
|
+
if args.policy_command == "remove":
|
|
2750
|
+
entries = [e for e in _policy_entries(repo) if e.get("id") != args.policy_id]
|
|
2751
|
+
_policy_file(repo).write_text("".join(json.dumps(e, sort_keys=True)+"\n" for e in entries), encoding="utf-8")
|
|
2752
|
+
print(f"Removed policy {args.policy_id}"); return 0
|
|
2753
|
+
return 1
|
|
2754
|
+
|
|
2755
|
+
def cli_reset(args) -> int:
|
|
2756
|
+
repo = Path(args.repo).resolve(); target = repo / ".sourcepack" / "reports"
|
|
2757
|
+
if target.exists(): shutil.rmtree(target)
|
|
2758
|
+
print("SourcePack reset complete: removed local reports only; user code and trusted baseline were not deleted.")
|
|
2759
|
+
return 0
|
|
2760
|
+
|
|
2761
|
+
def cli_baseline_lifecycle(args) -> int | None:
|
|
2762
|
+
if args.repo not in {"status", "verify", "refresh", "repair", "path"}: return None
|
|
2763
|
+
command = args.repo; repo = Path(".").resolve(); status = validate_baseline(repo)
|
|
2764
|
+
if command == "status":
|
|
2765
|
+
if args.json: print(json.dumps({"schema_version":"sourcepack.baseline.status.v1", **status}, indent=2))
|
|
2766
|
+
else: print(f"Baseline: {status.get('state')}\n{status.get('message')}")
|
|
2767
|
+
return 0
|
|
2768
|
+
if command == "verify":
|
|
2769
|
+
if args.json: print(json.dumps({"schema_version":"sourcepack.baseline.verify.v1", **status}, indent=2))
|
|
2770
|
+
else: print(f"Baseline verify: {status.get('state')} - {status.get('message')}")
|
|
2771
|
+
return 0 if status.get("state") in {"present", "stale"} else 1
|
|
2772
|
+
if command == "path":
|
|
2773
|
+
print(status.get("packet_path") or "")
|
|
2774
|
+
return 0 if status.get("packet_path") else 1
|
|
2775
|
+
if command == "refresh":
|
|
2776
|
+
dirty, _ = git_worktree_dirty(repo)
|
|
2777
|
+
if dirty and not getattr(args, "force", False):
|
|
2778
|
+
print("ERROR: refusing baseline refresh with dirty worktree; commit/discard changes or pass --force after review.", file=sys.stderr); return 1
|
|
2779
|
+
class A: pass
|
|
2780
|
+
a=A(); a.repo="."; a.refresh=True; a.verbose=getattr(args,"verbose",False); a.json=args.json; a.quiet=False
|
|
2781
|
+
return cli_baseline(a)
|
|
2782
|
+
if command == "repair":
|
|
2783
|
+
print("Baseline repair checked metadata; no unsafe repair was attempted.")
|
|
2784
|
+
return 0 if status.get("state") in {"present", "stale"} else 1
|
|
2785
|
+
return None
|
|
2786
|
+
|
|
2787
|
+
def run_cli(args_list=None):
|
|
2788
|
+
parser = argparse.ArgumentParser(prog="sourcepack", description="Local guardrail for AI-assisted repo changes. PASS exits 0, WARN exits 0 locally unless --strict or --ci is used, and FAIL exits nonzero.")
|
|
2789
|
+
parser.add_argument("--version", action="store_true")
|
|
2790
|
+
subs = parser.add_subparsers(dest="command")
|
|
2791
|
+
build = subs.add_parser("build")
|
|
2792
|
+
build.add_argument("input")
|
|
2793
|
+
build.add_argument("--out", required=True)
|
|
2794
|
+
build.add_argument("--force", action="store_true")
|
|
2795
|
+
build.add_argument("--max-file-size", type=int, default=1_000_000)
|
|
2796
|
+
build.add_argument("--include-hidden", action="store_true")
|
|
2797
|
+
build.add_argument("--no-redact", action="store_true")
|
|
2798
|
+
verify = subs.add_parser("verify")
|
|
2799
|
+
verify.add_argument("packet")
|
|
2800
|
+
verify.add_argument("--against")
|
|
2801
|
+
judge = subs.add_parser("judge")
|
|
2802
|
+
judge.add_argument("packet")
|
|
2803
|
+
judge.add_argument("ai_answer")
|
|
2804
|
+
judge.add_argument("--out")
|
|
2805
|
+
judge_patch_cmd = subs.add_parser("judge-patch", help="judge a unified diff against a packet", description="Judge a git-style unified diff against SourcePack packet evidence. The JSON and markdown reports include verdict, blockers, warnings, uncertainties, checked categories, not checked categories, next action, and report path.")
|
|
2806
|
+
judge_patch_cmd.add_argument("packet")
|
|
2807
|
+
judge_patch_cmd.add_argument("patch")
|
|
2808
|
+
judge_patch_cmd.add_argument("--out", required=True)
|
|
2809
|
+
map_cmd = subs.add_parser("map")
|
|
2810
|
+
map_cmd.add_argument("input")
|
|
2811
|
+
map_cmd.add_argument("--out", required=True)
|
|
2812
|
+
instr = subs.add_parser("instructions")
|
|
2813
|
+
instr.add_argument("packet")
|
|
2814
|
+
subs.add_parser("demo")
|
|
2815
|
+
init = subs.add_parser("init", help="initialize local SourcePack state", description="Initialize .sourcepack state. With --auto, create a safe baseline when possible and install git hooks. --strict installs hooks that block WARN and FAIL.")
|
|
2816
|
+
init.add_argument("path", nargs="?", default=".")
|
|
2817
|
+
init.add_argument("--auto", action="store_true")
|
|
2818
|
+
init.add_argument("--strict", action="store_true")
|
|
2819
|
+
init.add_argument("--no-hook", action="store_true")
|
|
2820
|
+
init.add_argument("--refresh-baseline", action="store_true")
|
|
2821
|
+
init.add_argument("--install-hygiene-hooks", action="store_true")
|
|
2822
|
+
init.add_argument("--json", action="store_true")
|
|
2823
|
+
doctor_cmd = subs.add_parser("doctor")
|
|
2824
|
+
doctor_cmd.add_argument("--strict", action="store_true", help="exit nonzero on warnings as well as failures")
|
|
2825
|
+
exec_cmd = subs.add_parser("exec", help="run a local command and record bounded execution evidence")
|
|
2826
|
+
exec_cmd.add_argument("exec_command", nargs=argparse.REMAINDER)
|
|
2827
|
+
evidence_cmd = subs.add_parser("evidence", help="inspect local SourcePack execution evidence")
|
|
2828
|
+
evidence_subs = evidence_cmd.add_subparsers(dest="evidence_command")
|
|
2829
|
+
evidence_list = evidence_subs.add_parser("list")
|
|
2830
|
+
evidence_list.add_argument("--json", action="store_true")
|
|
2831
|
+
evidence_show = evidence_subs.add_parser("show")
|
|
2832
|
+
evidence_show.add_argument("entry_id")
|
|
2833
|
+
evidence_subs.add_parser("clear")
|
|
2834
|
+
evidence_export = evidence_subs.add_parser("export")
|
|
2835
|
+
evidence_export.add_argument("--json", action="store_true")
|
|
2836
|
+
prompt_cmd = subs.add_parser("prompt", help="write non-authoritative AI prompt context", description="Generate selective prompt context for an AI task. Prompt context is non-authoritative and never refreshes the trusted enforcement baseline.")
|
|
2837
|
+
prompt_cmd.add_argument("repo")
|
|
2838
|
+
prompt_cmd.add_argument("task", nargs="?")
|
|
2839
|
+
prompt_cmd.add_argument("--copy", action="store_true")
|
|
2840
|
+
prompt_cmd.add_argument("--verbose", action="store_true")
|
|
2841
|
+
prompt_cmd.add_argument("--json", action="store_true")
|
|
2842
|
+
baseline_cmd = subs.add_parser("baseline", help="create or refresh trusted enforcement baseline", description="Create or refresh .sourcepack/baseline, the authoritative enforcement state used by sourcepack diff.")
|
|
2843
|
+
baseline_cmd.add_argument("repo")
|
|
2844
|
+
baseline_cmd.add_argument("--force", action="store_true")
|
|
2845
|
+
baseline_cmd.add_argument("--refresh", action="store_true")
|
|
2846
|
+
baseline_cmd.add_argument("--verbose", action="store_true")
|
|
2847
|
+
baseline_cmd.add_argument("--json", action="store_true")
|
|
2848
|
+
baseline_cmd.add_argument("--quiet", action="store_true")
|
|
2849
|
+
diff_cmd = subs.add_parser("diff", help="check repo changes against trusted baseline", description="Judge working-tree or staged changes against .sourcepack/baseline. PASS exits 0. WARN exits 0 locally, but exits nonzero with --strict or --ci. FAIL exits nonzero. --json stays machine-readable.")
|
|
2850
|
+
diff_cmd.add_argument("repo")
|
|
2851
|
+
diff_cmd.add_argument("--staged", action="store_true")
|
|
2852
|
+
diff_cmd.add_argument("--verbose", action="store_true")
|
|
2853
|
+
diff_cmd.add_argument("--json", action="store_true")
|
|
2854
|
+
diff_cmd.add_argument("--strict", action="store_true", help="exit nonzero on WARN as well as FAIL")
|
|
2855
|
+
diff_cmd.add_argument("--ci", action="store_true", help="non-interactive CI mode; implies --strict and prints JSON")
|
|
2856
|
+
install_hook = subs.add_parser("install-hook")
|
|
2857
|
+
install_hook.add_argument("repo")
|
|
2858
|
+
install_hook.add_argument("--strict", action="store_true")
|
|
2859
|
+
uninstall_hook = subs.add_parser("uninstall-hook")
|
|
2860
|
+
uninstall_hook.add_argument("repo")
|
|
2861
|
+
status_cmd = subs.add_parser("status", help="show SourcePack repo state", description="Show baseline, hook, report, git, and dirty-worktree state without changing the baseline.")
|
|
2862
|
+
status_cmd.add_argument("repo")
|
|
2863
|
+
status_cmd.add_argument("--json", action="store_true")
|
|
2864
|
+
report_cmd = subs.add_parser("report", help="work with local SourcePack reports")
|
|
2865
|
+
report_subs = report_cmd.add_subparsers(dest="report_command")
|
|
2866
|
+
report_open = report_subs.add_parser("open", help="open .sourcepack/reports/latest.html")
|
|
2867
|
+
report_open.add_argument("repo", nargs="?", default=".")
|
|
2868
|
+
report_path = report_subs.add_parser("path", help="print .sourcepack/reports/latest.html")
|
|
2869
|
+
report_path.add_argument("repo", nargs="?", default=".")
|
|
2870
|
+
explain_cmd = subs.add_parser("explain")
|
|
2871
|
+
explain_cmd.add_argument("reason_code")
|
|
2872
|
+
allow_cmd = subs.add_parser("allow")
|
|
2873
|
+
allow_cmd.add_argument("allow_type", choices=["dependency", "command", "path"])
|
|
2874
|
+
allow_cmd.add_argument("value")
|
|
2875
|
+
allow_cmd.add_argument("--reason", required=True)
|
|
2876
|
+
allow_cmd.add_argument("--expires")
|
|
2877
|
+
allow_cmd.add_argument("--high-risk", action="store_true")
|
|
2878
|
+
policy_cmd = subs.add_parser("policy")
|
|
2879
|
+
policy_subs = policy_cmd.add_subparsers(dest="policy_command")
|
|
2880
|
+
policy_subs.add_parser("list")
|
|
2881
|
+
policy_remove = policy_subs.add_parser("remove")
|
|
2882
|
+
policy_remove.add_argument("policy_id")
|
|
2883
|
+
reset_cmd = subs.add_parser("reset")
|
|
2884
|
+
reset_cmd.add_argument("repo", nargs="?", default=".")
|
|
2885
|
+
args = parser.parse_args(args_list)
|
|
2886
|
+
if args.version:
|
|
2887
|
+
print(__version__); return 0
|
|
2888
|
+
try:
|
|
2889
|
+
if args.command == "doctor":
|
|
2890
|
+
return doctor(strict=getattr(args, "strict", False))
|
|
2891
|
+
if args.command == "exec":
|
|
2892
|
+
if args.exec_command and args.exec_command[0] == "--":
|
|
2893
|
+
args.exec_command = args.exec_command[1:]
|
|
2894
|
+
return cli_exec(args)
|
|
2895
|
+
if args.command == "evidence":
|
|
2896
|
+
return cli_evidence(args)
|
|
2897
|
+
if args.command == "init":
|
|
2898
|
+
return cli_init(args)
|
|
2899
|
+
if args.command == "prompt":
|
|
2900
|
+
return cli_prompt(args)
|
|
2901
|
+
if args.command == "baseline":
|
|
2902
|
+
lifecycle = cli_baseline_lifecycle(args)
|
|
2903
|
+
if lifecycle is not None:
|
|
2904
|
+
return lifecycle
|
|
2905
|
+
return cli_baseline(args)
|
|
2906
|
+
if args.command == "diff":
|
|
2907
|
+
return cli_diff(args)
|
|
2908
|
+
if args.command == "install-hook":
|
|
2909
|
+
return cli_install_hook(args)
|
|
2910
|
+
if args.command == "uninstall-hook":
|
|
2911
|
+
return cli_uninstall_hook(args)
|
|
2912
|
+
if args.command == "status":
|
|
2913
|
+
return cli_status(args)
|
|
2914
|
+
if args.command == "explain":
|
|
2915
|
+
return cli_explain(args)
|
|
2916
|
+
if args.command == "allow":
|
|
2917
|
+
return cli_allow(args)
|
|
2918
|
+
if args.command == "policy":
|
|
2919
|
+
return cli_policy(args)
|
|
2920
|
+
if args.command == "reset":
|
|
2921
|
+
return cli_reset(args)
|
|
2922
|
+
if args.command == "report":
|
|
2923
|
+
if args.report_command == "open":
|
|
2924
|
+
return cli_report_open(args)
|
|
2925
|
+
if args.report_command == "path":
|
|
2926
|
+
return cli_report_path(args)
|
|
2927
|
+
parser.parse_args(["report", "--help"])
|
|
2928
|
+
return 1
|
|
2929
|
+
if args.command == "build":
|
|
2930
|
+
scanner = SourceScanner(args.input, max_file_size=args.max_file_size, include_hidden=args.include_hidden, redact=not args.no_redact).scan()
|
|
2931
|
+
out = PacketWriter(args.out, scanner, force=args.force).write_all()
|
|
2932
|
+
print(f"Packet built successfully at {out}"); return 0
|
|
2933
|
+
if args.command == "map":
|
|
2934
|
+
scanner = SourceScanner(args.input).scan()
|
|
2935
|
+
with tempfile.TemporaryDirectory() as td:
|
|
2936
|
+
packet = PacketWriter(td, scanner, force=True).write_all()
|
|
2937
|
+
reality_map = json.loads((packet / "reality_map.json").read_text(encoding="utf-8"))
|
|
2938
|
+
out_path = Path(args.out)
|
|
2939
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2940
|
+
out_path.write_text(json.dumps(reality_map, indent=2), encoding="utf-8")
|
|
2941
|
+
print(f"Reality map written to {out_path}"); return 0
|
|
2942
|
+
if args.command == "instructions":
|
|
2943
|
+
packet = Path(args.packet)
|
|
2944
|
+
instructions_path = packet / "ai_instructions.md"
|
|
2945
|
+
if instructions_path.exists():
|
|
2946
|
+
print(instructions_path.read_text(encoding="utf-8"), end=""); return 0
|
|
2947
|
+
reality_path = packet / "reality_map.json"
|
|
2948
|
+
if not reality_path.exists():
|
|
2949
|
+
print("ERROR: missing ai_instructions.md and reality_map.json", file=sys.stderr); return 1
|
|
2950
|
+
reality_map = json.loads(reality_path.read_text(encoding="utf-8"))
|
|
2951
|
+
text = render_ai_instructions(reality_map)
|
|
2952
|
+
instructions_path.write_text(text, encoding="utf-8")
|
|
2953
|
+
print(text, end=""); return 0
|
|
2954
|
+
if args.command == "demo":
|
|
2955
|
+
demo_repo = Path("examples/demo_repo")
|
|
2956
|
+
fake_answer = Path("examples/fake_ai_answer.md")
|
|
2957
|
+
if not demo_repo.exists() or not fake_answer.exists():
|
|
2958
|
+
print("ERROR: examples/demo_repo and examples/fake_ai_answer.md are required", file=sys.stderr); return 1
|
|
2959
|
+
tmp = Path(tempfile.mkdtemp(prefix="sourcepack_demo_"))
|
|
2960
|
+
packet = tmp / "packet"
|
|
2961
|
+
judgment = tmp / "judgment"
|
|
2962
|
+
PacketWriter(packet, SourceScanner(demo_repo).scan(), force=True).write_all()
|
|
2963
|
+
if not verify_packet(packet): return 1
|
|
2964
|
+
judge_ai_answer(packet, fake_answer, judgment)
|
|
2965
|
+
fake_patch = Path("examples/fake_ai_patch.diff")
|
|
2966
|
+
if fake_patch.exists():
|
|
2967
|
+
patch_judgment = tmp / "patch_judgment"
|
|
2968
|
+
judge_patch(packet, fake_patch, patch_judgment)
|
|
2969
|
+
print(f"Demo patch judgment: {patch_judgment}")
|
|
2970
|
+
print(f"Demo packet: {packet}")
|
|
2971
|
+
print(f"Demo judgment: {judgment}")
|
|
2972
|
+
return 0
|
|
2973
|
+
if args.command == "verify":
|
|
2974
|
+
return 0 if verify_packet(args.packet, args.against) else 1
|
|
2975
|
+
if args.command == "judge":
|
|
2976
|
+
judge_ai_answer(args.packet, args.ai_answer, args.out); return 0
|
|
2977
|
+
if args.command == "judge-patch":
|
|
2978
|
+
report = judge_patch(args.packet, args.patch, args.out)
|
|
2979
|
+
return 1 if report.get("malformed_diff") else 0
|
|
2980
|
+
parser.print_help(); return 1
|
|
2981
|
+
except Exception as exc:
|
|
2982
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
2983
|
+
return 1
|
|
2984
|
+
|
|
2985
|
+
|
|
2986
|
+
def main(argv: list[str] | None = None) -> int:
|
|
2987
|
+
return run_cli(argv)
|
|
2988
|
+
|
|
2989
|
+
|
|
2990
|
+
if __name__ == "__main__":
|
|
2991
|
+
raise SystemExit(main())
|