refactorai-core 3.2.2__tar.gz → 3.2.5__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.
Files changed (55) hide show
  1. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/PKG-INFO +1 -1
  2. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/pyproject.toml +2 -1
  3. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/__init__.py +1 -1
  4. refactorai_core-3.2.5/refactor_core/intent/__init__.py +36 -0
  5. refactorai_core-3.2.5/refactor_core/intent/capture.py +296 -0
  6. refactorai_core-3.2.5/refactor_core/intent/contract.py +154 -0
  7. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/refactor_requests.py +18 -0
  8. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactorai_core.egg-info/PKG-INFO +1 -1
  9. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactorai_core.egg-info/SOURCES.txt +3 -0
  10. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/README.md +0 -0
  11. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/apply.py +0 -0
  12. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/budgeting.py +0 -0
  13. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/capabilities.py +0 -0
  14. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/compliance/__init__.py +0 -0
  15. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/compliance/detectors.py +0 -0
  16. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/compliance/evidence.py +0 -0
  17. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/compliance/loader.py +0 -0
  18. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/compliance/models.py +0 -0
  19. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/compliance/packs/__init__.py +0 -0
  20. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/compliance/packs/hipaa-ephi.json +0 -0
  21. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/compliance/packs/iso27001-sdlc.json +0 -0
  22. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/compliance/packs/pci-payments.json +0 -0
  23. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/compliance/packs/privacy-gdpr.json +0 -0
  24. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/compliance/packs/soc2-core.json +0 -0
  25. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/compliance/projection.py +0 -0
  26. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/compliance_settings.py +0 -0
  27. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/constitution.py +0 -0
  28. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/gate.py +0 -0
  29. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/host_toolchain.py +0 -0
  30. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/indexing.py +0 -0
  31. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/merge_gate.py +0 -0
  32. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/model_catalog.py +0 -0
  33. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/models.py +0 -0
  34. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/op_classifier.py +0 -0
  35. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/op_feasibility.py +0 -0
  36. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/paths.py +0 -0
  37. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/refactor_ops.py +0 -0
  38. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/rules/__init__.py +0 -0
  39. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/rules/injection.py +0 -0
  40. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/rules/loader.py +0 -0
  41. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/rules/matcher.py +0 -0
  42. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/rules/models.py +0 -0
  43. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/rules/resolver.py +0 -0
  44. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/rules/system_rules.json +0 -0
  45. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/security/__init__.py +0 -0
  46. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/security/finding_merge.py +0 -0
  47. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/security/semgrep_parser.py +0 -0
  48. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/security/semgrep_runner.py +0 -0
  49. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/security/suppression.py +0 -0
  50. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/store.py +0 -0
  51. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactor_core/testcmd.py +0 -0
  52. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactorai_core.egg-info/dependency_links.txt +0 -0
  53. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactorai_core.egg-info/requires.txt +0 -0
  54. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/refactorai_core.egg-info/top_level.txt +0 -0
  55. {refactorai_core-3.2.2 → refactorai_core-3.2.5}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-core
3
- Version: 3.2.2
3
+ Version: 3.2.5
4
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
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "refactorai-core"
3
- version = "3.2.2"
3
+ version = "3.2.5"
4
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
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -38,6 +38,7 @@ include = [
38
38
  "refactor_core.security",
39
39
  "refactor_core.compliance",
40
40
  "refactor_core.compliance.packs",
41
+ "refactor_core.intent",
41
42
  ]
42
43
  # Defensive: never ship engine subpackages in the public client wheel even if a
43
44
  # future refactor accidentally makes one discoverable.
@@ -24,4 +24,4 @@ __all__ = [
24
24
  "__version__",
25
25
  ]
26
26
 
