evagix 0.1.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.
Files changed (198) hide show
  1. evagix/__init__.py +1 -0
  2. evagix/__main__.py +4 -0
  3. evagix/agent_context_registry.py +162 -0
  4. evagix/changes.py +358 -0
  5. evagix/classification.py +197 -0
  6. evagix/classification_models.py +27 -0
  7. evagix/classification_rules.py +155 -0
  8. evagix/cli.py +8 -0
  9. evagix/command_analysis.py +382 -0
  10. evagix/command_recipes.py +354 -0
  11. evagix/command_safety.py +380 -0
  12. evagix/command_shell.py +168 -0
  13. evagix/command_text.py +99 -0
  14. evagix/commands/__init__.py +1 -0
  15. evagix/commands/agents.py +21 -0
  16. evagix/commands/common.py +100 -0
  17. evagix/commands/context_pack.py +11 -0
  18. evagix/commands/core_cli.py +137 -0
  19. evagix/commands/fix_apply.py +42 -0
  20. evagix/commands/fix_cli.py +40 -0
  21. evagix/commands/fix_plan.py +41 -0
  22. evagix/commands/generation.py +398 -0
  23. evagix/commands/generation_safety.py +96 -0
  24. evagix/commands/git_cli.py +36 -0
  25. evagix/commands/git_cmds.py +62 -0
  26. evagix/commands/inspect.py +276 -0
  27. evagix/commands/inspect_cli.py +71 -0
  28. evagix/commands/mcp.py +27 -0
  29. evagix/commands/prepare.py +55 -0
  30. evagix/commands/preview_cli.py +44 -0
  31. evagix/commands/preview_cmds.py +39 -0
  32. evagix/commands/readiness_cli.py +201 -0
  33. evagix/commands/registry.py +54 -0
  34. evagix/commands/report_audit.py +177 -0
  35. evagix/commands/report_doctor.py +147 -0
  36. evagix/commands/report_misc.py +76 -0
  37. evagix/commands/report_readme_context.py +164 -0
  38. evagix/commands/reports_cmds.py +67 -0
  39. evagix/config.py +12 -0
  40. evagix/config_loader.py +130 -0
  41. evagix/config_models.py +39 -0
  42. evagix/config_validate.py +154 -0
  43. evagix/constants.py +6 -0
  44. evagix/context/__init__.py +1 -0
  45. evagix/context/command_checks.py +268 -0
  46. evagix/context/content_checks.py +87 -0
  47. evagix/context/eval_engine.py +400 -0
  48. evagix/context/eval_models.py +26 -0
  49. evagix/context/eval_rendering.py +83 -0
  50. evagix/context/files.py +204 -0
  51. evagix/context/quality.py +66 -0
  52. evagix/context_eval.py +13 -0
  53. evagix/context_quality.py +7 -0
  54. evagix/core/__init__.py +8 -0
  55. evagix/core/collections.py +24 -0
  56. evagix/core/constants.py +5 -0
  57. evagix/core/fs.py +35 -0
  58. evagix/core/io.py +210 -0
  59. evagix/core/models.py +32 -0
  60. evagix/core/paths.py +10 -0
  61. evagix/core/text.py +21 -0
  62. evagix/decide.py +303 -0
  63. evagix/drift.py +130 -0
  64. evagix/ecosystems/__init__.py +13 -0
  65. evagix/ecosystems/commands.py +195 -0
  66. evagix/ecosystems/core.py +34 -0
  67. evagix/ecosystems/manifests.py +290 -0
  68. evagix/ecosystems/node.py +119 -0
  69. evagix/ecosystems/profiles.py +252 -0
  70. evagix/ecosystems/python.py +170 -0
  71. evagix/ecosystems/utils.py +328 -0
  72. evagix/evidence.py +236 -0
  73. evagix/explain.py +98 -0
  74. evagix/fixes.py +102 -0
  75. evagix/generated_integrity.py +165 -0
  76. evagix/model.py +183 -0
  77. evagix/onboard.py +226 -0
  78. evagix/pr_risk.py +242 -0
  79. evagix/profiles.py +182 -0
  80. evagix/prompt_injection.py +321 -0
  81. evagix/py.typed +0 -0
  82. evagix/readme/__init__.py +1 -0
  83. evagix/readme/audit_engine.py +377 -0
  84. evagix/readme/claim_checks.py +350 -0
  85. evagix/readme/claim_registry.py +134 -0
  86. evagix/readme/claim_rules.py +5 -0
  87. evagix/readme/claim_text_scan.py +85 -0
  88. evagix/readme/command_extractor.py +209 -0
  89. evagix/readme/evidence_matcher.py +120 -0
  90. evagix/readme/findings.py +69 -0
  91. evagix/readme/source.py +84 -0
  92. evagix/readme/text_utils.py +396 -0
  93. evagix/readme_audit.py +25 -0
  94. evagix/renderers.py +53 -0
  95. evagix/rendering/__init__.py +1 -0
  96. evagix/rendering/context.py +148 -0
  97. evagix/rendering/fingerprints.py +12 -0
  98. evagix/rendering/payloads.py +99 -0
  99. evagix/rendering/sections.py +70 -0
  100. evagix/rendering/sections_core.py +157 -0
  101. evagix/rendering/sections_domain.py +135 -0
  102. evagix/rendering/sections_policy.py +161 -0
  103. evagix/rendering/target_renderers.py +139 -0
  104. evagix/report_models.py +65 -0
  105. evagix/reports/__init__.py +1 -0
  106. evagix/reports/context_pack.py +69 -0
  107. evagix/reports/json.py +20 -0
  108. evagix/reports/locations.py +79 -0
  109. evagix/reports/markdown.py +26 -0
  110. evagix/reports/pr_comment.py +33 -0
  111. evagix/reports/sarif.py +105 -0
  112. evagix/reports/text.py +17 -0
  113. evagix/repository_intent.py +344 -0
  114. evagix/rules/__init__.py +18 -0
  115. evagix/rules/_shared.py +19 -0
  116. evagix/rules/agent_compatibility.py +12 -0
  117. evagix/rules/aliases.py +30 -0
  118. evagix/rules/commands.py +12 -0
  119. evagix/rules/context.py +12 -0
  120. evagix/rules/mcp.py +12 -0
  121. evagix/rules/models.py +34 -0
  122. evagix/rules/pr_risk.py +12 -0
  123. evagix/rules/readme.py +12 -0
  124. evagix/rules/readme_source.py +9 -0
  125. evagix/rules/registry.py +400 -0
  126. evagix/rules/safety.py +12 -0
  127. evagix/rules/static_rules.py +78 -0
  128. evagix/safety.py +121 -0
  129. evagix/scanner.py +7 -0
  130. evagix/scanner_utils.py +362 -0
  131. evagix/scanners/__init__.py +1 -0
  132. evagix/scanners/agent_files.py +89 -0
  133. evagix/scanners/commands.py +21 -0
  134. evagix/scanners/context_pack.py +10 -0
  135. evagix/scanners/ecosystems.py +14 -0
  136. evagix/scanners/git_diff.py +9 -0
  137. evagix/scanners/mcp.py +33 -0
  138. evagix/scanners/readme.py +11 -0
  139. evagix/scanners/repository.py +22 -0
  140. evagix/scanners/safety.py +19 -0
  141. evagix/scanning/__init__.py +1 -0
  142. evagix/scanning/base.py +164 -0
  143. evagix/scanning/command_evidence.py +152 -0
  144. evagix/scanning/infrastructure.py +280 -0
  145. evagix/scanning/node_policy.py +179 -0
  146. evagix/scanning/polyglot_extra.py +113 -0
  147. evagix/scanning/project_evidence.py +384 -0
  148. evagix/scanning/python_evidence.py +398 -0
  149. evagix/scanning/repository.py +57 -0
  150. evagix/scanning/shared.py +82 -0
  151. evagix/schemas/__init__.py +1 -0
  152. evagix/schemas/audit-report.schema.json +145 -0
  153. evagix/schemas/changed-report.schema.json +40 -0
  154. evagix/schemas/classification-report.schema.json +55 -0
  155. evagix/schemas/context-eval.schema.json +154 -0
  156. evagix/schemas/decision-plan.schema.json +27 -0
  157. evagix/schemas/doctor-report.schema.json +117 -0
  158. evagix/schemas/drift-report.schema.json +91 -0
  159. evagix/schemas/evagix-config.schema.json +212 -0
  160. evagix/schemas/evidence.schema.json +62 -0
  161. evagix/schemas/policy-report.schema.json +45 -0
  162. evagix/schemas/pr-risk-report.schema.json +67 -0
  163. evagix/schemas/readme-audit.schema.json +326 -0
  164. evagix/schemas/scan-facts.schema.json +149 -0
  165. evagix/scoped.py +76 -0
  166. evagix/scoring/__init__.py +5 -0
  167. evagix/scoring/breakdown.py +10 -0
  168. evagix/scoring/engine.py +25 -0
  169. evagix/scoring/weights.py +9 -0
  170. evagix/security/__init__.py +12 -0
  171. evagix/security/assignment_patterns.py +45 -0
  172. evagix/security/labels.py +29 -0
  173. evagix/security/output.py +78 -0
  174. evagix/security/redaction.py +385 -0
  175. evagix/signatures.py +156 -0
  176. evagix/strict_scoring.py +243 -0
  177. evagix/targets.py +206 -0
  178. evagix/task_renderers.py +180 -0
  179. evagix/templates.py +186 -0
  180. evagix/text_diagnostics.py +33 -0
  181. evagix/thresholds.py +50 -0
  182. evagix/utils.py +86 -0
  183. evagix/validation/__init__.py +35 -0
  184. evagix/validation/audit.py +58 -0
  185. evagix/validation/audit_actions.py +37 -0
  186. evagix/validation/audit_rules.py +46 -0
  187. evagix/validation/checks.py +252 -0
  188. evagix/validation/doctor.py +228 -0
  189. evagix/validation/generated_context.py +253 -0
  190. evagix/validation/rendering.py +128 -0
  191. evagix/validation/scoring.py +197 -0
  192. evagix/validators.py +41 -0
  193. evagix-0.1.0.dist-info/METADATA +454 -0
  194. evagix-0.1.0.dist-info/RECORD +198 -0
  195. evagix-0.1.0.dist-info/WHEEL +5 -0
  196. evagix-0.1.0.dist-info/entry_points.txt +3 -0
  197. evagix-0.1.0.dist-info/licenses/LICENSE +201 -0
  198. evagix-0.1.0.dist-info/top_level.txt +1 -0
