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.
@@ -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,4 @@
1
+ from file_policy.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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
@@ -0,0 +1,3 @@
1
+ from file_policy.mdl.engine import run_minimal_data_loading
2
+
3
+ __all__ = ["run_minimal_data_loading"]