27
- __version__ = "3.2.2"
27
+ __version__ = "3.2.5"
@@ -0,0 +1,36 @@
1
+ """Shared, pure intent-capture and contract-normalization primitives (`R27`/`R28`).
2
+
3
+ Single source of truth for the deterministic intent math used by both the server
4
+ (`backend/app/services/intent*.py`) and the local commit-scoped review loop
5
+ (docs/41). Everything here is provider- and storage-independent.
6
+ """
7
+
8
+ from refactor_core.intent.capture import (
9
+ build_payload,
10
+ derive_contract,
11
+ extract_statements,
12
+ summarize_intent,
13
+ )
14
+ from refactor_core.intent.contract import (
15
+ ELEVATED_RISK_TIERS,
16
+ STRUCTURAL_ASSERTIONS,
17
+ compute_content_hash,
18
+ infer_verifier,
19
+ normalize_contract_input,
20
+ required_permission_for_risk_tier,
21
+ summarize_verdicts,
22
+ )
23
+
24
+ __all__ = [
25
+ "build_payload",
26
+ "derive_contract",
27
+ "extract_statements",
28
+ "summarize_intent",
29
+ "ELEVATED_RISK_TIERS",
30
+ "STRUCTURAL_ASSERTIONS",
31
+ "compute_content_hash",
32
+ "infer_verifier",
33
+ "normalize_contract_input",
34
+ "required_permission_for_risk_tier",
35
+ "summarize_verdicts",
36
+ ]
@@ -0,0 +1,296 @@
1
+ """Deterministic implicit intent capture (docs/39 FR-01, Part D INTENT CAPTURE).
2
+
3
+ Intent does **not** have to be authored by hand. This module derives the same
4
+ canonical acceptance-criteria structure an explicit contract produces from the
5
+ free-form context that already surrounds a change:
6
+
7
+ - a coding agent's prompt (Cursor / Claude Code) submitted at trigger time,
8
+ - a commit message (local commit trigger, docs/41 `R28`),
9
+ - a pull-request title/description (webhook path),
10
+ - a linked ticket, or a design/spec doc.
11
+
12
+ The derived structure is fed through :func:`contract.normalize_contract_input`,
13
+ so capture shares one normalization/keying/verifier-routing path with explicit
14
+ contracts, on both the server and the local CLI.
15
+
16
+ Design constraints:
17
+
18
+ - **Deterministic and provider-independent.** No LLM call, so every execution
19
+ mode behaves identically and the extractor is fully unit-testable. Richer LLM
20
+ extraction can layer on later behind the same ``derive_contract`` interface.
21
+ - **Never fabricate a verified verdict.** Extraction only *captures* criteria;
22
+ structural ones route to the deterministic ``code-scan`` verifier, everything
23
+ else stays ``behavioral`` (recorded, escalatable) rather than silently passing
24
+ (docs/39 Part C hard boundary).
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import re
30
+
31
+ from refactor_core.intent import contract as contract_mod
32
+
33
+ # A statement looks like a requirement when it carries an imperative/modal verb.
34
+ _MODAL = re.compile(
35
+ r"\b(must|should|shall|need(?:s)? to|has to|have to|will|ensure|add|adds|"
36
+ r"implement|implements|introduce|create|creates|fix|fixes|support|supports|"
37
+ r"expose|exposes|return|returns|handle|handles|remove|removes|rename|renames|"
38
+ r"update|updates|allow|allows|prevent|prevents|validate|validates|require|"
39
+ r"requires|guarantee|guarantees|enforce|enforces)\b",
40
+ re.IGNORECASE,
41
+ )
42
+
43
+ # Section headers that flip parsing into "these bullets are non-goals".
44
+ _NON_GOAL_HINT = re.compile(r"\b(non[-\s]?goals?|out[-\s]?of[-\s]?scope|not in scope|will not)\b", re.IGNORECASE)
45
+ # Section headers that mark an explicit list of acceptance criteria.
46
+ _CRITERIA_HINT = re.compile(
47
+ r"\b(acceptance criteri\w+|requirements?|criteria|tasks?|checklist|to[-\s]?do)\b", re.IGNORECASE
48
+ )
49
+
50
+ _BULLET = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]?\]\s+)?(.*\S)\s*$")
51
+ _HEADER = re.compile(r"^\s*(?:#{1,6}\s*|\*\*)\s*(.+?)\s*(?:\*\*)?\s*:?\s*$")
52
+ _QUOTED = re.compile(r"`([^`]+)`|\"([^\"]+)\"|'([^']+)'")
53
+ _BARE_PATH = re.compile(r"(?<![\w./-])([\w./-]+\.[A-Za-z0-9]{1,6})(?![\w/])")
54
+ _CONTAINS_VERB = re.compile(
55
+ r"\b(contain|contains|mention|mentions|include|includes|reference|references|say|says|document|documents)\b",
56
+ re.IGNORECASE,
57
+ )
58
+
59
+ _MAX_CRITERIA = 12
60
+ _MAX_STATEMENT = 200
61
+
62
+
63
+ def _looks_like_path(token: str) -> bool:
64
+ token = (token or "").strip()
65
+ if not token or " " in token or "\t" in token:
66
+ return False
67
+ if not re.fullmatch(r"[\w./@+-]+", token):
68
+ return False
69
+ return "/" in token or bool(re.search(r"\.[A-Za-z0-9]{1,6}$", token))
70
+
71
+
72
+ def _find_path(statement: str) -> str:
73
+ """Return the first path-like token mentioned in a statement, else ``""``."""
74
+ for match in _QUOTED.finditer(statement):
75
+ token = next((g for g in match.groups() if g), "")
76
+ if _looks_like_path(token):
77
+ return token.strip().lstrip("/")
78
+ bare = _BARE_PATH.search(statement)
79
+ if bare and _looks_like_path(bare.group(1)):
80
+ return bare.group(1).strip().lstrip("/")
81
+ return ""
82
+
83
+
84
+ def _detect_structural(statement: str) -> tuple[str, str, str, str]:
85
+ """Classify a statement into (kind, assertion, path, expected).
86
+
87
+ A statement that names a concrete file/path is treated as a deterministically
88
+ checkable structural criterion; if it also asks for that file to *contain* a
89
+ quoted term it becomes ``path_contains``. Everything else is ``behavioral``
90
+ (routed to the runtime verifier, escalatable, never silently passed).
91
+ """
92
+ path = _find_path(statement)
93
+ if not path:
94
+ return "behavioral", "", "", ""
95
+
96
+ if _CONTAINS_VERB.search(statement):
97
+ for match in _QUOTED.finditer(statement):
98
+ token = next((g for g in match.groups() if g), "").strip()
99
+ if token and token.lstrip("/") != path and not _looks_like_path(token):
100
+ return "structural", "path_contains", path, token
101
+ return "structural", "path_exists", path, ""
102
+
103
+
104
+ def _clean_statement(text: str) -> str:
105
+ text = re.sub(r"\s+", " ", (text or "").strip())
106
+ text = text.rstrip(".;")
107
+ return text[:_MAX_STATEMENT].strip()
108
+
109
+
110
+ def _sentences(line: str) -> list[str]:
111
+ # Split a prose line into candidate requirement sentences.
112
+ parts = re.split(r"(?<=[.!?])\s+", line.strip())
113
+ return [p for p in (part.strip() for part in parts) if p]
114
+
115
+
116
+ def extract_statements(text: str) -> tuple[list[str], list[str]]:
117
+ """Extract (criterion statements, non-goal statements) from one text blob.
118
+
119
+ Bullet/checklist lines become criteria (or non-goals under a non-goal
120
+ heading); free prose contributes sentences that carry a modal/imperative verb.
121
+ """
122
+ if not text:
123
+ return [], []
124
+
125
+ criteria: list[str] = []
126
+ non_goals: list[str] = []
127
+ section = "body" # body | non_goal | criteria
128
+
129
+ for raw in str(text).splitlines():
130
+ line = raw.rstrip()
131
+ if not line.strip():
132
+ continue
133
+
134
+ bullet = _BULLET.match(line)
135
+ header = None if bullet else _HEADER.match(line)
136
+
137
+ if header:
138
+ label = header.group(1)
139
+ if _NON_GOAL_HINT.search(label):
140
+ section = "non_goal"
141
+ elif _CRITERIA_HINT.search(label):
142
+ section = "criteria"
143
+ else:
144
+ section = "body"
145
+ continue
146
+
147
+ if bullet:
148
+ content = _clean_statement(bullet.group(1))
149
+ if not content:
150
+ continue
151
+ if section == "non_goal":
152
+ non_goals.append(content)
153
+ else:
154
+ criteria.append(content)
155
+ continue
156
+
157
+ # Non-bullet prose: harvest imperative sentences (and non-goals when the
158
+ # current section says so).
159
+ for sentence in _sentences(line):
160
+ if section == "non_goal":
161
+ non_goals.append(_clean_statement(sentence))
162
+ elif _MODAL.search(sentence):
163
+ criteria.append(_clean_statement(sentence))
164
+
165
+ return [c for c in criteria if c], [n for n in non_goals if n]
166
+
167
+
168
+ def _dedupe(values: list[str]) -> list[str]:
169
+ seen: set[str] = set()
170
+ ordered: list[str] = []
171
+ for value in values:
172
+ key = value.lower()
173
+ if key and key not in seen:
174
+ seen.add(key)
175
+ ordered.append(value)
176
+ return ordered
177
+
178
+
179
+ def _source_label(sources: list[dict]) -> str:
180
+ kinds = {str(s.get("type", "") or "").strip().lower() for s in sources if str(s.get("type", "") or "").strip()}
181
+ if len(kinds) == 1:
182
+ return next(iter(kinds))
183
+ return "derived"
184
+
185
+
186
+ def _title_from(sources: list[dict], statements: list[str]) -> str:
187
+ for source in sources:
188
+ title = str(source.get("title", "") or "").strip()
189
+ if title:
190
+ return title[:_MAX_STATEMENT]
191
+ for source in sources:
192
+ for line in str(source.get("text", "") or "").splitlines():
193
+ stripped = line.strip().lstrip("#").strip()
194
+ if stripped:
195
+ return _clean_statement(stripped)
196
+ return statements[0] if statements else ""
197
+
198
+
199
+ def build_payload(sources: list[dict]) -> dict | None:
200
+ """Build a raw intent-contract *input* payload from free-form sources.
201
+
202
+ Returns the payload shape consumed by
203
+ :func:`contract.normalize_contract_input` (or ``None`` when nothing usable
204
+ was found). Kept separate from :func:`derive_contract` so it is trivially
205
+ unit-testable without touching the normalization layer.
206
+ """
207
+ if not sources:
208
+ return None
209
+
210
+ # Keep first-seen provenance for each distinct statement so every derived
211
+ # criterion stays auditable even when we aggregate multiple source types.
212
+ all_criteria: list[tuple[str, str, str]] = []
213
+ all_non_goals: list[str] = []
214
+ for source in sources:
215
+ source_type = str(source.get("type", "derived") or "derived").strip().lower() or "derived"
216
+ source_ref = str(source.get("ref", "") or "").strip()
217
+ source_title = str(source.get("title", "") or "").strip()
218
+ source_text = str(source.get("text", "") or "").strip()
219
+ # Parse both title and body: webhook PR events and commit subjects often
220
+ # encode intent in the title while the body may be empty/minimal.
221
+ merged_text = "\n".join(part for part in [source_title, source_text] if part)
222
+ crit, non_goals = extract_statements(merged_text)
223
+ for statement in crit:
224
+ all_criteria.append((statement, source_type, source_ref))
225
+ all_non_goals.extend(non_goals)
226
+
227
+ statement_provenance: dict[str, tuple[str, str]] = {}
228
+ statements: list[str] = []
229
+ for statement, source_type, source_ref in all_criteria:
230
+ key = statement.lower()
231
+ if key in statement_provenance:
232
+ continue
233
+ statement_provenance[key] = (source_type, source_ref)
234
+ statements.append(statement)
235
+ if len(statements) >= _MAX_CRITERIA:
236
+ break
237
+ non_goals = _dedupe(all_non_goals)[:_MAX_CRITERIA]
238
+ title = _title_from(sources, statements)
239
+
240
+ if not title and not statements:
241
+ return None
242
+
243
+ label = _source_label(sources)
244
+ criteria: list[dict] = []
245
+ for index, statement in enumerate(statements, start=1):
246
+ kind, assertion, path, expected = _detect_structural(statement)
247
+ source_type, source_ref = statement_provenance.get(statement.lower(), (label, ""))
248
+ note = f"derived from {source_type}"
249
+ if source_ref:
250
+ note = f"{note} ({source_ref})"
251
+ criteria.append(
252
+ {
253
+ "id": f"criterion-{index}",
254
+ "statement": statement,
255
+ "kind": kind,
256
+ "risk_tier": "standard",
257
+ "assert": assertion,
258
+ "path": path,
259
+ "contains": expected,
260
+ "notes": note,
261
+ }
262
+ )
263
+
264
+ return {"title": title, "source": label, "non_goals": non_goals, "criteria": criteria}
265
+
266
+
267
+ def derive_contract(sources: list[dict] | None) -> dict | None:
268
+ """Derive a canonical intent contract from free-form authoring context.
269
+
270
+ ``sources`` is a list of ``{type, title, text, ref}`` dicts. Returns the
271
+ canonical structure (same shape as an explicitly-submitted contract), or
272
+ ``None`` when no intent could be derived (callers treat that as the
273
+ non-blocking "no intent" case).
274
+ """
275
+ payload = build_payload([s for s in (sources or []) if isinstance(s, dict)])
276
+ if payload is None:
277
+ return None
278
+ return contract_mod.normalize_contract_input(payload)
279
+
280
+
281
+ def summarize_intent(canonical: dict | None) -> str:
282
+ """One-line human summary of a derived contract for terminal display (R28).
283
+
284
+ Prefers the contract title, else the first criterion statement; returns an
285
+ empty string when there is no usable intent.
286
+ """
287
+ if not isinstance(canonical, dict):
288
+ return ""
289
+ title = str(canonical.get("title", "") or "").strip()
290
+ if title:
291
+ return title[:_MAX_STATEMENT]
292
+ for criterion in canonical.get("criteria", []) or []:
293
+ statement = str((criterion or {}).get("statement", "") or "").strip()
294
+ if statement:
295
+ return statement[:_MAX_STATEMENT]
296
+ return ""
@@ -0,0 +1,154 @@
1
+ """Pure intent-contract normalization and verdict aggregation (docs/39 `R27`).
2
+
3
+ This is the single, provider- and storage-independent implementation of the
4
+ intent-contract math shared by the server (`backend/app/services/intent.py`) and
5
+ the local CLI (docs/41 `R28`). It contains only pure functions over plain dicts:
6
+
7
+ - ``normalize_contract_input`` coerces a raw payload into a canonical,
8
+ deterministically-keyed structure (shared shape for persistence and the local
9
+ commit-scoped review).
10
+ - ``_infer_verifier`` routes a criterion to exactly one verifier.
11
+ - ``compute_content_hash`` produces a stable hash over the canonical contract.
12
+ - ``summarize_verdicts`` aggregates per-criterion verdicts into a gate decision.
13
+
14
+ No database, no execution mode, no provider selection — so ``local`` /
15
+ ``cloud_managed`` / ``cloud_byok`` behave identically wherever this is imported.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import json
22
+
23
+ # Structural assertions the deterministic ``code-scan`` verifier understands.
24
+ STRUCTURAL_ASSERTIONS = {"path_exists", "path_contains"}
25
+
26
+ # Criteria that carry organizational risk require elevated permission to amend
27
+ # (docs/39 FR-08). Kept here so the risk vocabulary has one definition.
28
+ ELEVATED_RISK_TIERS = {"high", "security", "compliance"}
29
+
30
+ _VALID_RISK_TIERS = {"standard", "high", "security", "compliance"}
31
+
32
+
33
+ def infer_verifier(kind: str, assertion: str) -> str:
34
+ """Route a criterion to exactly one verifier (docs/39 FR-03).
35
+
36
+ Only the deterministic ``code-scan`` verifier is implemented for structural
37
+ criteria; behavioral criteria route to ``runtime`` (recorded, escalatable,
38
+ never silently passed); anything else is ``pending``.
39
+ """
40
+ if kind == "structural" and assertion in STRUCTURAL_ASSERTIONS:
41
+ return "code-scan"
42
+ if kind == "behavioral":
43
+ return "runtime"
44
+ return "pending"
45
+
46
+
47
+ # Backwards-compatible private alias (backend historically referenced the
48
+ # underscore-prefixed name).
49
+ _infer_verifier = infer_verifier
50
+
51
+
52
+ def normalize_contract_input(payload: dict | None) -> dict | None:
53
+ """Coerce a raw intent payload into a canonical contract structure.
54
+
55
+ Returns ``None`` when no meaningful contract was supplied (no criteria and no
56
+ title), so callers can treat "no intent" as a first-class, non-blocking case.
57
+ Criterion keys are assigned deterministically (``criterion-1``, ...) so the
58
+ persisted rows and any downstream verdicts reference the same identifiers.
59
+ """
60
+ if not isinstance(payload, dict):
61
+ return None
62
+
63
+ title = str(payload.get("title", "") or "").strip()
64
+ source = str(payload.get("source", "api") or "api").strip().lower() or "api"
65
+ raw_non_goals = payload.get("non_goals")
66
+ non_goals = (
67
+ [str(item).strip() for item in raw_non_goals if str(item).strip()]
68
+ if isinstance(raw_non_goals, list)
69
+ else []
70
+ )
71
+
72
+ raw_criteria = payload.get("criteria")
73
+ criteria: list[dict] = []
74
+ if isinstance(raw_criteria, list):
75
+ for index, item in enumerate(raw_criteria, start=1):
76
+ if not isinstance(item, dict):
77
+ continue
78
+ kind = str(item.get("kind", "structural") or "structural").strip().lower()
79
+ if kind not in {"structural", "behavioral"}:
80
+ kind = "structural"
81
+ assertion = str(item.get("assert", item.get("assertion", "")) or "").strip().lower()
82
+ key = str(item.get("id", "") or f"criterion-{index}").strip() or f"criterion-{index}"
83
+ risk_tier = str(item.get("risk_tier", "standard") or "standard").strip().lower()
84
+ if risk_tier not in _VALID_RISK_TIERS:
85
+ risk_tier = "standard"
86
+ criteria.append(
87
+ {
88
+ "criterion_key": key,
89
+ "statement": str(item.get("statement", "") or "").strip(),
90
+ "kind": kind,
91
+ "risk_tier": risk_tier,
92
+ "verifier": infer_verifier(kind, assertion),
93
+ "assertion": assertion,
94
+ "path": str(item.get("path", "") or "").strip().lstrip("/"),
95
+ "expected": str(item.get("expected", item.get("contains", "")) or "").strip(),
96
+ "notes": str(item.get("notes", "") or "").strip(),
97
+ }
98
+ )
99
+
100
+ if not title and not criteria:
101
+ return None
102
+
103
+ return {"title": title, "source": source, "non_goals": non_goals, "criteria": criteria}
104
+
105
+
106
+ def compute_content_hash(canonical: dict) -> str:
107
+ """Stable content hash over the canonical contract (docs/39 Part E)."""
108
+ encoded = json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode("utf-8")
109
+ return hashlib.sha256(encoded).hexdigest()
110
+
111
+
112
+ def required_permission_for_risk_tier(risk_tier: str) -> str:
113
+ """Minimum permission required to amend a criterion of this tier (FR-08)."""
114
+ return "admin" if str(risk_tier or "standard").strip().lower() in ELEVATED_RISK_TIERS else "write"
115
+
116
+
117
+ def summarize_verdicts(verdicts: list[str]) -> dict:
118
+ """Aggregate criterion verdicts into a gate decision (pure).
119
+
120
+ Result values:
121
+ - ``not_provided``: no verdicts, or only ``not_applicable``.
122
+ - ``pending``: some criteria still unverified.
123
+ - ``passed``: at least one satisfied and none failed/pending.
124
+ - ``blocked``: at least one ``failed``/``escalated``.
125
+ """
126
+ passed = failed = pending = not_applicable = 0
127
+ for verdict in verdicts:
128
+ if verdict == "passed":
129
+ passed += 1
130
+ elif verdict in {"failed", "escalated"}:
131
+ failed += 1
132
+ elif verdict == "pending":
133
+ pending += 1
134
+ else:
135
+ not_applicable += 1
136
+
137
+ if failed:
138
+ result, reason = "blocked", "one_or_more_criteria_failed"
139
+ elif pending:
140
+ result, reason = "pending", "verification_incomplete"
141
+ elif passed:
142
+ result, reason = "passed", "all_verifiable_criteria_satisfied"
143
+ else:
144
+ result, reason = "not_provided", "no_verifiable_criteria"
145
+
146
+ return {
147
+ "result": result,
148
+ "criteria_total": len(verdicts),
149
+ "criteria_passed": passed,
150
+ "criteria_failed": failed,
151
+ "criteria_pending": pending,
152
+ "criteria_not_applicable": not_applicable,
153
+ "reason": reason,
154
+ }
@@ -60,6 +60,8 @@ APPLIED = "applied"
60
60
  DENIED = "denied"