evagix/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
evagix/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from evagix.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from evagix.scanner_utils import TraversalDiagnostics, _iter_repo_files, is_safe_repo_path
7
+ from evagix.targets import TARGET_ADAPTERS
8
+
9
+ AGENT_CONTEXT_SUFFIXES = frozenset({".md", ".mdc", ".txt", ".toml", ".json", ".yml", ".yaml"})
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class AgentContextSource:
14
+ """One supported agent-context file or directory family."""
15
+
16
+ path: str
17
+ tool: str
18
+ kind: str = "file"
19
+ confidence: str = "high"
20
+
21
+
22
+ _STATIC_FILE_SOURCES: tuple[AgentContextSource, ...] = (
23
+ AgentContextSource("AGENTS.override.md", "generic agents override"),
24
+ AgentContextSource(".claude/CLAUDE.md", "Claude Code"),
25
+ AgentContextSource(".cursorrules", "Cursor"),
26
+ AgentContextSource(".windsurfrules", "Windsurf"),
27
+ )
28
+
29
+
30
+ _TOOL_OVERRIDES = {
31
+ "AGENTS.md": "OpenAI Codex / AGENTS.md-compatible agents",
32
+ "CLAUDE.md": "Claude Code",
33
+ "GEMINI.md": "Gemini CLI",
34
+ ".github/copilot-instructions.md": "GitHub Copilot",
35
+ ".cursor/rules/project.mdc": "Cursor",
36
+ ".windsurf/rules/evagix.md": "Windsurf",
37
+ ".continue/rules/evagix.md": "Continue",
38
+ ".clinerules": "Cline",
39
+ ".roo/rules/evagix.md": "Roo Code",
40
+ "CONVENTIONS.md": "generic conventions",
41
+ ".openhands/skills/repository/SKILL.md": "OpenHands",
42
+ }
43
+
44
+ _DIRECTORY_SOURCES: tuple[AgentContextSource, ...] = (
45
+ AgentContextSource(".claude/rules", "Claude Code", kind="directory", confidence="medium"),
46
+ AgentContextSource(".github/instructions", "GitHub Copilot", kind="directory", confidence="medium"),
47
+ AgentContextSource(".cursor/rules", "Cursor", kind="directory", confidence="medium"),
48
+ AgentContextSource(".windsurf/rules", "Windsurf", kind="directory", confidence="medium"),
49
+ AgentContextSource(".continue/rules", "Continue", kind="directory", confidence="medium"),
50
+ AgentContextSource(".roo/rules", "Roo Code", kind="directory", confidence="medium"),
51
+ AgentContextSource(".openhands/microagents", "OpenHands", kind="directory", confidence="medium"),
52
+ AgentContextSource(".openhands/skills", "OpenHands", kind="directory", confidence="medium"),
53
+ )
54
+
55
+
56
+ def agent_context_file_sources() -> tuple[AgentContextSource, ...]:
57
+ """Return the canonical exact-path agent-context registry."""
58
+
59
+ target_sources = tuple(
60
+ AgentContextSource(adapter.path, _TOOL_OVERRIDES.get(adapter.path, adapter.label))
61
+ for adapter in TARGET_ADAPTERS.values()
62
+ if adapter.category == "tool-adapter"
63
+ )
64
+ by_path = {source.path: source for source in (*target_sources, *_STATIC_FILE_SOURCES)}
65
+ return tuple(by_path[path] for path in sorted(by_path))
66
+
67
+
68
+ def agent_context_directory_sources() -> tuple[AgentContextSource, ...]:
69
+ return _DIRECTORY_SOURCES
70
+
71
+
72
+ def agent_context_exact_paths() -> frozenset[str]:
73
+ return frozenset(source.path for source in agent_context_file_sources())
74
+
75
+
76
+ def generated_agent_target_paths() -> tuple[str, ...]:
77
+ return tuple(
78
+ sorted(
79
+ adapter.path
80
+ for adapter in TARGET_ADAPTERS.values()
81
+ if adapter.category in {"tool-adapter", "universal-context"}
82
+ )
83
+ )
84
+
85
+
86
+ def declared_agent_context_paths(root: Path) -> tuple[Path, ...]:
87
+ """Return every exact file, generated target, and directory trust boundary."""
88
+
89
+ relative_paths = {source.path for source in agent_context_file_sources()}
90
+ relative_paths.update(source.path for source in agent_context_directory_sources())
91
+ relative_paths.update(generated_agent_target_paths())
92
+ return tuple(root / relative for relative in sorted(relative_paths))
93
+
94
+
95
+ def unsafe_declared_agent_context_paths(root: Path) -> tuple[Path, ...]:
96
+ """Return declared context boundaries that escape through a symlink."""
97
+
98
+ unsafe: list[Path] = []
99
+ for path in declared_agent_context_paths(root):
100
+ try:
101
+ if (path.exists() or path.is_symlink()) and not is_safe_repo_path(root, path):
102
+ unsafe.append(path)
103
+ except OSError:
104
+ continue
105
+ return tuple(unsafe)
106
+
107
+
108
+ def iter_agent_context_paths(
109
+ root: Path,
110
+ *,
111
+ limit: int = 200,
112
+ diagnostics: TraversalDiagnostics | None = None,
113
+ ) -> list[tuple[Path, AgentContextSource]]:
114
+ """Discover every supported agent-context path through one bounded registry."""
115
+
116
+ state = diagnostics or TraversalDiagnostics()
117
+ results: list[tuple[Path, AgentContextSource]] = []
118
+ seen: set[str] = set()
119
+
120
+ for source in agent_context_file_sources():
121
+ path = root / source.path
122
+ try:
123
+ if path.is_file() and is_safe_repo_path(root, path):
124
+ key = path.relative_to(root).as_posix()
125
+ if key not in seen:
126
+ if len(results) >= limit:
127
+ state.result_limit_reached = True
128
+ return results
129
+ seen.add(key)
130
+ results.append((path, source))
131
+ except OSError:
132
+ state.read_errors += 1
133
+ continue
134
+
135
+ for source in agent_context_directory_sources():
136
+ directory = root / source.path
137
+ try:
138
+ if not directory.is_dir() or not is_safe_repo_path(root, directory):
139
+ continue
140
+ except OSError:
141
+ state.read_errors += 1
142
+ continue
143
+ for path in _iter_repo_files(
144
+ root,
145
+ start=directory,
146
+ allow_skipped_start=True,
147
+ diagnostics=state,
148
+ ):
149
+ if path.suffix.lower() not in AGENT_CONTEXT_SUFFIXES:
150
+ continue
151
+ key = path.relative_to(root).as_posix()
152
+ if key in seen:
153
+ continue
154
+ if len(results) >= limit:
155
+ state.result_limit_reached = True
156
+ return results
157
+ seen.add(key)
158
+ results.append((path, source))
159
+ if state.truncated:
160
+ return results
161
+
162
+ return sorted(results, key=lambda item: item[0].relative_to(root).as_posix())
evagix/changes.py ADDED
@@ -0,0 +1,358 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from collections.abc import Mapping
5
+ from dataclasses import asdict, dataclass
6
+ from pathlib import Path
7
+
8
+ from evagix.core.collections import unique_preserving_order
9
+ from evagix.core.text import escape_github_command_value
10
+ from evagix.safety import GIT_REVISION_POLICY
11
+ from evagix.security.output import redacted_text_output
12
+ from evagix.security.redaction import redact_sensitive_text
13
+ from evagix.utils import stable_json
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ChangedFileRisk:
18
+ path: str
19
+ risk: str
20
+ reason: str
21
+
22
+ def __post_init__(self) -> None:
23
+ object.__setattr__(self, "path", redact_sensitive_text(self.path))
24
+ object.__setattr__(self, "reason", redact_sensitive_text(self.reason))
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ChangedReport:
29
+ base: str
30
+ files: list[ChangedFileRisk]
31
+ required_gates: list[str]
32
+ head: str = "HEAD"
33
+
34
+ def __post_init__(self) -> None:
35
+ object.__setattr__(self, "base", redact_sensitive_text(self.base))
36
+ object.__setattr__(self, "head", redact_sensitive_text(self.head))
37
+ object.__setattr__(self, "files", list(self.files))
38
+ object.__setattr__(self, "required_gates", [redact_sensitive_text(item) for item in self.required_gates])
39
+
40
+ @property
41
+ def has_high_risk(self) -> bool:
42
+ return any(item.risk == "HIGH" for item in self.files)
43
+
44
+
45
+ def build_changed_report(
46
+ root: Path,
47
+ base: str = "main",
48
+ head: str = "HEAD",
49
+ *,
50
+ commands: Mapping[str, str] | None = None,
51
+ ) -> ChangedReport:
52
+ clean_base = GIT_REVISION_POLICY.clean_ref(base, field_name="base ref")
53
+ clean_head = GIT_REVISION_POLICY.clean_ref(head, field_name="head ref")
54
+ files = [_classify_changed_path(item) for item in _git_changed_files(root, clean_base, head=clean_head)]
55
+ return ChangedReport(
56
+ base=clean_base, files=files, required_gates=_required_gates(files, commands=commands), head=clean_head
57
+ )
58
+
59
+
60
+ @redacted_text_output
61
+ def render_changed_text(report: ChangedReport) -> str:
62
+ lines = [f"Changed files risk assessment against `{report.base}`:", ""]
63
+ if not report.files:
64
+ lines.append("No changed files detected.")
65
+ for item in report.files:
66
+ lines.append(f" {item.risk:<6} {item.path:<45} — {item.reason}")
67
+ lines.extend(["", "Required gates for this change set:"])
68
+ for gate in report.required_gates:
69
+ lines.append(f" - {gate}")
70
+ return "\n".join(lines).rstrip() + "\n"
71
+
72
+
73
+ def render_changed_json(report: ChangedReport) -> str:
74
+ payload = {
75
+ "schema_version": "1.0",
76
+ "tool": "evagix",
77
+ "base": report.base,
78
+ "head": report.head,
79
+ "files": [asdict(item) for item in report.files],
80
+ "required_gates": report.required_gates,
81
+ "has_high_risk": report.has_high_risk,
82
+ }
83
+ return stable_json(payload) + "\n"
84
+
85
+
86
+ @redacted_text_output
87
+ def render_changed_github_annotations(report: ChangedReport) -> str:
88
+ lines: list[str] = []
89
+ for item in report.files:
90
+ level = "error" if item.risk == "HIGH" else "warning" if item.risk == "MEDIUM" else "notice"
91
+ message = escape_github_command_value(f"{item.risk} risk changed file: {item.reason}")
92
+ path = escape_github_command_value(item.path)
93
+ lines.append(f"::{level} file={path},line=1,title=Evagix changed-file risk::{message}")
94
+ if not lines:
95
+ lines.append("::notice title=Evagix changed-file risk::No changed files detected")
96
+ return "\n".join(lines) + "\n"
97
+
98
+
99
+ def _git_changed_files(root: Path, base: str, head: str = "HEAD") -> list[str]:
100
+ clean_base = GIT_REVISION_POLICY.clean_ref(base, field_name="base ref")
101
+ clean_head = GIT_REVISION_POLICY.clean_ref(head, field_name="head ref")
102
+ candidates = (
103
+ ["git", "diff", "--name-only", "-z", f"{clean_base}...{clean_head}", "--"],
104
+ ["git", "diff", "--name-only", "-z", clean_base, clean_head, "--"],
105
+ )
106
+ last_error = ""
107
+ for command in candidates:
108
+ completed = _run_git(command, root)
109
+ if completed.returncode == 0:
110
+ changed = set(_nul_delimited_paths(completed.stdout))
111
+ worktree = _run_git(["git", "diff", "--name-only", "-z"], root)
112
+ if worktree.returncode == 0:
113
+ changed.update(_nul_delimited_paths(worktree.stdout))
114
+ staged = _run_git(["git", "diff", "--cached", "--name-only", "-z"], root)
115
+ if staged.returncode == 0:
116
+ changed.update(_nul_delimited_paths(staged.stdout))
117
+ untracked = _run_git(["git", "ls-files", "--others", "--exclude-standard", "-z"], root)
118
+ if untracked.returncode == 0:
119
+ changed.update(_nul_delimited_paths(untracked.stdout))
120
+ return sorted(changed)
121
+ last_error = (completed.stderr or completed.stdout or "git diff failed").strip()
122
+ raise RuntimeError(_format_changed_files_error(root, clean_base, clean_head, last_error))
123
+
124
+
125
+ def _nul_delimited_paths(output: str) -> list[str]:
126
+ """Parse Git ``-z`` output without corrupting unusual file names.
127
+
128
+ Git permits tabs and newlines in paths. NUL-delimited output is therefore
129
+ the only unambiguous machine-readable form for changed-file discovery.
130
+ """
131
+
132
+ return [item for item in output.split("\0") if item]
133
+
134
+
135
+ def _format_changed_files_error(root: Path, base: str, head: str, git_error: str) -> str:
136
+ message = f"Could not inspect changed files between {base!r} and {head!r}: {git_error}"
137
+ hint = _missing_base_ref_hint(root, base)
138
+ if hint:
139
+ return f"{message}\n{hint}"
140
+ return message
141
+
142
+
143
+ def _missing_base_ref_hint(root: Path, base: str) -> str | None:
144
+ branches = _local_branch_names(root)
145
+ if not branches:
146
+ return None
147
+ if base in branches:
148
+ return None
149
+ preferred = _preferred_branch_suggestion(base, branches)
150
+ if preferred is not None:
151
+ return f"Base ref `{base}` was not found. Detected local branch: {preferred}. Try: --base {preferred}"
152
+ if len(branches) == 1:
153
+ branch = branches[0]
154
+ return f"Base ref `{base}` was not found. Detected local branch: {branch}. Try: --base {branch}"
155
+ return f"Base ref `{base}` was not found. Available local branches: {', '.join(branches[:6])}."
156
+
157
+
158
+ def _preferred_branch_suggestion(base: str, branches: list[str]) -> str | None:
159
+ branch_set = set(branches)
160
+ if base == "main" and "master" in branch_set:
161
+ return "master"
162
+ if base == "master" and "main" in branch_set:
163
+ return "main"
164
+ if base.startswith("origin/"):
165
+ local = base.removeprefix("origin/")
166
+ if local in branch_set:
167
+ return local
168
+ return None
169
+
170
+
171
+ def _local_branch_names(root: Path) -> list[str]:
172
+ completed = _run_git(["git", "branch", "--format=%(refname:short)"], root)
173
+ if completed.returncode != 0:
174
+ return []
175
+ return sorted(item.strip().lstrip("* ") for item in completed.stdout.splitlines() if item.strip())
176
+
177
+
178
+ def _run_git(command: list[str], root: Path) -> subprocess.CompletedProcess[str]:
179
+ try:
180
+ # POSIX Git paths are byte sequences. Render undecodable bytes with a
181
+ # replacement marker so reports remain valid text instead of crashing.
182
+ return subprocess.run(
183
+ command,
184
+ cwd=root,
185
+ text=True,
186
+ encoding="utf-8",
187
+ errors="replace",
188
+ capture_output=True,
189
+ check=False,
190
+ timeout=30,
191
+ )
192
+ except subprocess.TimeoutExpired as exc:
193
+ raise RuntimeError("Git command timed out while inspecting changed files.") from exc
194
+ except FileNotFoundError as exc:
195
+ raise RuntimeError(
196
+ "Git executable was not found. Install Git or run Evagix PR/change review in an environment with Git available."
197
+ ) from exc
198
+ except OSError as exc:
199
+ raise RuntimeError(f"Could not run Git command {' '.join(command)!r}: {exc}") from exc
200
+
201
+
202
+ def _classify_changed_path(path: str) -> ChangedFileRisk:
203
+ normalized = path.replace("\\", "/").strip("/")
204
+ lower = normalized.lower()
205
+ if not normalized:
206
+ return ChangedFileRisk(path=path, risk="LOW", reason="empty path")
207
+ # Non-executable documentation, tests, and examples remain low-risk even
208
+ # when their paths describe sensitive concepts such as auth, migrations,
209
+ # or deployment. Operational paths are evaluated only after this context.
210
+ if _matches_prefix(lower, LOW_RISK_PREFIXES) or _matches_suffix(lower, LOW_RISK_SUFFIXES):
211
+ return ChangedFileRisk(path=normalized, risk="LOW", reason="documentation, tests, or examples")
212
+ if (
213
+ _matches_prefix(lower, HIGH_RISK_PREFIXES)
214
+ or _matches_name(lower, HIGH_RISK_NAMES)
215
+ or _matches_segment(lower, HIGH_RISK_SEGMENTS)
216
+ ):
217
+ return ChangedFileRisk(path=normalized, risk="HIGH", reason=_high_risk_reason(lower))
218
+ if _matches_suffix(lower, MEDIUM_RISK_SUFFIXES) or _matches_name(lower, MEDIUM_RISK_NAMES):
219
+ return ChangedFileRisk(path=normalized, risk="MEDIUM", reason="project configuration or package metadata")
220
+ return ChangedFileRisk(path=normalized, risk="MEDIUM", reason="source or unclassified project file")
221
+
222
+
223
+ def _required_gates(files: list[ChangedFileRisk], *, commands: Mapping[str, str] | None = None) -> list[str]:
224
+ gates = ["evagix check"]
225
+ if not files:
226
+ return gates
227
+ risks = {item.risk for item in files}
228
+ paths = [item.path.lower() for item in files]
229
+ if any(path.endswith(".py") for path in paths):
230
+ gates.extend(_python_validation_gates(commands))
231
+ if any(path.startswith("tests/") for path in paths):
232
+ gates.append("pytest")
233
+ if "HIGH" in risks:
234
+ gates.extend(["evagix doctor", "human approval"])
235
+ if any(path.startswith(".github/workflows/") for path in paths):
236
+ gates.extend(["workflow review", "evagix doctor"])
237
+ return unique_preserving_order(gates)
238
+
239
+
240
+ def _python_validation_gates(commands: Mapping[str, str] | None = None) -> list[str]:
241
+ configured = commands or {}
242
+ return [
243
+ configured.get("lint", "ruff check ."),
244
+ configured.get("typecheck", "mypy ."),
245
+ configured.get("test", "pytest"),
246
+ ]
247
+
248
+
249
+ def _high_risk_reason(path: str) -> str:
250
+ if path.startswith(".github/workflows/"):
251
+ return "CI workflow can execute shell commands"
252
+ if path in {"pyproject.toml", "setup.py", "setup.cfg"} or path.endswith("lock"):
253
+ return "dependency, build, or package metadata change"
254
+ if _matches_segment(path, MIGRATION_SEGMENTS):
255
+ return "database migration path"
256
+ if _matches_segment(path, DEPLOYMENT_SEGMENTS) or _matches_segment(path, SECURITY_SENSITIVE_SEGMENTS):
257
+ return "security, auth, billing, permissions, or deployment-sensitive path"
258
+ if path.endswith(("dockerfile", "docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml")):
259
+ return "container or runtime orchestration change"
260
+ if ".env" in path or "secret" in path or "private" in path:
261
+ return "secret-bearing or environment-sensitive path"
262
+ return "high-risk repository path"
263
+
264
+
265
+ def _matches_prefix(path: str, prefixes: tuple[str, ...]) -> bool:
266
+ return any(path.startswith(prefix) for prefix in prefixes)
267
+
268
+
269
+ def _matches_suffix(path: str, suffixes: tuple[str, ...]) -> bool:
270
+ return any(path.endswith(suffix) for suffix in suffixes)
271
+
272
+
273
+ def _matches_name(path: str, names: tuple[str, ...]) -> bool:
274
+ return path in names or any(path.endswith(f"/{name}") for name in names)
275
+
276
+
277
+ def _matches_segment(path: str, segments: tuple[str, ...]) -> bool:
278
+ parts = tuple(part for part in path.split("/") if part)
279
+ return any(part in segments for part in parts)
280
+
281
+
282
+ HIGH_RISK_PREFIXES = (
283
+ ".github/workflows/",
284
+ "migrations/",
285
+ "alembic/",
286
+ "auth/",
287
+ "security/",
288
+ "infra/",
289
+ "deploy/",
290
+ )
291
+ MIGRATION_SEGMENTS = (
292
+ "migrations",
293
+ "alembic",
294
+ )
295
+ DEPLOYMENT_SEGMENTS = (
296
+ "infra",
297
+ "infrastructure",
298
+ "deploy",
299
+ "deployment",
300
+ "terraform",
301
+ "k8s",
302
+ "kubernetes",
303
+ )
304
+ SECURITY_SENSITIVE_SEGMENTS = (
305
+ "auth",
306
+ "security",
307
+ "permissions",
308
+ "permission",
309
+ "jwt",
310
+ "token",
311
+ "tokens",
312
+ "billing",
313
+ "payment",
314
+ "payments",
315
+ "secret",
316
+ "secrets",
317
+ )
318
+ HIGH_RISK_SEGMENTS = MIGRATION_SEGMENTS + DEPLOYMENT_SEGMENTS + SECURITY_SENSITIVE_SEGMENTS
319
+
320
+ HIGH_RISK_NAMES = (
321
+ "pyproject.toml",
322
+ "setup.py",
323
+ "setup.cfg",
324
+ "requirements.txt",
325
+ "requirements-dev.txt",
326
+ "package-lock.json",
327
+ "pnpm-lock.yaml",
328
+ "yarn.lock",
329
+ "uv.lock",
330
+ "poetry.lock",
331
+ "dockerfile",
332
+ "docker-compose.yml",
333
+ "docker-compose.yaml",
334
+ "compose.yml",
335
+ "compose.yaml",
336
+ ".env",
337
+ ".env.local",
338
+ "auth.py",
339
+ "security.py",
340
+ "permissions.py",
341
+ "permission.py",
342
+ "jwt.py",
343
+ "token.py",
344
+ "tokens.py",
345
+ "billing.py",
346
+ "payment.py",
347
+ "payments.py",
348
+ )
349
+ MEDIUM_RISK_NAMES = (
350
+ "evagix.toml",
351
+ ".pre-commit-config.yaml",
352
+ "tox.ini",
353
+ "mypy.ini",
354
+ "ruff.toml",
355
+ )
356
+ MEDIUM_RISK_SUFFIXES = (".toml", ".ini", ".cfg", ".yaml", ".yml", ".json")
357
+ LOW_RISK_PREFIXES = ("docs/", "tests/", "examples/")
358
+ LOW_RISK_SUFFIXES = (".md", ".rst", ".txt")