refactorai-core 3.0.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.
- refactorai_core-3.0.0/PKG-INFO +27 -0
- refactorai_core-3.0.0/README.md +13 -0
- refactorai_core-3.0.0/pyproject.toml +53 -0
- refactorai_core-3.0.0/refactor_core/__init__.py +27 -0
- refactorai_core-3.0.0/refactor_core/apply.py +252 -0
- refactorai_core-3.0.0/refactor_core/budgeting.py +153 -0
- refactorai_core-3.0.0/refactor_core/capabilities.py +56 -0
- refactorai_core-3.0.0/refactor_core/compliance/__init__.py +34 -0
- refactorai_core-3.0.0/refactor_core/compliance/detectors.py +109 -0
- refactorai_core-3.0.0/refactor_core/compliance/evidence.py +45 -0
- refactorai_core-3.0.0/refactor_core/compliance/loader.py +233 -0
- refactorai_core-3.0.0/refactor_core/compliance/models.py +68 -0
- refactorai_core-3.0.0/refactor_core/compliance/packs/__init__.py +2 -0
- refactorai_core-3.0.0/refactor_core/compliance/packs/hipaa-ephi.json +55 -0
- refactorai_core-3.0.0/refactor_core/compliance/packs/iso27001-sdlc.json +62 -0
- refactorai_core-3.0.0/refactor_core/compliance/packs/pci-payments.json +63 -0
- refactorai_core-3.0.0/refactor_core/compliance/packs/privacy-gdpr.json +71 -0
- refactorai_core-3.0.0/refactor_core/compliance/packs/soc2-core.json +63 -0
- refactorai_core-3.0.0/refactor_core/compliance/projection.py +252 -0
- refactorai_core-3.0.0/refactor_core/compliance_settings.py +173 -0
- refactorai_core-3.0.0/refactor_core/constitution.py +354 -0
- refactorai_core-3.0.0/refactor_core/gate.py +48 -0
- refactorai_core-3.0.0/refactor_core/indexing.py +225 -0
- refactorai_core-3.0.0/refactor_core/model_catalog.py +127 -0
- refactorai_core-3.0.0/refactor_core/models.py +69 -0
- refactorai_core-3.0.0/refactor_core/op_classifier.py +243 -0
- refactorai_core-3.0.0/refactor_core/op_feasibility.py +237 -0
- refactorai_core-3.0.0/refactor_core/paths.py +47 -0
- refactorai_core-3.0.0/refactor_core/refactor_ops.py +152 -0
- refactorai_core-3.0.0/refactor_core/refactor_requests.py +858 -0
- refactorai_core-3.0.0/refactor_core/rules/__init__.py +19 -0
- refactorai_core-3.0.0/refactor_core/rules/injection.py +124 -0
- refactorai_core-3.0.0/refactor_core/rules/loader.py +91 -0
- refactorai_core-3.0.0/refactor_core/rules/matcher.py +41 -0
- refactorai_core-3.0.0/refactor_core/rules/models.py +46 -0
- refactorai_core-3.0.0/refactor_core/rules/resolver.py +150 -0
- refactorai_core-3.0.0/refactor_core/rules/system_rules.json +170 -0
- refactorai_core-3.0.0/refactor_core/security/__init__.py +35 -0
- refactorai_core-3.0.0/refactor_core/security/finding_merge.py +159 -0
- refactorai_core-3.0.0/refactor_core/security/semgrep_parser.py +129 -0
- refactorai_core-3.0.0/refactor_core/security/semgrep_runner.py +360 -0
- refactorai_core-3.0.0/refactor_core/security/suppression.py +88 -0
- refactorai_core-3.0.0/refactor_core/store.py +354 -0
- refactorai_core-3.0.0/refactor_core/testcmd.py +64 -0
- refactorai_core-3.0.0/refactorai_core.egg-info/PKG-INFO +27 -0
- refactorai_core-3.0.0/refactorai_core.egg-info/SOURCES.txt +48 -0
- refactorai_core-3.0.0/refactorai_core.egg-info/dependency_links.txt +1 -0
- refactorai_core-3.0.0/refactorai_core.egg-info/requires.txt +8 -0
- refactorai_core-3.0.0/refactorai_core.egg-info/top_level.txt +1 -0
- refactorai_core-3.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: refactorai-core
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: Public client core for the refactor platform: indexing, constitution parsing, models, gate evaluation, rules, security prefilter, RR/compliance classification. Contains no proprietary inference engine.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: pydantic>=2.8.2
|
|
8
|
+
Requires-Dist: PyYAML>=6.0.1
|
|
9
|
+
Requires-Dist: httpx>=0.27.0
|
|
10
|
+
Requires-Dist: semgrep>=1.168.0
|
|
11
|
+
Provides-Extra: intelligence
|
|
12
|
+
Requires-Dist: tree-sitter==0.21.3; extra == "intelligence"
|
|
13
|
+
Requires-Dist: tree-sitter-languages==1.10.2; extra == "intelligence"
|
|
14
|
+
|
|
15
|
+
# refactorai-core
|
|
16
|
+
|
|
17
|
+
Public client core library for Refactor AI.
|
|
18
|
+
|
|
19
|
+
This package contains client-safe components used by `refactorai-cli`, including:
|
|
20
|
+
|
|
21
|
+
- constitution/config parsing
|
|
22
|
+
- document indexing and rule layering
|
|
23
|
+
- data models and gate evaluation
|
|
24
|
+
- security prefilter helpers
|
|
25
|
+
- Refactor Request (RR) classification and local persistence helpers
|
|
26
|
+
|
|
27
|
+
It intentionally excludes the proprietary engine runtime.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# refactorai-core
|
|
2
|
+
|
|
3
|
+
Public client core library for Refactor AI.
|
|
4
|
+
|
|
5
|
+
This package contains client-safe components used by `refactorai-cli`, including:
|
|
6
|
+
|
|
7
|
+
- constitution/config parsing
|
|
8
|
+
- document indexing and rule layering
|
|
9
|
+
- data models and gate evaluation
|
|
10
|
+
- security prefilter helpers
|
|
11
|
+
- Refactor Request (RR) classification and local persistence helpers
|
|
12
|
+
|
|
13
|
+
It intentionally excludes the proprietary engine runtime.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "refactorai-core"
|
|
3
|
+
version = "3.0.0"
|
|
4
|
+
description = "Public client core for the refactor platform: indexing, constitution parsing, models, gate evaluation, rules, security prefilter, RR/compliance classification. Contains no proprietary inference engine."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"pydantic>=2.8.2",
|
|
9
|
+
"PyYAML>=6.0.1",
|
|
10
|
+
"httpx>=0.27.0",
|
|
11
|
+
"semgrep>=1.168.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
# Code intelligence grammars are only needed by the engine (codeintel), but the
|
|
16
|
+
# extra is kept here for backward-compatible install names.
|
|
17
|
+
intelligence = [
|
|
18
|
+
"tree-sitter==0.21.3",
|
|
19
|
+
"tree-sitter-languages==1.10.2",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["setuptools>=68", "wheel"]
|
|
24
|
+
build-backend = "setuptools.build_meta"
|
|
25
|
+
|
|
26
|
+
# R21 core split: the client distribution ships ONLY client-safe modules. The
|
|
27
|
+
# proprietary engine (providers, pipeline prompts, execution run loop, codeintel,
|
|
28
|
+
# sandbox/engine runtime) is shipped separately in ``refactor-core-engine`` and
|
|
29
|
+
# is never present on a pip/cloud user's disk. Both distributions install into
|
|
30
|
+
# the same ``refactor_core`` import package. All engine code lives in dedicated
|
|
31
|
+
# subpackages (``enginecore``, ``execution``, ``providers``, ``codeintel``); the
|
|
32
|
+
# top level of ``refactor_core`` contains only client-safe modules, so listing
|
|
33
|
+
# ``refactor_core`` as a package here does NOT drag in any engine ``.py``.
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
include = [
|
|
36
|
+
"refactor_core",
|
|
37
|
+
"refactor_core.rules",
|
|
38
|
+
"refactor_core.security",
|
|
39
|
+
"refactor_core.compliance",
|
|
40
|
+
"refactor_core.compliance.packs",
|
|
41
|
+
]
|
|
42
|
+
# Defensive: never ship engine subpackages in the public client wheel even if a
|
|
43
|
+
# future refactor accidentally makes one discoverable.
|
|
44
|
+
exclude = [
|
|
45
|
+
"refactor_core.enginecore*",
|
|
46
|
+
"refactor_core.execution*",
|
|
47
|
+
"refactor_core.providers*",
|
|
48
|
+
"refactor_core.codeintel*",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[tool.setuptools.package-data]
|
|
52
|
+
"refactor_core.rules" = ["*.json"]
|
|
53
|
+
"refactor_core.compliance.packs" = ["*.json"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Shared core for the refactor platform.
|
|
2
|
+
|
|
3
|
+
This package is the single source of truth for code indexing, pipeline data
|
|
4
|
+
contracts, quality-gate evaluation, constitution parsing, and central-store
|
|
5
|
+
path resolution. It is imported by both the CLI (`refactor`) and, going
|
|
6
|
+
forward, the server-side worker so local and server runs stay in parity
|
|
7
|
+
(see docs/15-local-cli-and-constitution-spec.md, ADR-204).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from refactor_core.models import (
|
|
11
|
+
Finding,
|
|
12
|
+
GateResult,
|
|
13
|
+
GeneratedTest,
|
|
14
|
+
PatchGroup,
|
|
15
|
+
RunArtifact,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Finding",
|
|
20
|
+
"GateResult",
|
|
21
|
+
"GeneratedTest",
|
|
22
|
+
"PatchGroup",
|
|
23
|
+
"RunArtifact",
|
|
24
|
+
"__version__",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
__version__ = "3.0.0"
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Unified-diff parsing and atomic in-place application with backup/revert.
|
|
2
|
+
|
|
3
|
+
Patches are produced as unified diffs (the run artifact). Applying them is
|
|
4
|
+
atomic: all target files are computed first (raising on any mismatch), the
|
|
5
|
+
originals are snapshotted into the run's ``backup/`` directory, then the new
|
|
6
|
+
contents are written. ``revert`` restores from that snapshot.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import difflib
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def make_unified_diff(path: str, original_text: str, new_text: str) -> str:
|
|
21
|
+
"""Build a unified diff for a single file (consistent with the applier)."""
|
|
22
|
+
a = original_text.split("\n")
|
|
23
|
+
b = new_text.split("\n")
|
|
24
|
+
diff = difflib.unified_diff(a, b, fromfile=f"a/{path}", tofile=f"b/{path}", lineterm="")
|
|
25
|
+
return "\n".join(diff)
|
|
26
|
+
|
|
27
|
+
_HUNK_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
|
28
|
+
APPLIED_MARKER = "applied.json"
|
|
29
|
+
BACKUP_DIR = "backup"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ApplyError(RuntimeError):
|
|
33
|
+
"""Raised when a diff cannot be cleanly applied."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Hunk:
|
|
38
|
+
old_start: int
|
|
39
|
+
lines: list[tuple[str, str]] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class FilePatch:
|
|
44
|
+
path: str
|
|
45
|
+
hunks: list[Hunk] = field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _strip_ab_prefix(path: str) -> str:
|
|
49
|
+
path = path.strip()
|
|
50
|
+
if path.startswith(("a/", "b/")):
|
|
51
|
+
return path[2:]
|
|
52
|
+
return path
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_unified_diff(diff_text: str) -> list[FilePatch]:
|
|
56
|
+
"""Parse a (possibly multi-file) unified diff into ``FilePatch`` objects."""
|
|
57
|
+
patches: list[FilePatch] = []
|
|
58
|
+
current: FilePatch | None = None
|
|
59
|
+
lines = diff_text.split("\n")
|
|
60
|
+
i = 0
|
|
61
|
+
while i < len(lines):
|
|
62
|
+
line = lines[i]
|
|
63
|
+
if line.startswith("--- "):
|
|
64
|
+
i += 1
|
|
65
|
+
continue
|
|
66
|
+
if line.startswith("+++ "):
|
|
67
|
+
current = FilePatch(path=_strip_ab_prefix(line[4:]))
|
|
68
|
+
patches.append(current)
|
|
69
|
+
i += 1
|
|
70
|
+
continue
|
|
71
|
+
match = _HUNK_RE.match(line)
|
|
72
|
+
if match:
|
|
73
|
+
if current is None:
|
|
74
|
+
raise ApplyError("Hunk encountered before a file header")
|
|
75
|
+
hunk = Hunk(old_start=int(match.group(1)))
|
|
76
|
+
i += 1
|
|
77
|
+
while i < len(lines):
|
|
78
|
+
hl = lines[i]
|
|
79
|
+
if not hl or hl[0] not in " -+" or hl.startswith(("--- ", "+++ ")) or _HUNK_RE.match(hl):
|
|
80
|
+
break
|
|
81
|
+
hunk.lines.append((hl[0], hl[1:]))
|
|
82
|
+
i += 1
|
|
83
|
+
current.hunks.append(hunk)
|
|
84
|
+
continue
|
|
85
|
+
i += 1
|
|
86
|
+
return patches
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def apply_file_patch(original_text: str, hunks: list[Hunk]) -> str:
|
|
90
|
+
"""Apply hunks to ``original_text`` and return the new text."""
|
|
91
|
+
original = original_text.split("\n")
|
|
92
|
+
result: list[str] = []
|
|
93
|
+
idx = 0 # 0-based cursor into original
|
|
94
|
+
|
|
95
|
+
for hunk in hunks:
|
|
96
|
+
start = max(hunk.old_start - 1, 0)
|
|
97
|
+
if start < idx:
|
|
98
|
+
raise ApplyError("Overlapping or out-of-order hunks")
|
|
99
|
+
result.extend(original[idx:start])
|
|
100
|
+
idx = start
|
|
101
|
+
for tag, content in hunk.lines:
|
|
102
|
+
if tag == " ":
|
|
103
|
+
if idx >= len(original) or original[idx] != content:
|
|
104
|
+
raise ApplyError(f"Context mismatch at line {idx + 1}: expected {content!r}")
|
|
105
|
+
result.append(original[idx])
|
|
106
|
+
idx += 1
|
|
107
|
+
elif tag == "-":
|
|
108
|
+
if idx >= len(original) or original[idx] != content:
|
|
109
|
+
raise ApplyError(f"Removal mismatch at line {idx + 1}: expected {content!r}")
|
|
110
|
+
idx += 1
|
|
111
|
+
elif tag == "+":
|
|
112
|
+
result.append(content)
|
|
113
|
+
result.extend(original[idx:])
|
|
114
|
+
return "\n".join(result)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def validate_diff(project_root: Path | str, diff_text: str) -> tuple[bool, str]:
|
|
118
|
+
"""Validate a single unified diff without mutating any files (R9, FR-13).
|
|
119
|
+
|
|
120
|
+
Returns ``(ok, reason)``. A diff is valid when it has at least one file
|
|
121
|
+
header, parses, and applies cleanly against current on-disk content.
|
|
122
|
+
"""
|
|
123
|
+
if not diff_text or not diff_text.strip():
|
|
124
|
+
return False, "empty diff"
|
|
125
|
+
try:
|
|
126
|
+
patches = parse_unified_diff(diff_text)
|
|
127
|
+
except ApplyError as exc:
|
|
128
|
+
return False, f"malformed diff: {exc}"
|
|
129
|
+
if not patches:
|
|
130
|
+
return False, "no file headers (expected 'a/<path>' and 'b/<path>')"
|
|
131
|
+
if not any(fp.hunks for fp in patches):
|
|
132
|
+
return False, "no hunks to apply"
|
|
133
|
+
try:
|
|
134
|
+
compute_file_changes(project_root, [diff_text])
|
|
135
|
+
except ApplyError as exc:
|
|
136
|
+
return False, f"does not apply cleanly: {exc}"
|
|
137
|
+
return True, ""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def compute_file_changes(project_root: Path | str, diff_texts: list[str]) -> dict[str, str]:
|
|
141
|
+
"""Compute new file contents for all diffs without writing anything.
|
|
142
|
+
|
|
143
|
+
Raises ``ApplyError`` if any hunk fails to apply, so callers can abort
|
|
144
|
+
before touching the working tree.
|
|
145
|
+
"""
|
|
146
|
+
project_root = Path(project_root)
|
|
147
|
+
changes: dict[str, str] = {}
|
|
148
|
+
for diff_text in diff_texts:
|
|
149
|
+
for file_patch in parse_unified_diff(diff_text):
|
|
150
|
+
target = project_root / file_patch.path
|
|
151
|
+
if file_patch.path in changes:
|
|
152
|
+
base = changes[file_patch.path]
|
|
153
|
+
elif target.exists():
|
|
154
|
+
base = target.read_text(encoding="utf-8")
|
|
155
|
+
else:
|
|
156
|
+
base = ""
|
|
157
|
+
changes[file_patch.path] = apply_file_patch(base, file_patch.hunks)
|
|
158
|
+
return changes
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def apply_changes(project_root: Path | str, run_dir: Path, changes: dict[str, str]) -> list[str]:
|
|
162
|
+
"""Snapshot originals into the run backup dir, then write new contents.
|
|
163
|
+
|
|
164
|
+
Returns the list of changed relative paths. On a write failure, restores
|
|
165
|
+
from the just-written backups and re-raises.
|
|
166
|
+
"""
|
|
167
|
+
project_root = Path(project_root)
|
|
168
|
+
backup_root = run_dir / BACKUP_DIR
|
|
169
|
+
backup_root.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
|
|
171
|
+
existing: list[str] = []
|
|
172
|
+
new_files: list[str] = []
|
|
173
|
+
for rel in changes:
|
|
174
|
+
src = project_root / rel
|
|
175
|
+
if src.exists():
|
|
176
|
+
dst = backup_root / rel
|
|
177
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
shutil.copy2(src, dst)
|
|
179
|
+
existing.append(rel)
|
|
180
|
+
else:
|
|
181
|
+
new_files.append(rel)
|
|
182
|
+
|
|
183
|
+
written: list[str] = []
|
|
184
|
+
try:
|
|
185
|
+
for rel, text in changes.items():
|
|
186
|
+
dest = project_root / rel
|
|
187
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
188
|
+
dest.write_text(text, encoding="utf-8")
|
|
189
|
+
written.append(rel)
|
|
190
|
+
except OSError as exc:
|
|
191
|
+
_restore(project_root, backup_root, existing, new_files_written=written, new_files=new_files)
|
|
192
|
+
raise ApplyError(f"Write failed, rolled back: {exc}") from exc
|
|
193
|
+
|
|
194
|
+
_write_marker(run_dir, status="applied", existing=existing, new_files=new_files)
|
|
195
|
+
return written
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def revert_from_backup(project_root: Path | str, run_dir: Path) -> list[str]:
|
|
199
|
+
"""Restore files from the run's backup snapshot."""
|
|
200
|
+
project_root = Path(project_root)
|
|
201
|
+
marker = read_marker(run_dir)
|
|
202
|
+
if not marker or marker.get("status") != "applied":
|
|
203
|
+
raise ApplyError("Latest run has no applied changes to revert")
|
|
204
|
+
|
|
205
|
+
existing = marker.get("files", [])
|
|
206
|
+
new_files = marker.get("new_files", [])
|
|
207
|
+
restored = _restore(project_root, run_dir / BACKUP_DIR, existing, new_files_written=new_files, new_files=new_files)
|
|
208
|
+
_write_marker(run_dir, status="reverted", existing=existing, new_files=new_files)
|
|
209
|
+
return restored
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _restore(project_root: Path, backup_root: Path, existing: list[str], *, new_files_written: list[str], new_files: list[str]) -> list[str]:
|
|
213
|
+
restored: list[str] = []
|
|
214
|
+
for rel in existing:
|
|
215
|
+
backup = backup_root / rel
|
|
216
|
+
if backup.exists():
|
|
217
|
+
dest = project_root / rel
|
|
218
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
219
|
+
shutil.copy2(backup, dest)
|
|
220
|
+
restored.append(rel)
|
|
221
|
+
for rel in new_files_written:
|
|
222
|
+
if rel in new_files:
|
|
223
|
+
target = project_root / rel
|
|
224
|
+
if target.exists():
|
|
225
|
+
target.unlink()
|
|
226
|
+
restored.append(rel)
|
|
227
|
+
return restored
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _write_marker(run_dir: Path, *, status: str, existing: list[str], new_files: list[str]) -> None:
|
|
231
|
+
(run_dir / APPLIED_MARKER).write_text(
|
|
232
|
+
json.dumps(
|
|
233
|
+
{
|
|
234
|
+
"status": status,
|
|
235
|
+
"files": existing,
|
|
236
|
+
"new_files": new_files,
|
|
237
|
+
"at": datetime.now(timezone.utc).isoformat(),
|
|
238
|
+
},
|
|
239
|
+
indent=2,
|
|
240
|
+
),
|
|
241
|
+
encoding="utf-8",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def read_marker(run_dir: Path) -> dict | None:
|
|
246
|
+
path = run_dir / APPLIED_MARKER
|
|
247
|
+
if not path.is_file():
|
|
248
|
+
return None
|
|
249
|
+
try:
|
|
250
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
251
|
+
except (json.JSONDecodeError, OSError):
|
|
252
|
+
return None
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Model-aware context budgeting and deterministic batch packing (R9.1, ADR-211).
|
|
2
|
+
|
|
3
|
+
Turns a flat list of index documents into one or more batches that each fit an
|
|
4
|
+
effective input-token budget derived from the resolved model context window and
|
|
5
|
+
a reserved output allowance. Both execution modes consume this so no single
|
|
6
|
+
provider call exceeds the model's limits.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from refactor_core.capabilities import CapabilitySnapshot
|
|
15
|
+
|
|
16
|
+
# Conservative chars-per-token estimate. Real tokenizers vary; we err on the
|
|
17
|
+
# side of *fewer* chars per token (i.e. higher token estimate) for safety.
|
|
18
|
+
CHARS_PER_TOKEN = 3.5
|
|
19
|
+
DEFAULT_BUDGET_RATIO = 0.55
|
|
20
|
+
# Per-request prompt scaffolding (instructions + constitution) reserve.
|
|
21
|
+
PROMPT_OVERHEAD_TOKENS = 1_500
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Budget:
|
|
26
|
+
context_window: int
|
|
27
|
+
reserved_output_tokens: int
|
|
28
|
+
input_budget_tokens: int
|
|
29
|
+
ratio: float
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class BatchPlan:
|
|
34
|
+
budget: Budget
|
|
35
|
+
batches: list[list[dict]]
|
|
36
|
+
estimated_tokens: list[int]
|
|
37
|
+
total_documents: int
|
|
38
|
+
skipped_oversized: int
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def batch_count(self) -> int:
|
|
42
|
+
return len(self.batches)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def total_estimated_tokens(self) -> int:
|
|
46
|
+
return sum(self.estimated_tokens)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def estimate_tokens(text: str) -> int:
|
|
50
|
+
"""Conservative token estimate for a chunk of text."""
|
|
51
|
+
if not text:
|
|
52
|
+
return 0
|
|
53
|
+
return max(1, math.ceil(len(text) / CHARS_PER_TOKEN))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def estimate_document_tokens(doc: dict) -> int:
|
|
57
|
+
"""Estimate tokens for a single index document, including light JSON framing."""
|
|
58
|
+
chunk = doc.get("chunk_text", "") or ""
|
|
59
|
+
path = doc.get("path", "") or ""
|
|
60
|
+
# ~20 tokens of structural framing per document (keys, braces, language).
|
|
61
|
+
return estimate_tokens(chunk) + estimate_tokens(path) + 20
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def compute_budget(
|
|
65
|
+
capabilities: CapabilitySnapshot,
|
|
66
|
+
*,
|
|
67
|
+
ratio: float = DEFAULT_BUDGET_RATIO,
|
|
68
|
+
output_ceiling: int | None = None,
|
|
69
|
+
) -> Budget:
|
|
70
|
+
"""Derive the effective input-token budget for a single provider call."""
|
|
71
|
+
ratio = _clamp_ratio(ratio)
|
|
72
|
+
context_window = max(1, int(capabilities.context_window))
|
|
73
|
+
reserved = int(capabilities.max_output_tokens)
|
|
74
|
+
if output_ceiling is not None:
|
|
75
|
+
reserved = min(reserved, max(1, int(output_ceiling)))
|
|
76
|
+
reserved = min(reserved, context_window - 1)
|
|
77
|
+
|
|
78
|
+
usable = (context_window - reserved) * ratio
|
|
79
|
+
input_budget = int(usable) - PROMPT_OVERHEAD_TOKENS
|
|
80
|
+
input_budget = max(input_budget, 1)
|
|
81
|
+
return Budget(
|
|
82
|
+
context_window=context_window,
|
|
83
|
+
reserved_output_tokens=reserved,
|
|
84
|
+
input_budget_tokens=input_budget,
|
|
85
|
+
ratio=ratio,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def plan_batches(
|
|
90
|
+
documents: list[dict],
|
|
91
|
+
capabilities: CapabilitySnapshot,
|
|
92
|
+
*,
|
|
93
|
+
ratio: float = DEFAULT_BUDGET_RATIO,
|
|
94
|
+
output_ceiling: int | None = None,
|
|
95
|
+
) -> BatchPlan:
|
|
96
|
+
"""Pack documents into context-safe batches (deterministic, input order).
|
|
97
|
+
|
|
98
|
+
A document larger than the whole input budget is placed in its own batch
|
|
99
|
+
(the provider/pipeline may further truncate); it is counted in
|
|
100
|
+
``skipped_oversized`` for observability but is never silently dropped.
|
|
101
|
+
"""
|
|
102
|
+
budget = compute_budget(capabilities, ratio=ratio, output_ceiling=output_ceiling)
|
|
103
|
+
|
|
104
|
+
batches: list[list[dict]] = []
|
|
105
|
+
estimated: list[int] = []
|
|
106
|
+
current: list[dict] = []
|
|
107
|
+
current_tokens = 0
|
|
108
|
+
skipped_oversized = 0
|
|
109
|
+
|
|
110
|
+
for doc in documents:
|
|
111
|
+
doc_tokens = estimate_document_tokens(doc)
|
|
112
|
+
|
|
113
|
+
if doc_tokens > budget.input_budget_tokens:
|
|
114
|
+
skipped_oversized += 1
|
|
115
|
+
if current:
|
|
116
|
+
batches.append(current)
|
|
117
|
+
estimated.append(current_tokens)
|
|
118
|
+
current = []
|
|
119
|
+
current_tokens = 0
|
|
120
|
+
batches.append([doc])
|
|
121
|
+
estimated.append(doc_tokens)
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
if current and current_tokens + doc_tokens > budget.input_budget_tokens:
|
|
125
|
+
batches.append(current)
|
|
126
|
+
estimated.append(current_tokens)
|
|
127
|
+
current = []
|
|
128
|
+
current_tokens = 0
|
|
129
|
+
|
|
130
|
+
current.append(doc)
|
|
131
|
+
current_tokens += doc_tokens
|
|
132
|
+
|
|
133
|
+
if current:
|
|
134
|
+
batches.append(current)
|
|
135
|
+
estimated.append(current_tokens)
|
|
136
|
+
|
|
137
|
+
return BatchPlan(
|
|
138
|
+
budget=budget,
|
|
139
|
+
batches=batches,
|
|
140
|
+
estimated_tokens=estimated,
|
|
141
|
+
total_documents=len(documents),
|
|
142
|
+
skipped_oversized=skipped_oversized,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _clamp_ratio(ratio: float) -> float:
|
|
147
|
+
try:
|
|
148
|
+
ratio = float(ratio)
|
|
149
|
+
except (TypeError, ValueError):
|
|
150
|
+
return DEFAULT_BUDGET_RATIO
|
|
151
|
+
if ratio <= 0 or ratio >= 1:
|
|
152
|
+
return DEFAULT_BUDGET_RATIO
|
|
153
|
+
return ratio
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Resolved provider/model capability snapshot (R9.1).
|
|
2
|
+
|
|
3
|
+
Combines a provider instance's declared metadata with the built-in model catalog
|
|
4
|
+
and ``model_context_overrides`` from ``refactor.config`` into a single snapshot
|
|
5
|
+
used by budgeting, mode resolution, and ``refactor doctor``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
|
|
12
|
+
from refactor_core.model_catalog import ModelContext, resolve_model_context
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class CapabilitySnapshot:
|
|
17
|
+
provider: str
|
|
18
|
+
model_id: str
|
|
19
|
+
supports_tools: bool
|
|
20
|
+
supports_refactor: bool
|
|
21
|
+
context_window: int
|
|
22
|
+
max_output_tokens: int
|
|
23
|
+
source: str
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict:
|
|
26
|
+
return asdict(self)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve_capabilities(provider, settings: dict | None = None) -> CapabilitySnapshot:
|
|
30
|
+
"""Resolve effective capabilities for a provider instance.
|
|
31
|
+
|
|
32
|
+
Precedence for context/tool values: a provider that *explicitly* declares a
|
|
33
|
+
non-default ``supports_tools`` keeps it; otherwise the catalog/override value
|
|
34
|
+
wins. Context-window and output-token ceilings always come from the catalog/
|
|
35
|
+
override resolution (the authoritative per-model source).
|
|
36
|
+
"""
|
|
37
|
+
settings = settings or {}
|
|
38
|
+
provider_name = getattr(provider, "provider_name", "unknown")
|
|
39
|
+
model_id = getattr(provider, "model_id", "unknown")
|
|
40
|
+
overrides = settings.get("model_context_overrides")
|
|
41
|
+
|
|
42
|
+
ctx: ModelContext = resolve_model_context(provider_name, model_id, overrides=overrides)
|
|
43
|
+
|
|
44
|
+
# Honor an explicit provider-level opt-in to tools if the catalog is unsure.
|
|
45
|
+
declared_tools = bool(getattr(provider, "supports_tools", False))
|
|
46
|
+
supports_tools = ctx.supports_tools or declared_tools
|
|
47
|
+
|
|
48
|
+
return CapabilitySnapshot(
|
|
49
|
+
provider=provider_name,
|
|
50
|
+
model_id=model_id,
|
|
51
|
+
supports_tools=supports_tools,
|
|
52
|
+
supports_refactor=bool(getattr(provider, "supports_refactor", True)),
|
|
53
|
+
context_window=ctx.context_window,
|
|
54
|
+
max_output_tokens=ctx.max_output_tokens,
|
|
55
|
+
source=ctx.source,
|
|
56
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Compliance pack loading/projection namespace (R18)."""
|
|
2
|
+
|
|
3
|
+
from refactor_core.compliance.evidence import (
|
|
4
|
+
ComplianceGateDecision,
|
|
5
|
+
build_compliance_evidence_bundle,
|
|
6
|
+
evaluate_compliance_gate,
|
|
7
|
+
)
|
|
8
|
+
from refactor_core.compliance.loader import (
|
|
9
|
+
CompliancePackError,
|
|
10
|
+
load_compliance_pack_for_framework,
|
|
11
|
+
resolve_compliance_packs,
|
|
12
|
+
)
|
|
13
|
+
from refactor_core.compliance.models import (
|
|
14
|
+
CompliancePack,
|
|
15
|
+
CompliancePackResolution,
|
|
16
|
+
ComplianceProjectedRule,
|
|
17
|
+
ComplianceRuleDef,
|
|
18
|
+
)
|
|
19
|
+
from refactor_core.compliance.projection import project_compliance_for_finding
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"CompliancePack",
|
|
23
|
+
"CompliancePackError",
|
|
24
|
+
"ComplianceGateDecision",
|
|
25
|
+
"CompliancePackResolution",
|
|
26
|
+
"ComplianceProjectedRule",
|
|
27
|
+
"ComplianceRuleDef",
|
|
28
|
+
"build_compliance_evidence_bundle",
|
|
29
|
+
"evaluate_compliance_gate",
|
|
30
|
+
"load_compliance_pack_for_framework",
|
|
31
|
+
"project_compliance_for_finding",
|
|
32
|
+
"resolve_compliance_packs",
|
|
33
|
+
]
|
|
34
|
+
|