61
61
  FAILED = "failed"
62
62
  DEFERRED = "deferred"
63
+ # R29: an applied RR whose per-request commit was reverted via ``git revert``.
64
+ REVERTED = "reverted"
63
65
 
64
66
  MAX_ATTEMPTS = 3
65
67
  _RENAME_WITH_KIND_RE = re.compile(
@@ -459,6 +461,13 @@ class RefactorRequest:
459
461
  attempts: list[RefactorAttempt] = field(default_factory=list)
460
462
  created_by_run: str = ""
461
463
  resolved_in_run: str = ""
464
+ # R29 (local refactor branch): the per-request git commit recorded on the
465
+ # isolated ``refactor/*`` branch, the branch it lives on, and whether that
466
+ # commit has since been reverted. Empty/False for requests resolved without
467
+ # the branch/auto-commit flow.
468
+ commit_sha: str = ""
469
+ refactor_branch: str = ""
470
+ reverted: bool = False
462
471
  created_at: str = field(default_factory=_now_iso)
463
472
  updated_at: str = field(default_factory=_now_iso)
464
473
 
@@ -515,6 +524,9 @@ class RefactorRequest:
515
524
  "attempts": [a.to_dict() for a in self.attempts],
516
525
  "created_by_run": self.created_by_run,
517
526
  "resolved_in_run": self.resolved_in_run,
527
+ "commit_sha": self.commit_sha,
528
+ "refactor_branch": self.refactor_branch,
529
+ "reverted": self.reverted,
518
530
  "created_at": self.created_at,
519
531
  "updated_at": self.updated_at,
520
532
  }
