context-engineering-cli 2.6.0__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.
- context_engineering/__init__.py +3 -0
- context_engineering/__main__.py +2 -0
- context_engineering/analysis/__init__.py +1 -0
- context_engineering/analysis/backfill.py +1064 -0
- context_engineering/analysis/context_check.py +253 -0
- context_engineering/analysis/context_layout.py +111 -0
- context_engineering/analysis/context_review.py +224 -0
- context_engineering/analysis/cross_cutting/__init__.py +6 -0
- context_engineering/analysis/cross_cutting/authors.py +57 -0
- context_engineering/analysis/cross_cutting/buckets.py +40 -0
- context_engineering/analysis/cross_cutting/co_change.py +47 -0
- context_engineering/analysis/cross_cutting/discover.py +75 -0
- context_engineering/analysis/cross_cutting/imports.py +61 -0
- context_engineering/analysis/cross_cutting/pair.py +118 -0
- context_engineering/analysis/impact.py +77 -0
- context_engineering/analysis/sessions.py +27 -0
- context_engineering/analysis/staleness.py +179 -0
- context_engineering/analysis/tier.py +91 -0
- context_engineering/checks/__init__.py +1 -0
- context_engineering/checks/antipatterns/__init__.py +5 -0
- context_engineering/checks/antipatterns/context.py +23 -0
- context_engineering/checks/antipatterns/density.py +72 -0
- context_engineering/checks/antipatterns/line_limits.py +52 -0
- context_engineering/checks/antipatterns/runner.py +137 -0
- context_engineering/checks/antipatterns/splitting.py +97 -0
- context_engineering/checks/antipatterns/volatile.py +38 -0
- context_engineering/checks/antipatterns/watermark.py +113 -0
- context_engineering/checks/contracts.py +456 -0
- context_engineering/checks/depth.py +82 -0
- context_engineering/checks/frontmatter.py +125 -0
- context_engineering/checks/references.py +325 -0
- context_engineering/checks/skill_structure.py +124 -0
- context_engineering/cli/__init__.py +3 -0
- context_engineering/cli/dispatch.py +90 -0
- context_engineering/cli/registry.py +33 -0
- context_engineering/cli/render.py +92 -0
- context_engineering/cli/subcommands.py +587 -0
- context_engineering/domain/__init__.py +0 -0
- context_engineering/domain/commit.py +19 -0
- context_engineering/domain/evidence.py +57 -0
- context_engineering/domain/finding.py +37 -0
- context_engineering/domain/result.py +59 -0
- context_engineering/infra/__init__.py +13 -0
- context_engineering/infra/filesystem.py +22 -0
- context_engineering/infra/git.py +153 -0
- context_engineering/infra/git_evidence.py +357 -0
- context_engineering/infra/git_tree.py +139 -0
- context_engineering/infra/markdown.py +58 -0
- context_engineering/infra/yaml_frontmatter.py +70 -0
- context_engineering_cli-2.6.0.dist-info/METADATA +27 -0
- context_engineering_cli-2.6.0.dist-info/RECORD +55 -0
- context_engineering_cli-2.6.0.dist-info/WHEEL +4 -0
- context_engineering_cli-2.6.0.dist-info/entry_points.txt +2 -0
- context_engineering_cli-2.6.0.dist-info/licenses/LICENSE +21 -0
- provenance.json +1 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Result containers — LintResult (findings) and AnalysisResult (data + findings)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .finding import Finding, Severity
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class LintResult:
|
|
13
|
+
target: str
|
|
14
|
+
findings: list[Finding] = field(default_factory=list)
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def error_count(self) -> int:
|
|
18
|
+
return sum(1 for f in self.findings if f.severity == Severity.ERROR)
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def warning_count(self) -> int:
|
|
22
|
+
return sum(1 for f in self.findings if f.severity == Severity.WARNING)
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def info_count(self) -> int:
|
|
26
|
+
return sum(1 for f in self.findings if f.severity == Severity.INFO)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def has_errors(self) -> bool:
|
|
30
|
+
return self.error_count > 0
|
|
31
|
+
|
|
32
|
+
def to_dict(self) -> dict[str, Any]:
|
|
33
|
+
return {
|
|
34
|
+
"target": self.target,
|
|
35
|
+
"findings": [f.to_dict() for f in self.findings],
|
|
36
|
+
"summary": {
|
|
37
|
+
"error": self.error_count,
|
|
38
|
+
"warning": self.warning_count,
|
|
39
|
+
"info": self.info_count,
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class AnalysisResult:
|
|
46
|
+
target: str
|
|
47
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
48
|
+
findings: list[Finding] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def has_errors(self) -> bool:
|
|
52
|
+
return any(finding.severity == Severity.ERROR for finding in self.findings)
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict[str, Any]:
|
|
55
|
+
return {
|
|
56
|
+
"target": self.target,
|
|
57
|
+
"data": self.data,
|
|
58
|
+
"findings": [f.to_dict() for f in self.findings],
|
|
59
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Thin wrappers around the outside world — git, YAML, filesystem."""
|
|
2
|
+
|
|
3
|
+
from .filesystem import EXCLUDED_DIRS, read_text_safe
|
|
4
|
+
from .git import git_log, git_root
|
|
5
|
+
from .yaml_frontmatter import parse_frontmatter
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"EXCLUDED_DIRS",
|
|
9
|
+
"git_log",
|
|
10
|
+
"git_root",
|
|
11
|
+
"parse_frontmatter",
|
|
12
|
+
"read_text_safe",
|
|
13
|
+
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Shared filesystem helpers — excluded-dir set + safe text read.
|
|
2
|
+
|
|
3
|
+
Each checker/analyzer does its own specialized discovery walk (docs-only,
|
|
4
|
+
AGENTS.md + docs pairs, git-tracked files), so we don't try to provide a
|
|
5
|
+
one-size-fits-all walker. Consumers check `EXCLUDED_DIRS` directly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
EXCLUDED_DIRS: frozenset[str] = frozenset(
|
|
13
|
+
{".git", ".venv", "_archive", "archive", "node_modules", "__pycache__"}
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def read_text_safe(path: Path) -> str | None:
|
|
18
|
+
"""Read text or return None on OSError/UnicodeDecodeError/missing."""
|
|
19
|
+
try:
|
|
20
|
+
return path.read_text(encoding="utf-8")
|
|
21
|
+
except (OSError, UnicodeDecodeError):
|
|
22
|
+
return None
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""One git wrapper. Replaces three separate git-log parsers across modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import subprocess
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from ..domain.commit import Commit
|
|
11
|
+
|
|
12
|
+
_COMMIT_SENTINEL = "\x1fCOMMIT\x1f"
|
|
13
|
+
_FIELD_SEP = "\x1f"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def resolve_commit(repo: Path, ref: str) -> tuple[str | None, str | None]:
|
|
17
|
+
"""Resolve ``ref`` to a full commit SHA without allowing Git options."""
|
|
18
|
+
try:
|
|
19
|
+
result = subprocess.run(
|
|
20
|
+
["git", "rev-parse", "--verify", "--end-of-options", f"{ref}^{{commit}}"],
|
|
21
|
+
capture_output=True,
|
|
22
|
+
text=True,
|
|
23
|
+
cwd=repo,
|
|
24
|
+
)
|
|
25
|
+
except FileNotFoundError:
|
|
26
|
+
return None, "Git is not installed or is not on PATH"
|
|
27
|
+
if result.returncode != 0:
|
|
28
|
+
diagnostic = result.stderr.strip() or f"git rev-parse exited {result.returncode}"
|
|
29
|
+
return None, diagnostic
|
|
30
|
+
return result.stdout.strip(), None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resolve_merge_base(repo: Path, base: str, head: str) -> tuple[str | None, str | None]:
|
|
34
|
+
"""Resolve the unique merge base used by a three-dot diff."""
|
|
35
|
+
try:
|
|
36
|
+
result = subprocess.run(
|
|
37
|
+
["git", "merge-base", "--", base, head],
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
cwd=repo,
|
|
41
|
+
)
|
|
42
|
+
except FileNotFoundError:
|
|
43
|
+
return None, "Git is not installed or is not on PATH"
|
|
44
|
+
if result.returncode != 0:
|
|
45
|
+
diagnostic = result.stderr.strip() or f"git merge-base exited {result.returncode}"
|
|
46
|
+
return None, diagnostic
|
|
47
|
+
candidates = result.stdout.splitlines()
|
|
48
|
+
if len(candidates) != 1:
|
|
49
|
+
return None, "Git did not return one merge base"
|
|
50
|
+
return candidates[0], None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def git_root(path: Path) -> Path | None:
|
|
54
|
+
"""Return repo root for `path`, or None if not inside a git repo."""
|
|
55
|
+
search = path if path.is_dir() else path.parent
|
|
56
|
+
while not search.exists() and search != search.parent:
|
|
57
|
+
search = search.parent
|
|
58
|
+
if not search.exists():
|
|
59
|
+
return None
|
|
60
|
+
try:
|
|
61
|
+
result = subprocess.run(
|
|
62
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
63
|
+
capture_output=True,
|
|
64
|
+
text=True,
|
|
65
|
+
cwd=search,
|
|
66
|
+
)
|
|
67
|
+
except FileNotFoundError:
|
|
68
|
+
return None
|
|
69
|
+
if result.returncode != 0:
|
|
70
|
+
return None
|
|
71
|
+
reported = Path(result.stdout.strip())
|
|
72
|
+
candidate = search
|
|
73
|
+
while True:
|
|
74
|
+
try:
|
|
75
|
+
if candidate.samefile(reported):
|
|
76
|
+
return candidate
|
|
77
|
+
except OSError:
|
|
78
|
+
pass
|
|
79
|
+
if candidate == candidate.parent:
|
|
80
|
+
break
|
|
81
|
+
candidate = candidate.parent
|
|
82
|
+
return reported
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def git_log(
|
|
86
|
+
repo: Path,
|
|
87
|
+
*,
|
|
88
|
+
since_days: int | None = None,
|
|
89
|
+
since_ref: str | None = None,
|
|
90
|
+
paths: list[str] | None = None,
|
|
91
|
+
max_count: int | None = None,
|
|
92
|
+
) -> Iterator[Commit]:
|
|
93
|
+
"""Stream commits from `repo`, newest first.
|
|
94
|
+
|
|
95
|
+
- `since_days`: restrict to commits within the last N days.
|
|
96
|
+
- `since_ref`: restrict to `<ref>..HEAD` (commits since a specific SHA/ref).
|
|
97
|
+
- `paths`: restrict to commits touching any of these paths.
|
|
98
|
+
- `max_count`: cap the number of commits (for lookups like "most recent one").
|
|
99
|
+
"""
|
|
100
|
+
root = git_root(repo)
|
|
101
|
+
if root is None:
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
pretty = f"--pretty=format:{_COMMIT_SENTINEL}%H{_FIELD_SEP}%an{_FIELD_SEP}%aI"
|
|
105
|
+
cmd = ["git", "log", "--name-only", pretty]
|
|
106
|
+
if max_count is not None:
|
|
107
|
+
cmd.append(f"-{max_count}")
|
|
108
|
+
if since_days is not None:
|
|
109
|
+
cmd.append(f"--since={since_days}.days.ago")
|
|
110
|
+
if since_ref:
|
|
111
|
+
cmd.append(f"{since_ref}..HEAD")
|
|
112
|
+
if paths:
|
|
113
|
+
cmd.append("--")
|
|
114
|
+
cmd.extend(paths)
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
result = subprocess.run(cmd, capture_output=True, text=True, cwd=root)
|
|
118
|
+
except FileNotFoundError:
|
|
119
|
+
return
|
|
120
|
+
if result.returncode != 0:
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
yield from _parse(result.stdout)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _parse(stdout: str) -> Iterator[Commit]:
|
|
127
|
+
sha = ""
|
|
128
|
+
author = ""
|
|
129
|
+
date = datetime.datetime.min.replace(tzinfo=datetime.UTC)
|
|
130
|
+
files: list[str] = []
|
|
131
|
+
have_header = False
|
|
132
|
+
|
|
133
|
+
for raw in stdout.splitlines():
|
|
134
|
+
if raw.startswith(_COMMIT_SENTINEL):
|
|
135
|
+
if have_header:
|
|
136
|
+
yield Commit(sha=sha, author=author, date=date, files=tuple(files))
|
|
137
|
+
header = raw[len(_COMMIT_SENTINEL) :]
|
|
138
|
+
parts = header.split(_FIELD_SEP)
|
|
139
|
+
if len(parts) < 3:
|
|
140
|
+
have_header = False
|
|
141
|
+
continue
|
|
142
|
+
sha, author, date_str = parts[0], parts[1], parts[2]
|
|
143
|
+
try:
|
|
144
|
+
date = datetime.datetime.fromisoformat(date_str)
|
|
145
|
+
except ValueError:
|
|
146
|
+
date = datetime.datetime.min.replace(tzinfo=datetime.UTC)
|
|
147
|
+
files = []
|
|
148
|
+
have_header = True
|
|
149
|
+
elif raw.strip() and have_header:
|
|
150
|
+
files.append(raw.strip())
|
|
151
|
+
|
|
152
|
+
if have_header:
|
|
153
|
+
yield Commit(sha=sha, author=author, date=date, files=tuple(files))
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""Byte-safe Git history and diff evidence parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import threading
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import NotRequired, TypedDict
|
|
12
|
+
|
|
13
|
+
_RECORD = b"\x1e"
|
|
14
|
+
_FIELD = "\x1f"
|
|
15
|
+
_DEFAULT_MAX_BYTES = 256 * 1024 * 1024
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class EvidenceBudget:
|
|
20
|
+
max_records: int = 100_000
|
|
21
|
+
max_paths: int = 1_000_000
|
|
22
|
+
max_bytes: int = _DEFAULT_MAX_BYTES
|
|
23
|
+
max_subprocesses: int = 10_000
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class EvidenceCounts:
|
|
28
|
+
records: int
|
|
29
|
+
paths: int
|
|
30
|
+
bytes: int
|
|
31
|
+
subprocesses: int = 1
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> dict[str, int]:
|
|
34
|
+
return {
|
|
35
|
+
"records": self.records,
|
|
36
|
+
"paths": self.paths,
|
|
37
|
+
"bytes": self.bytes,
|
|
38
|
+
"subprocesses": self.subprocesses,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CompletePathChange(TypedDict):
|
|
43
|
+
status: str
|
|
44
|
+
path: str
|
|
45
|
+
old_path: NotRequired[str]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CompleteHistoryRecord(TypedDict):
|
|
49
|
+
commit: str
|
|
50
|
+
date: str
|
|
51
|
+
subject: str
|
|
52
|
+
parents: list[str]
|
|
53
|
+
changes: list[CompletePathChange]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class HistoryRecord:
|
|
58
|
+
commit: str
|
|
59
|
+
date: str
|
|
60
|
+
subject: str
|
|
61
|
+
paths: tuple[str, ...]
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> dict[str, object]:
|
|
64
|
+
return {
|
|
65
|
+
"commit": self.commit,
|
|
66
|
+
"date": self.date,
|
|
67
|
+
"subject": self.subject,
|
|
68
|
+
"paths": list(self.paths),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class PathChange:
|
|
74
|
+
status: str
|
|
75
|
+
path: str
|
|
76
|
+
old_path: str | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def communicate_bounded(
|
|
80
|
+
process: subprocess.Popen[bytes],
|
|
81
|
+
*,
|
|
82
|
+
max_bytes: int,
|
|
83
|
+
timeout: float = 120,
|
|
84
|
+
) -> tuple[bytes, bytes, str | None]:
|
|
85
|
+
"""Drain stdout and stderr concurrently under one combined byte ceiling."""
|
|
86
|
+
assert process.stdout is not None and process.stderr is not None
|
|
87
|
+
stdout = bytearray()
|
|
88
|
+
stderr = bytearray()
|
|
89
|
+
lock = threading.Lock()
|
|
90
|
+
exceeded = threading.Event()
|
|
91
|
+
observed = 0
|
|
92
|
+
|
|
93
|
+
def drain(stream, sink: bytearray) -> None:
|
|
94
|
+
nonlocal observed
|
|
95
|
+
while chunk := stream.read(64 * 1024):
|
|
96
|
+
with lock:
|
|
97
|
+
observed += len(chunk)
|
|
98
|
+
if len(stdout) + len(stderr) <= max_bytes:
|
|
99
|
+
remaining = max_bytes + 1 - len(stdout) - len(stderr)
|
|
100
|
+
sink.extend(chunk[:remaining])
|
|
101
|
+
if observed > max_bytes and not exceeded.is_set():
|
|
102
|
+
exceeded.set()
|
|
103
|
+
process.kill()
|
|
104
|
+
|
|
105
|
+
threads = [
|
|
106
|
+
threading.Thread(target=drain, args=(process.stdout, stdout), daemon=True),
|
|
107
|
+
threading.Thread(target=drain, args=(process.stderr, stderr), daemon=True),
|
|
108
|
+
]
|
|
109
|
+
for thread in threads:
|
|
110
|
+
thread.start()
|
|
111
|
+
error: str | None = None
|
|
112
|
+
try:
|
|
113
|
+
process.wait(timeout=timeout)
|
|
114
|
+
except subprocess.TimeoutExpired:
|
|
115
|
+
process.kill()
|
|
116
|
+
error = "Git subprocess timed out"
|
|
117
|
+
for thread in threads:
|
|
118
|
+
thread.join(timeout=5)
|
|
119
|
+
if any(thread.is_alive() for thread in threads):
|
|
120
|
+
process.kill()
|
|
121
|
+
error = error or "Git subprocess streams did not close"
|
|
122
|
+
if exceeded.is_set():
|
|
123
|
+
error = "Git subprocess output exceeds the configured byte ceiling"
|
|
124
|
+
return bytes(stdout), bytes(stderr), error
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def parse_history_output(stdout: bytes) -> tuple[list[HistoryRecord], str | None]:
|
|
128
|
+
"""Parse NUL-delimited history without decoding repository paths as UTF-8."""
|
|
129
|
+
records: list[HistoryRecord] = []
|
|
130
|
+
header: tuple[str, str, str] | None = None
|
|
131
|
+
paths: list[str] = []
|
|
132
|
+
first_path = False
|
|
133
|
+
for raw_token in stdout.split(b"\0"):
|
|
134
|
+
token = raw_token
|
|
135
|
+
structural = token[1:] if token.startswith(b"\n" + _RECORD) else token
|
|
136
|
+
if structural.startswith(_RECORD):
|
|
137
|
+
if header is not None and paths:
|
|
138
|
+
records.append(HistoryRecord(*header, tuple(sorted(paths))))
|
|
139
|
+
fields = os.fsdecode(structural[len(_RECORD) :]).split(_FIELD, 2)
|
|
140
|
+
if len(fields) != 3:
|
|
141
|
+
return [], "git log returned malformed commit metadata"
|
|
142
|
+
header = (fields[0], fields[1][:10], fields[2])
|
|
143
|
+
paths = []
|
|
144
|
+
first_path = True
|
|
145
|
+
continue
|
|
146
|
+
if not token:
|
|
147
|
+
continue
|
|
148
|
+
if header is None:
|
|
149
|
+
return [], "git log returned a path before commit metadata"
|
|
150
|
+
if first_path and token.startswith(b"\n"):
|
|
151
|
+
token = token[1:]
|
|
152
|
+
first_path = False
|
|
153
|
+
if token:
|
|
154
|
+
paths.append(os.fsdecode(token))
|
|
155
|
+
if header is not None and paths:
|
|
156
|
+
records.append(HistoryRecord(*header, tuple(sorted(paths))))
|
|
157
|
+
return records, None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def read_history(
|
|
161
|
+
repo: Path,
|
|
162
|
+
*,
|
|
163
|
+
cutoff: str,
|
|
164
|
+
relative_path: str,
|
|
165
|
+
max_count: int,
|
|
166
|
+
) -> tuple[list[HistoryRecord], str | None]:
|
|
167
|
+
command = [
|
|
168
|
+
"git",
|
|
169
|
+
"log",
|
|
170
|
+
f"--max-count={max_count}",
|
|
171
|
+
"--format=%x1e%H%x1f%aI%x1f%s",
|
|
172
|
+
"--name-only",
|
|
173
|
+
"-z",
|
|
174
|
+
"--end-of-options",
|
|
175
|
+
cutoff,
|
|
176
|
+
"--",
|
|
177
|
+
]
|
|
178
|
+
if relative_path != ".":
|
|
179
|
+
command.append(relative_path)
|
|
180
|
+
try:
|
|
181
|
+
result = subprocess.run(command, cwd=repo, capture_output=True)
|
|
182
|
+
except FileNotFoundError:
|
|
183
|
+
return [], "Git is not installed or is not on PATH"
|
|
184
|
+
if result.returncode != 0:
|
|
185
|
+
diagnostic = os.fsdecode(result.stderr).strip()
|
|
186
|
+
return [], diagnostic or f"git log exited {result.returncode}"
|
|
187
|
+
return parse_history_output(result.stdout)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def parse_diff_output(stdout: bytes) -> tuple[list[PathChange], str | None]:
|
|
191
|
+
"""Parse `git diff --name-status -z` output into exact path changes."""
|
|
192
|
+
tokens = stdout.split(b"\0")
|
|
193
|
+
if tokens and tokens[-1] == b"":
|
|
194
|
+
tokens.pop()
|
|
195
|
+
changes: list[PathChange] = []
|
|
196
|
+
index = 0
|
|
197
|
+
while index < len(tokens):
|
|
198
|
+
raw_status = os.fsdecode(tokens[index])
|
|
199
|
+
index += 1
|
|
200
|
+
if not raw_status:
|
|
201
|
+
return [], "git diff returned an empty status field"
|
|
202
|
+
code = raw_status[:1]
|
|
203
|
+
path_count = 2 if code in {"R", "C"} else 1
|
|
204
|
+
if index + path_count > len(tokens):
|
|
205
|
+
return [], "git diff returned an incomplete NUL-delimited path record"
|
|
206
|
+
paths = [os.fsdecode(tokens[index + offset]) for offset in range(path_count)]
|
|
207
|
+
index += path_count
|
|
208
|
+
changes.append(
|
|
209
|
+
PathChange(
|
|
210
|
+
status=raw_status,
|
|
211
|
+
path=paths[-1],
|
|
212
|
+
old_path=paths[0] if path_count == 2 else None,
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
return changes, None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def read_diff(
|
|
219
|
+
repo: Path,
|
|
220
|
+
*,
|
|
221
|
+
base: str,
|
|
222
|
+
head: str,
|
|
223
|
+
) -> tuple[list[PathChange], str | None]:
|
|
224
|
+
command = ["git", "diff", "--name-status", "-z", "--find-renames", f"{base}...{head}", "--"]
|
|
225
|
+
try:
|
|
226
|
+
result = subprocess.run(command, cwd=repo, capture_output=True)
|
|
227
|
+
except FileNotFoundError:
|
|
228
|
+
return [], "Git is not installed or is not on PATH"
|
|
229
|
+
if result.returncode != 0:
|
|
230
|
+
diagnostic = os.fsdecode(result.stderr).strip()
|
|
231
|
+
return [], diagnostic or f"git diff exited {result.returncode}"
|
|
232
|
+
return parse_diff_output(result.stdout)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def parse_complete_history_output(
|
|
236
|
+
stdout: bytes,
|
|
237
|
+
*,
|
|
238
|
+
budget: EvidenceBudget,
|
|
239
|
+
) -> tuple[list[CompleteHistoryRecord], EvidenceCounts, str | None]:
|
|
240
|
+
"""Parse complete name-status history with explicit resource accounting."""
|
|
241
|
+
records: list[CompleteHistoryRecord] = []
|
|
242
|
+
current: CompleteHistoryRecord | None = None
|
|
243
|
+
path_count = 0
|
|
244
|
+
tokens = stdout.split(b"\0")
|
|
245
|
+
index = 0
|
|
246
|
+
while index < len(tokens):
|
|
247
|
+
token = os.fsdecode(tokens[index])
|
|
248
|
+
structural = token.lstrip("\r\n")
|
|
249
|
+
if structural.startswith("\x1e"):
|
|
250
|
+
if current is not None:
|
|
251
|
+
records.append(current)
|
|
252
|
+
if len(records) > budget.max_records:
|
|
253
|
+
counts = EvidenceCounts(len(records), path_count, len(stdout))
|
|
254
|
+
return [], counts, "history exceeds the configured record ceiling"
|
|
255
|
+
fields = structural[1:].split(_FIELD, 3)
|
|
256
|
+
current = None
|
|
257
|
+
if len(fields) != 4:
|
|
258
|
+
return (
|
|
259
|
+
[],
|
|
260
|
+
EvidenceCounts(len(records), path_count, len(stdout)),
|
|
261
|
+
("git log returned malformed commit metadata"),
|
|
262
|
+
)
|
|
263
|
+
current = {
|
|
264
|
+
"commit": fields[0],
|
|
265
|
+
"date": fields[1][:10],
|
|
266
|
+
"subject": fields[2],
|
|
267
|
+
"parents": fields[3].split() if fields[3] else [],
|
|
268
|
+
"changes": [],
|
|
269
|
+
}
|
|
270
|
+
index += 1
|
|
271
|
+
continue
|
|
272
|
+
status = structural
|
|
273
|
+
rename_or_copy = re.fullmatch(r"[RC][0-9]+", status)
|
|
274
|
+
tuple_paths = 2 if rename_or_copy else 1
|
|
275
|
+
if current is None or not re.fullmatch(r"(?:[RC][0-9]+|[ACDMRTUXB]+)", status):
|
|
276
|
+
index += 1
|
|
277
|
+
continue
|
|
278
|
+
if index + tuple_paths >= len(tokens):
|
|
279
|
+
return (
|
|
280
|
+
[],
|
|
281
|
+
EvidenceCounts(len(records), path_count, len(stdout)),
|
|
282
|
+
("git log returned an incomplete NUL-delimited path record"),
|
|
283
|
+
)
|
|
284
|
+
paths = [os.fsdecode(tokens[index + offset]) for offset in range(1, tuple_paths + 1)]
|
|
285
|
+
path_count += tuple_paths
|
|
286
|
+
if path_count > budget.max_paths:
|
|
287
|
+
counts = EvidenceCounts(len(records), path_count, len(stdout))
|
|
288
|
+
return [], counts, "history exceeds the configured path ceiling"
|
|
289
|
+
current["changes"].append(
|
|
290
|
+
{"status": status, "path": paths[0]}
|
|
291
|
+
if tuple_paths == 1
|
|
292
|
+
else {"status": status, "old_path": paths[0], "path": paths[1]}
|
|
293
|
+
)
|
|
294
|
+
index += tuple_paths + 1
|
|
295
|
+
if current is not None:
|
|
296
|
+
records.append(current)
|
|
297
|
+
counts = EvidenceCounts(len(records), path_count, len(stdout))
|
|
298
|
+
if counts.records > budget.max_records:
|
|
299
|
+
return [], counts, "history exceeds the configured record ceiling"
|
|
300
|
+
return records, counts, None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def read_complete_history(
|
|
304
|
+
repo: Path,
|
|
305
|
+
*,
|
|
306
|
+
cutoff: str,
|
|
307
|
+
budget: EvidenceBudget | None = None,
|
|
308
|
+
) -> tuple[list[CompleteHistoryRecord], EvidenceCounts, str | None]:
|
|
309
|
+
"""Read complete history while stopping before unbounded stdout allocation."""
|
|
310
|
+
active_budget = budget or EvidenceBudget()
|
|
311
|
+
command = [
|
|
312
|
+
"git",
|
|
313
|
+
"log",
|
|
314
|
+
"--reverse",
|
|
315
|
+
"--topo-order",
|
|
316
|
+
"--full-history",
|
|
317
|
+
"--diff-merges=combined",
|
|
318
|
+
"--find-renames=50%",
|
|
319
|
+
f"--format=\x1e%H{_FIELD}%aI{_FIELD}%s{_FIELD}%P",
|
|
320
|
+
"--name-status",
|
|
321
|
+
"-z",
|
|
322
|
+
"--end-of-options",
|
|
323
|
+
cutoff,
|
|
324
|
+
]
|
|
325
|
+
try:
|
|
326
|
+
process = subprocess.Popen(
|
|
327
|
+
command,
|
|
328
|
+
cwd=repo,
|
|
329
|
+
stdout=subprocess.PIPE,
|
|
330
|
+
stderr=subprocess.PIPE,
|
|
331
|
+
)
|
|
332
|
+
except OSError as exc:
|
|
333
|
+
return [], EvidenceCounts(0, 0, 0), f"could not run Git: {exc}"
|
|
334
|
+
stdout, stderr, transport_error = communicate_bounded(
|
|
335
|
+
process,
|
|
336
|
+
max_bytes=active_budget.max_bytes,
|
|
337
|
+
)
|
|
338
|
+
total_bytes = len(stdout) + len(stderr)
|
|
339
|
+
if transport_error is not None:
|
|
340
|
+
return (
|
|
341
|
+
[],
|
|
342
|
+
EvidenceCounts(0, 0, total_bytes),
|
|
343
|
+
(
|
|
344
|
+
"history exceeds the configured byte ceiling"
|
|
345
|
+
if "ceiling" in transport_error
|
|
346
|
+
else transport_error
|
|
347
|
+
),
|
|
348
|
+
)
|
|
349
|
+
if process.returncode != 0:
|
|
350
|
+
diagnostic = os.fsdecode(stderr).strip()
|
|
351
|
+
return (
|
|
352
|
+
[],
|
|
353
|
+
EvidenceCounts(0, 0, total_bytes),
|
|
354
|
+
(diagnostic or f"git log exited {process.returncode}"),
|
|
355
|
+
)
|
|
356
|
+
records, counts, error = parse_complete_history_output(stdout, budget=active_budget)
|
|
357
|
+
return records, EvidenceCounts(counts.records, counts.paths, total_bytes), error
|