refactorai-core 3.0.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.
- refactor_core/__init__.py +27 -0
- refactor_core/apply.py +252 -0
- refactor_core/budgeting.py +153 -0
- refactor_core/capabilities.py +56 -0
- refactor_core/compliance/__init__.py +34 -0
- refactor_core/compliance/detectors.py +109 -0
- refactor_core/compliance/evidence.py +45 -0
- refactor_core/compliance/loader.py +233 -0
- refactor_core/compliance/models.py +68 -0
- refactor_core/compliance/packs/__init__.py +2 -0
- refactor_core/compliance/packs/hipaa-ephi.json +55 -0
- refactor_core/compliance/packs/iso27001-sdlc.json +62 -0
- refactor_core/compliance/packs/pci-payments.json +63 -0
- refactor_core/compliance/packs/privacy-gdpr.json +71 -0
- refactor_core/compliance/packs/soc2-core.json +63 -0
- refactor_core/compliance/projection.py +252 -0
- refactor_core/compliance_settings.py +173 -0
- refactor_core/constitution.py +354 -0
- refactor_core/gate.py +48 -0
- refactor_core/indexing.py +225 -0
- refactor_core/model_catalog.py +127 -0
- refactor_core/models.py +69 -0
- refactor_core/op_classifier.py +243 -0
- refactor_core/op_feasibility.py +237 -0
- refactor_core/paths.py +47 -0
- refactor_core/refactor_ops.py +152 -0
- refactor_core/refactor_requests.py +858 -0
- refactor_core/rules/__init__.py +19 -0
- refactor_core/rules/injection.py +124 -0
- refactor_core/rules/loader.py +91 -0
- refactor_core/rules/matcher.py +41 -0
- refactor_core/rules/models.py +46 -0
- refactor_core/rules/resolver.py +150 -0
- refactor_core/rules/system_rules.json +170 -0
- refactor_core/security/__init__.py +35 -0
- refactor_core/security/finding_merge.py +159 -0
- refactor_core/security/semgrep_parser.py +129 -0
- refactor_core/security/semgrep_runner.py +360 -0
- refactor_core/security/suppression.py +88 -0
- refactor_core/store.py +354 -0
- refactor_core/testcmd.py +64 -0
- refactorai_core-3.0.0.dist-info/METADATA +27 -0
- refactorai_core-3.0.0.dist-info/RECORD +45 -0
- refactorai_core-3.0.0.dist-info/WHEEL +5 -0
- refactorai_core-3.0.0.dist-info/top_level.txt +1 -0
|
@@ -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"
|
refactor_core/apply.py
ADDED
|
@@ -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
|
+
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Project-context detectors for compliance projection (R18 Phase 3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_SENSITIVE_SEGMENTS = {
|
|
9
|
+
"auth",
|
|
10
|
+
"iam",
|
|
11
|
+
"billing",
|
|
12
|
+
"payment",
|
|
13
|
+
"payments",
|
|
14
|
+
"audit",
|
|
15
|
+
"security",
|
|
16
|
+
"crypto",
|
|
17
|
+
"pii",
|
|
18
|
+
"privacy",
|
|
19
|
+
"ephi",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
_CODE_EXTS = {
|
|
23
|
+
".py",
|
|
24
|
+
".js",
|
|
25
|
+
".jsx",
|
|
26
|
+
".ts",
|
|
27
|
+
".tsx",
|
|
28
|
+
".java",
|
|
29
|
+
".go",
|
|
30
|
+
".cs",
|
|
31
|
+
".php",
|
|
32
|
+
".rb",
|
|
33
|
+
".rs",
|
|
34
|
+
".cpp",
|
|
35
|
+
".cc",
|
|
36
|
+
".c",
|
|
37
|
+
".h",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class ComplianceProjectContext:
|
|
43
|
+
"""Derived context used by rule projection."""
|
|
44
|
+
|
|
45
|
+
project_root: Path
|
|
46
|
+
languages: tuple[str, ...]
|
|
47
|
+
has_package_json: bool
|
|
48
|
+
has_pyproject: bool
|
|
49
|
+
has_go_mod: bool
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def detect_project_context(project_root: Path | str) -> ComplianceProjectContext:
|
|
53
|
+
root = Path(project_root)
|
|
54
|
+
langs: set[str] = set()
|
|
55
|
+
if (root / "package.json").is_file():
|
|
56
|
+
langs.update({"js", "ts"})
|
|
57
|
+
if (root / "pyproject.toml").is_file() or (root / "requirements.txt").is_file():
|
|
58
|
+
langs.add("py")
|
|
59
|
+
if (root / "go.mod").is_file():
|
|
60
|
+
langs.add("go")
|
|
61
|
+
# Cheap fallback: infer from extension seen in top-level/src.
|
|
62
|
+
for candidate in (root, root / "src"):
|
|
63
|
+
if not candidate.is_dir():
|
|
64
|
+
continue
|
|
65
|
+
for item in candidate.rglob("*"):
|
|
66
|
+
if not item.is_file():
|
|
67
|
+
continue
|
|
68
|
+
if item.suffix.lower() not in _CODE_EXTS:
|
|
69
|
+
continue
|
|
70
|
+
suffix = item.suffix.lower()
|
|
71
|
+
if suffix in {".js", ".jsx", ".ts", ".tsx"}:
|
|
72
|
+
langs.update({"js", "ts"})
|
|
73
|
+
elif suffix == ".py":
|
|
74
|
+
langs.add("py")
|
|
75
|
+
elif suffix == ".go":
|
|
76
|
+
langs.add("go")
|
|
77
|
+
elif suffix in {".java"}:
|
|
78
|
+
langs.add("java")
|
|
79
|
+
return ComplianceProjectContext(
|
|
80
|
+
project_root=root,
|
|
81
|
+
languages=tuple(sorted(langs)),
|
|
82
|
+
has_package_json=(root / "package.json").is_file(),
|
|
83
|
+
has_pyproject=(root / "pyproject.toml").is_file(),
|
|
84
|
+
has_go_mod=(root / "go.mod").is_file(),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def normalize_rel_path(value: str) -> str:
|
|
89
|
+
text = str(value or "").strip().replace("\\", "/").lstrip("./")
|
|
90
|
+
while "//" in text:
|
|
91
|
+
text = text.replace("//", "/")
|
|
92
|
+
return text.strip().lower()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def path_segments(path_value: str) -> tuple[str, ...]:
|
|
96
|
+
norm = normalize_rel_path(path_value)
|
|
97
|
+
if not norm:
|
|
98
|
+
return ()
|
|
99
|
+
return tuple(chunk for chunk in norm.split("/") if chunk)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def touches_sensitive_path(path_value: str) -> bool:
|
|
103
|
+
return any(seg in _SENSITIVE_SEGMENTS for seg in path_segments(path_value))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def looks_like_serializer(path_value: str) -> bool:
|
|
107
|
+
norm = normalize_rel_path(path_value)
|
|
108
|
+
return "serial" in norm or norm.endswith("_dto.py") or "schema" in norm
|
|
109
|
+
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Compliance gate/evidence helpers (R18 Phase 7)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class ComplianceGateDecision:
|
|
10
|
+
status: str # passed | blocked
|
|
11
|
+
reasons: tuple[str, ...] = ()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def evaluate_compliance_gate(compliance_summary: dict | None, *, base_gate_status: str) -> ComplianceGateDecision:
|
|
15
|
+
"""Derive compliance gate outcome from summary payload and base gate status."""
|
|
16
|
+
summary = compliance_summary if isinstance(compliance_summary, dict) else {}
|
|
17
|
+
try:
|
|
18
|
+
blocked_count = int(summary.get("blocked_count", 0) or 0)
|
|
19
|
+
except (TypeError, ValueError):
|
|
20
|
+
blocked_count = 0
|
|
21
|
+
level = str(summary.get("level", "standard") or "standard").strip().lower()
|
|
22
|
+
if base_gate_status == "blocked":
|
|
23
|
+
return ComplianceGateDecision(status="blocked", reasons=("quality_gate_blocked",))
|
|
24
|
+
if blocked_count > 0 and level in {"standard", "strict"}:
|
|
25
|
+
return ComplianceGateDecision(status="blocked", reasons=("compliance_controls_blocked",))
|
|
26
|
+
return ComplianceGateDecision(status="passed", reasons=())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_compliance_evidence_bundle(report: dict) -> dict:
|
|
30
|
+
"""Create an auditor-friendly compliance evidence payload from run report."""
|
|
31
|
+
payload = dict(report or {})
|
|
32
|
+
return {
|
|
33
|
+
"run_id": payload.get("run_id", ""),
|
|
34
|
+
"target": payload.get("target", ""),
|
|
35
|
+
"created_at": payload.get("created_at", ""),
|
|
36
|
+
"model_id": payload.get("model_id", ""),
|
|
37
|
+
"gate": payload.get("gate", {}),
|
|
38
|
+
"compliance_summary": payload.get("compliance_summary", {}),
|
|
39
|
+
"rule_trace": {
|
|
40
|
+
"rule_source": payload.get("rule_source", ""),
|
|
41
|
+
"rule_pattern": payload.get("rule_pattern", ""),
|
|
42
|
+
"rule_hash": payload.get("rule_hash", ""),
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|