file-policy 0.1.0__tar.gz
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.
- file_policy-0.1.0/PKG-INFO +59 -0
- file_policy-0.1.0/README.md +49 -0
- file_policy-0.1.0/file_policy/__init__.py +14 -0
- file_policy-0.1.0/file_policy/__main__.py +4 -0
- file_policy-0.1.0/file_policy/cache.py +28 -0
- file_policy-0.1.0/file_policy/cli.py +96 -0
- file_policy-0.1.0/file_policy/fixture_paths.py +50 -0
- file_policy-0.1.0/file_policy/fixtures.py +103 -0
- file_policy-0.1.0/file_policy/git_paths.py +144 -0
- file_policy-0.1.0/file_policy/mdl/__init__.py +3 -0
- file_policy-0.1.0/file_policy/mdl/common.py +184 -0
- file_policy-0.1.0/file_policy/mdl/dart.py +219 -0
- file_policy-0.1.0/file_policy/mdl/engine.py +177 -0
- file_policy-0.1.0/file_policy/mdl/rust.py +196 -0
- file_policy-0.1.0/file_policy/registry.py +100 -0
- file_policy-0.1.0/file_policy/rule_new.py +49 -0
- file_policy-0.1.0/file_policy/rule_test.py +108 -0
- file_policy-0.1.0/file_policy/runner.py +150 -0
- file_policy-0.1.0/file_policy/violations.py +37 -0
- file_policy-0.1.0/file_policy.egg-info/PKG-INFO +59 -0
- file_policy-0.1.0/file_policy.egg-info/SOURCES.txt +27 -0
- file_policy-0.1.0/file_policy.egg-info/dependency_links.txt +1 -0
- file_policy-0.1.0/file_policy.egg-info/requires.txt +6 -0
- file_policy-0.1.0/file_policy.egg-info/top_level.txt +1 -0
- file_policy-0.1.0/pyproject.toml +23 -0
- file_policy-0.1.0/setup.cfg +4 -0
- file_policy-0.1.0/tests/test_fixtures_toml.py +43 -0
- file_policy-0.1.0/tests/test_mdl_selftest.py +5 -0
- file_policy-0.1.0/tests/test_runner_smoke.py +104 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: file-policy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Diff-scoped file policy runner with rule packs
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: tomli>=2.0; python_version < "3.11"
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
10
|
+
|
|
11
|
+
# file_policy
|
|
12
|
+
|
|
13
|
+
Diff-scoped **file policy runner** for verification rule packs. One combined git changed-path pass, per-path read cache, TOML rule registry, JSON-lines violations, and exit codes for CI.
|
|
14
|
+
|
|
15
|
+
Rule pack corpora live in **agent-standards** (`file_policy/rule_packs/`) and are assembled into app repos as `file-policy/packs/`. This repo is the **engine only**.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
**Engine development:**
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install -e ".[dev]"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**App repos:** commit `file-policy/requirements.txt` with `file-policy==x.y.z`. Release: push tag `v*` → GitHub Actions publishes to **PyPI** (repo secret `PYPI_API_TOKEN`). `scripts/verify/file_policy.ps1` bootstraps `.venv-file-policy`.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install file-policy==0.1.0
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**One-time publish setup:** [PyPI](https://pypi.org) account → API token → GitHub repo secret **`PYPI_API_TOKEN`** → push tag **`v0.1.0`** (or next version). **GitHub repo can stay private**; the PyPI wheel is public and needs no install auth.
|
|
32
|
+
|
|
33
|
+
## CLI
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
python -m file_policy run --repo ROOT --packs PACKS_DIR [--git-base BASE] [--scope changed|full] [--verbose]
|
|
37
|
+
python -m file_policy rule test SLUG --repo ROOT --packs PACKS_DIR
|
|
38
|
+
python -m file_policy rule new SLUG --packs PACKS_DIR [--kind per-file|multi-file]
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Exit codes: **0** clean, **1** violations, **2** usage/config errors.
|
|
42
|
+
|
|
43
|
+
## Rule pack layout
|
|
44
|
+
|
|
45
|
+
Each pack is a directory under `PACKS_DIR` (see agent-standards `file_policy/rule_packs/<slug>/`).
|
|
46
|
+
|
|
47
|
+
## MDL (minimal data loading)
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from file_policy import run_minimal_data_loading
|
|
51
|
+
|
|
52
|
+
violations = run_minimal_data_loading(repo_root, config_dict, git_base)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Tests
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
python -m pytest tests/ -q
|
|
59
|
+
```
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# file_policy
|
|
2
|
+
|
|
3
|
+
Diff-scoped **file policy runner** for verification rule packs. One combined git changed-path pass, per-path read cache, TOML rule registry, JSON-lines violations, and exit codes for CI.
|
|
4
|
+
|
|
5
|
+
Rule pack corpora live in **agent-standards** (`file_policy/rule_packs/`) and are assembled into app repos as `file-policy/packs/`. This repo is the **engine only**.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
**Engine development:**
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install -e ".[dev]"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
**App repos:** commit `file-policy/requirements.txt` with `file-policy==x.y.z`. Release: push tag `v*` → GitHub Actions publishes to **PyPI** (repo secret `PYPI_API_TOKEN`). `scripts/verify/file_policy.ps1` bootstraps `.venv-file-policy`.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install file-policy==0.1.0
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**One-time publish setup:** [PyPI](https://pypi.org) account → API token → GitHub repo secret **`PYPI_API_TOKEN`** → push tag **`v0.1.0`** (or next version). **GitHub repo can stay private**; the PyPI wheel is public and needs no install auth.
|
|
22
|
+
|
|
23
|
+
## CLI
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
python -m file_policy run --repo ROOT --packs PACKS_DIR [--git-base BASE] [--scope changed|full] [--verbose]
|
|
27
|
+
python -m file_policy rule test SLUG --repo ROOT --packs PACKS_DIR
|
|
28
|
+
python -m file_policy rule new SLUG --packs PACKS_DIR [--kind per-file|multi-file]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Exit codes: **0** clean, **1** violations, **2** usage/config errors.
|
|
32
|
+
|
|
33
|
+
## Rule pack layout
|
|
34
|
+
|
|
35
|
+
Each pack is a directory under `PACKS_DIR` (see agent-standards `file_policy/rule_packs/<slug>/`).
|
|
36
|
+
|
|
37
|
+
## MDL (minimal data loading)
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from file_policy import run_minimal_data_loading
|
|
41
|
+
|
|
42
|
+
violations = run_minimal_data_loading(repo_root, config_dict, git_base)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Tests
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
python -m pytest tests/ -q
|
|
49
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Diff-scoped file policy runner."""
|
|
2
|
+
|
|
3
|
+
from file_policy.violations import Violation, violation_to_json_line
|
|
4
|
+
from file_policy.runner import run_policy
|
|
5
|
+
from file_policy.registry import load_rule_packs
|
|
6
|
+
from file_policy.mdl.engine import run_minimal_data_loading
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Violation",
|
|
10
|
+
"violation_to_json_line",
|
|
11
|
+
"run_policy",
|
|
12
|
+
"load_rule_packs",
|
|
13
|
+
"run_minimal_data_loading",
|
|
14
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FileCache:
|
|
7
|
+
"""Read each repo-relative path at most once."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, repo_root: str | Path) -> None:
|
|
10
|
+
self.repo_root = Path(repo_root).resolve()
|
|
11
|
+
self._text: dict[str, str] = {}
|
|
12
|
+
self._lines: dict[str, list[str]] = {}
|
|
13
|
+
|
|
14
|
+
def read_text(self, relative_path: str) -> str:
|
|
15
|
+
key = relative_path.replace("\\", "/")
|
|
16
|
+
if key not in self._text:
|
|
17
|
+
full = self.repo_root / key
|
|
18
|
+
self._text[key] = full.read_text(encoding="utf-8", errors="replace")
|
|
19
|
+
return self._text[key]
|
|
20
|
+
|
|
21
|
+
def read_lines(self, relative_path: str) -> list[str]:
|
|
22
|
+
key = relative_path.replace("\\", "/")
|
|
23
|
+
if key not in self._lines:
|
|
24
|
+
self._lines[key] = self.read_text(key).splitlines()
|
|
25
|
+
return self._lines[key]
|
|
26
|
+
|
|
27
|
+
def paths_loaded(self) -> frozenset[str]:
|
|
28
|
+
return frozenset(self._text.keys())
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from file_policy.rule_new import scaffold_rule
|
|
7
|
+
from file_policy.rule_test import run_rule_test
|
|
8
|
+
from file_policy.runner import run_policy
|
|
9
|
+
from file_policy.violations import emit_violations
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
13
|
+
parser = argparse.ArgumentParser(prog="file_policy")
|
|
14
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
15
|
+
|
|
16
|
+
run_p = sub.add_parser("run", help="Run rule packs against a repo")
|
|
17
|
+
run_p.add_argument("--repo", required=True, help="Repository root")
|
|
18
|
+
run_p.add_argument("--packs", required=True, help="Rule packs directory")
|
|
19
|
+
run_p.add_argument("--git-base", default=None, help="Git base ref for diff scope")
|
|
20
|
+
run_p.add_argument(
|
|
21
|
+
"--scope",
|
|
22
|
+
choices=["changed", "full"],
|
|
23
|
+
default="changed",
|
|
24
|
+
help="File scope (default: changed)",
|
|
25
|
+
)
|
|
26
|
+
run_p.add_argument("--verbose", action="store_true")
|
|
27
|
+
|
|
28
|
+
rule_p = sub.add_parser("rule", help="Rule pack utilities")
|
|
29
|
+
rule_sub = rule_p.add_subparsers(dest="rule_command", required=True)
|
|
30
|
+
|
|
31
|
+
test_p = rule_sub.add_parser("test", help="Test a rule pack fixtures")
|
|
32
|
+
test_p.add_argument("slug", help="Rule slug")
|
|
33
|
+
test_p.add_argument("--repo", required=True)
|
|
34
|
+
test_p.add_argument("--packs", required=True)
|
|
35
|
+
|
|
36
|
+
new_p = rule_sub.add_parser("new", help="Scaffold a new rule pack")
|
|
37
|
+
new_p.add_argument("slug", help="Rule slug")
|
|
38
|
+
new_p.add_argument("--packs", required=True)
|
|
39
|
+
new_p.add_argument(
|
|
40
|
+
"--kind",
|
|
41
|
+
choices=["per-file", "multi-file"],
|
|
42
|
+
default="per-file",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
return parser
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main(argv: list[str] | None = None) -> int:
|
|
49
|
+
parser = _build_parser()
|
|
50
|
+
args = parser.parse_args(argv)
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
if args.command == "run":
|
|
54
|
+
if args.scope == "changed" and not args.git_base:
|
|
55
|
+
print(
|
|
56
|
+
"warning: --git-base not set; changed scope yields no files",
|
|
57
|
+
file=sys.stderr,
|
|
58
|
+
)
|
|
59
|
+
violations = run_policy(
|
|
60
|
+
args.repo,
|
|
61
|
+
args.packs,
|
|
62
|
+
git_base=args.git_base,
|
|
63
|
+
scope=args.scope,
|
|
64
|
+
)
|
|
65
|
+
if args.verbose:
|
|
66
|
+
print(
|
|
67
|
+
f"file_policy: {len(violations)} violation(s)",
|
|
68
|
+
file=sys.stderr,
|
|
69
|
+
)
|
|
70
|
+
emit_violations(violations)
|
|
71
|
+
return 1 if violations else 0
|
|
72
|
+
|
|
73
|
+
if args.command == "rule":
|
|
74
|
+
if args.rule_command == "test":
|
|
75
|
+
errors = run_rule_test(args.slug, args.repo, args.packs)
|
|
76
|
+
if errors:
|
|
77
|
+
for e in errors:
|
|
78
|
+
print(e, file=sys.stderr)
|
|
79
|
+
return 1
|
|
80
|
+
print(f"rule test {args.slug}: passed")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
if args.rule_command == "new":
|
|
84
|
+
path = scaffold_rule(args.slug, args.packs, kind=args.kind)
|
|
85
|
+
print(f"created {path}")
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
parser.print_help()
|
|
89
|
+
return 2
|
|
90
|
+
except (FileNotFoundError, KeyError, ValueError, AttributeError) as exc:
|
|
91
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
92
|
+
return 2
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
FIXTURE_TIERS = frozenset({"bad", "good"})
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def fixture_virtual_rel(tier: str, language: str, case_id: str) -> str:
|
|
9
|
+
"""Synthetic path for rule-test snippets (not a repo file path)."""
|
|
10
|
+
if tier not in FIXTURE_TIERS:
|
|
11
|
+
raise ValueError(f"fixture tier must be bad|good, got {tier!r}")
|
|
12
|
+
return f"{tier}/{language}/{case_id}"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def fixture_repo_relpath(language: str, case_id: str) -> Path:
|
|
16
|
+
"""Where a snippet is written in a temp repo so MDL surfaces pick it up."""
|
|
17
|
+
if language == "dart":
|
|
18
|
+
return Path("lib/features/fixture") / f"{case_id}_repository.dart"
|
|
19
|
+
if language == "rust":
|
|
20
|
+
return Path("api/src/db") / f"{case_id}.rs"
|
|
21
|
+
raise ValueError(f"unsupported fixture language: {language!r}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_fixture_virtual_path(path: str) -> bool:
|
|
25
|
+
norm = path.replace("\\", "/")
|
|
26
|
+
parts = norm.split("/")
|
|
27
|
+
if len(parts) != 3:
|
|
28
|
+
return False
|
|
29
|
+
tier, language, case_id = parts
|
|
30
|
+
if tier not in FIXTURE_TIERS or not case_id:
|
|
31
|
+
return False
|
|
32
|
+
return language in ("dart", "rust")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def fixture_repo_path_from_virtual(fixture_rel: str) -> Path:
|
|
36
|
+
norm = fixture_rel.replace("\\", "/")
|
|
37
|
+
if is_fixture_virtual_path(norm):
|
|
38
|
+
_tier, language, case_id = norm.split("/", 2)
|
|
39
|
+
return fixture_repo_relpath(language, case_id)
|
|
40
|
+
return _legacy_fixture_repo_path(norm)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _legacy_fixture_repo_path(fixture_rel: str) -> Path:
|
|
44
|
+
"""bad/foo.dart layout (deprecated)."""
|
|
45
|
+
name = Path(fixture_rel).name
|
|
46
|
+
if name.endswith("_repository.dart") or name.endswith(".dart"):
|
|
47
|
+
return Path("lib/features/fixture") / name.replace(".dart", "_repository.dart")
|
|
48
|
+
if name.endswith(".rs"):
|
|
49
|
+
return Path("api/src/db") / name
|
|
50
|
+
raise ValueError(f"unsupported fixture path: {fixture_rel}")
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class FixtureCase:
|
|
12
|
+
id: str
|
|
13
|
+
language: str
|
|
14
|
+
tier: str # "bad" | "good"
|
|
15
|
+
source: str
|
|
16
|
+
message_contains: tuple[str, ...] = ()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _load_toml(path: Path) -> dict[str, Any]:
|
|
20
|
+
text = path.read_text(encoding="utf-8")
|
|
21
|
+
if sys.version_info >= (3, 11):
|
|
22
|
+
import tomllib
|
|
23
|
+
|
|
24
|
+
return tomllib.loads(text)
|
|
25
|
+
import tomli
|
|
26
|
+
|
|
27
|
+
return tomli.loads(text)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_pack_languages(pack_dir: Path) -> frozenset[str]:
|
|
31
|
+
"""Languages this rule applies to (meta.toml, else config.json surfaces)."""
|
|
32
|
+
meta_path = pack_dir / "meta.toml"
|
|
33
|
+
if meta_path.is_file():
|
|
34
|
+
raw = _load_toml(meta_path)
|
|
35
|
+
langs = raw.get("languages")
|
|
36
|
+
if isinstance(langs, list) and all(isinstance(x, str) for x in langs):
|
|
37
|
+
return frozenset(langs)
|
|
38
|
+
|
|
39
|
+
config_path = pack_dir / "config.json"
|
|
40
|
+
if config_path.is_file():
|
|
41
|
+
cfg = json.loads(config_path.read_text(encoding="utf-8"))
|
|
42
|
+
from_surfaces: set[str] = set()
|
|
43
|
+
for surface in cfg.get("surfaces") or []:
|
|
44
|
+
lang = surface.get("language")
|
|
45
|
+
if isinstance(lang, str) and lang:
|
|
46
|
+
from_surfaces.add(lang)
|
|
47
|
+
if from_surfaces:
|
|
48
|
+
return frozenset(from_surfaces)
|
|
49
|
+
|
|
50
|
+
return frozenset()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_fixture_cases(pack_dir: Path) -> list[FixtureCase]:
|
|
54
|
+
path = pack_dir / "fixtures.toml"
|
|
55
|
+
if not path.is_file():
|
|
56
|
+
return []
|
|
57
|
+
raw = _load_toml(path)
|
|
58
|
+
rows = raw.get("case")
|
|
59
|
+
if rows is None:
|
|
60
|
+
return []
|
|
61
|
+
if not isinstance(rows, list):
|
|
62
|
+
raise ValueError(f"fixtures.toml 'case' must be an array in {path}")
|
|
63
|
+
|
|
64
|
+
allowed = load_pack_languages(pack_dir)
|
|
65
|
+
cases: list[FixtureCase] = []
|
|
66
|
+
for i, row in enumerate(rows):
|
|
67
|
+
if not isinstance(row, dict):
|
|
68
|
+
raise ValueError(f"fixtures.toml case[{i}] must be a table in {path}")
|
|
69
|
+
tier = row.get("tier")
|
|
70
|
+
case_id = row.get("id")
|
|
71
|
+
language = row.get("language")
|
|
72
|
+
source = row.get("source")
|
|
73
|
+
if tier not in ("bad", "good"):
|
|
74
|
+
raise ValueError(f"fixtures.toml case[{i}].tier must be bad|good")
|
|
75
|
+
if not case_id or not isinstance(case_id, str):
|
|
76
|
+
raise ValueError(f"fixtures.toml case[{i}].id required")
|
|
77
|
+
if not language or not isinstance(language, str):
|
|
78
|
+
raise ValueError(f"fixtures.toml case[{i}].language required")
|
|
79
|
+
if allowed and language not in allowed:
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"fixtures.toml case[{i}].language {language!r} not in pack languages "
|
|
82
|
+
f"{sorted(allowed)!r} (meta.toml languages or config surfaces)"
|
|
83
|
+
)
|
|
84
|
+
if source is None or not isinstance(source, str):
|
|
85
|
+
raise ValueError(f"fixtures.toml case[{i}].source required")
|
|
86
|
+
msgs = row.get("message_contains", [])
|
|
87
|
+
if msgs is None:
|
|
88
|
+
msgs = []
|
|
89
|
+
if not isinstance(msgs, list):
|
|
90
|
+
raise ValueError(f"fixtures.toml case[{i}].message_contains must be a list")
|
|
91
|
+
body = source.strip()
|
|
92
|
+
if body and not body.endswith("\n"):
|
|
93
|
+
body += "\n"
|
|
94
|
+
cases.append(
|
|
95
|
+
FixtureCase(
|
|
96
|
+
id=case_id,
|
|
97
|
+
language=language,
|
|
98
|
+
tier=tier,
|
|
99
|
+
source=body,
|
|
100
|
+
message_contains=tuple(str(m) for m in msgs),
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
return cases
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Single combined git pass for changed paths (merge-base diff triplets + untracked)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _normalize_repo_path(path: str) -> str:
|
|
10
|
+
return path.replace("\\", "/").strip()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _path_filter_suffix(git_path: str) -> list[str]:
|
|
14
|
+
"""Git path limiter; omit trailing empty path (breaks `git diff`)."""
|
|
15
|
+
if git_path:
|
|
16
|
+
return ["--", git_path]
|
|
17
|
+
return []
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _invoke_git(repo_root: Path, args: list[str]) -> list[str]:
|
|
21
|
+
try:
|
|
22
|
+
result = subprocess.run(
|
|
23
|
+
["git", *args],
|
|
24
|
+
cwd=repo_root,
|
|
25
|
+
capture_output=True,
|
|
26
|
+
text=True,
|
|
27
|
+
check=False,
|
|
28
|
+
)
|
|
29
|
+
except OSError:
|
|
30
|
+
return []
|
|
31
|
+
if result.returncode != 0:
|
|
32
|
+
return []
|
|
33
|
+
lines: list[str] = []
|
|
34
|
+
for line in result.stdout.splitlines():
|
|
35
|
+
line = line.strip()
|
|
36
|
+
if line:
|
|
37
|
+
lines.append(line)
|
|
38
|
+
return lines
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def git_changed_files(
|
|
42
|
+
repo: str | Path,
|
|
43
|
+
base: str | None,
|
|
44
|
+
*,
|
|
45
|
+
scope: str = "changed",
|
|
46
|
+
path_prefix: str = "",
|
|
47
|
+
include_regex: str | None = None,
|
|
48
|
+
) -> set[str]:
|
|
49
|
+
"""
|
|
50
|
+
Return normalized repo-relative paths changed vs base.
|
|
51
|
+
|
|
52
|
+
scope:
|
|
53
|
+
- ``changed``: diff vs base (three diff passes) + untracked under prefix
|
|
54
|
+
- ``full``: all tracked + untracked under prefix (ignores base for listing)
|
|
55
|
+
"""
|
|
56
|
+
import re
|
|
57
|
+
|
|
58
|
+
repo_root = Path(repo).resolve()
|
|
59
|
+
prefix = _normalize_repo_path(path_prefix).rstrip("/")
|
|
60
|
+
git_path = f"{prefix}/" if prefix else ""
|
|
61
|
+
|
|
62
|
+
if scope == "full":
|
|
63
|
+
files: set[str] = set()
|
|
64
|
+
ls_args = ["ls-files", *([git_path] if git_path else [])]
|
|
65
|
+
for line in _invoke_git(repo_root, ls_args):
|
|
66
|
+
norm = _normalize_repo_path(line)
|
|
67
|
+
if include_regex and not re.search(include_regex, norm):
|
|
68
|
+
continue
|
|
69
|
+
files.add(norm)
|
|
70
|
+
untracked_args = ["ls-files", "--others", "--exclude-standard"]
|
|
71
|
+
if git_path:
|
|
72
|
+
untracked_args.append(git_path)
|
|
73
|
+
for line in _invoke_git(repo_root, untracked_args):
|
|
74
|
+
norm = _normalize_repo_path(line)
|
|
75
|
+
if include_regex and not re.search(include_regex, norm):
|
|
76
|
+
continue
|
|
77
|
+
files.add(norm)
|
|
78
|
+
return files
|
|
79
|
+
|
|
80
|
+
if scope != "changed":
|
|
81
|
+
raise ValueError(f"unknown scope: {scope!r}")
|
|
82
|
+
|
|
83
|
+
files_set: set[str] = set()
|
|
84
|
+
if not base:
|
|
85
|
+
return files_set
|
|
86
|
+
|
|
87
|
+
suffix = _path_filter_suffix(git_path)
|
|
88
|
+
diff_arg_sets = [
|
|
89
|
+
["diff", "--name-only", f"{base}...HEAD", *suffix],
|
|
90
|
+
["diff", "--name-only", "HEAD", *suffix],
|
|
91
|
+
["diff", "--cached", "--name-only", *suffix],
|
|
92
|
+
]
|
|
93
|
+
for args in diff_arg_sets:
|
|
94
|
+
for line in _invoke_git(repo_root, args):
|
|
95
|
+
norm = _normalize_repo_path(line)
|
|
96
|
+
if include_regex and not re.search(include_regex, norm):
|
|
97
|
+
continue
|
|
98
|
+
files_set.add(norm)
|
|
99
|
+
|
|
100
|
+
untracked_args = ["ls-files", "--others", "--exclude-standard"]
|
|
101
|
+
if git_path:
|
|
102
|
+
untracked_args.append(git_path)
|
|
103
|
+
for line in _invoke_git(repo_root, untracked_args):
|
|
104
|
+
norm = _normalize_repo_path(line)
|
|
105
|
+
if include_regex and not re.search(include_regex, norm):
|
|
106
|
+
continue
|
|
107
|
+
files_set.add(norm)
|
|
108
|
+
|
|
109
|
+
return files_set
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def git_changed_line_numbers(
|
|
113
|
+
repo: str | Path,
|
|
114
|
+
base: str | None,
|
|
115
|
+
relative_path: str,
|
|
116
|
+
) -> set[int]:
|
|
117
|
+
"""Line numbers (1-based) changed in relative_path vs base (three diff passes)."""
|
|
118
|
+
import re
|
|
119
|
+
|
|
120
|
+
repo_root = Path(repo).resolve()
|
|
121
|
+
lines_set: set[int] = set()
|
|
122
|
+
if not base:
|
|
123
|
+
return lines_set
|
|
124
|
+
|
|
125
|
+
path_arg = _normalize_repo_path(relative_path)
|
|
126
|
+
hunk_re = re.compile(r"^@@\s+-[0-9,]+\s+\+([0-9]+)(?:,([0-9]+))?\s+@@")
|
|
127
|
+
|
|
128
|
+
for args in (
|
|
129
|
+
["diff", "-U0", f"{base}...HEAD", "--", path_arg],
|
|
130
|
+
["diff", "-U0", "HEAD", "--", path_arg],
|
|
131
|
+
["diff", "--cached", "-U0", "--", path_arg],
|
|
132
|
+
):
|
|
133
|
+
for line in _invoke_git(repo_root, args):
|
|
134
|
+
m = hunk_re.match(line)
|
|
135
|
+
if not m:
|
|
136
|
+
continue
|
|
137
|
+
start = int(m.group(1))
|
|
138
|
+
count = int(m.group(2)) if m.group(2) else 1
|
|
139
|
+
if count <= 0:
|
|
140
|
+
continue
|
|
141
|
+
for n in range(start, start + count):
|
|
142
|
+
lines_set.add(n)
|
|
143
|
+
|
|
144
|
+
return lines_set
|