@@ -550,6 +562,9 @@ class RefactorRequest:
550
562
  attempts=[RefactorAttempt.from_dict(a) for a in data.get("attempts", [])],
551
563
  created_by_run=str(data.get("created_by_run", "")),
552
564
  resolved_in_run=str(data.get("resolved_in_run", "")),
565
+ commit_sha=str(data.get("commit_sha", "")),
566
+ refactor_branch=str(data.get("refactor_branch", "")),
567
+ reverted=bool(data.get("reverted", False)),
553
568
  created_at=str(data.get("created_at", _now_iso())),
554
569
  updated_at=str(data.get("updated_at", _now_iso())),
555
570
  )
@@ -852,6 +867,9 @@ def _update_index(project_root: Path | str, rr: RefactorRequest) -> None:
852
867
  "severity": rr.severity,
853
868
  "category": rr.category,
854
869
  "attempt_count": rr.attempt_count,
870
+ "commit_sha": rr.commit_sha,
871
+ "refactor_branch": rr.refactor_branch,
872
+ "reverted": rr.reverted,
855
873
  "updated_at": rr.updated_at,
856
874
  }
857
875
  index["requests"] = entries
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: refactorai-core
3
- Version: 3.2.2
3
+ Version: 3.2.5
4
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
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -31,6 +31,9 @@ refactor_core/compliance/packs/iso27001-sdlc.json
31
31
  refactor_core/compliance/packs/pci-payments.json
32
32
  refactor_core/compliance/packs/privacy-gdpr.json
33
33
  refactor_core/compliance/packs/soc2-core.json
34
+ refactor_core/intent/__init__.py
35
+ refactor_core/intent/capture.py
36
+ refactor_core/intent/contract.py
34
37
  refactor_core/rules/__init__.py
35
38
  refactor_core/rules/injection.py
36
39
  refactor_core/rules/loader.py