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.
- evagix/__init__.py +1 -0
- evagix/__main__.py +4 -0
- evagix/agent_context_registry.py +162 -0
- evagix/changes.py +358 -0
- evagix/classification.py +197 -0
- evagix/classification_models.py +27 -0
- evagix/classification_rules.py +155 -0
- evagix/cli.py +8 -0
- evagix/command_analysis.py +382 -0
- evagix/command_recipes.py +354 -0
- evagix/command_safety.py +380 -0
- evagix/command_shell.py +168 -0
- evagix/command_text.py +99 -0
- evagix/commands/__init__.py +1 -0
- evagix/commands/agents.py +21 -0
- evagix/commands/common.py +100 -0
- evagix/commands/context_pack.py +11 -0
- evagix/commands/core_cli.py +137 -0
- evagix/commands/fix_apply.py +42 -0
- evagix/commands/fix_cli.py +40 -0
- evagix/commands/fix_plan.py +41 -0
- evagix/commands/generation.py +398 -0
- evagix/commands/generation_safety.py +96 -0
- evagix/commands/git_cli.py +36 -0
- evagix/commands/git_cmds.py +62 -0
- evagix/commands/inspect.py +276 -0
- evagix/commands/inspect_cli.py +71 -0
- evagix/commands/mcp.py +27 -0
- evagix/commands/prepare.py +55 -0
- evagix/commands/preview_cli.py +44 -0
- evagix/commands/preview_cmds.py +39 -0
- evagix/commands/readiness_cli.py +201 -0
- evagix/commands/registry.py +54 -0
- evagix/commands/report_audit.py +177 -0
- evagix/commands/report_doctor.py +147 -0
- evagix/commands/report_misc.py +76 -0
- evagix/commands/report_readme_context.py +164 -0
- evagix/commands/reports_cmds.py +67 -0
- evagix/config.py +12 -0
- evagix/config_loader.py +130 -0
- evagix/config_models.py +39 -0
- evagix/config_validate.py +154 -0
- evagix/constants.py +6 -0
- evagix/context/__init__.py +1 -0
- evagix/context/command_checks.py +268 -0
- evagix/context/content_checks.py +87 -0
- evagix/context/eval_engine.py +400 -0
- evagix/context/eval_models.py +26 -0
- evagix/context/eval_rendering.py +83 -0
- evagix/context/files.py +204 -0
- evagix/context/quality.py +66 -0
- evagix/context_eval.py +13 -0
- evagix/context_quality.py +7 -0
- evagix/core/__init__.py +8 -0
- evagix/core/collections.py +24 -0
- evagix/core/constants.py +5 -0
- evagix/core/fs.py +35 -0
- evagix/core/io.py +210 -0
- evagix/core/models.py +32 -0
- evagix/core/paths.py +10 -0
- evagix/core/text.py +21 -0
- evagix/decide.py +303 -0
- evagix/drift.py +130 -0
- evagix/ecosystems/__init__.py +13 -0
- evagix/ecosystems/commands.py +195 -0
- evagix/ecosystems/core.py +34 -0
- evagix/ecosystems/manifests.py +290 -0
- evagix/ecosystems/node.py +119 -0
- evagix/ecosystems/profiles.py +252 -0
- evagix/ecosystems/python.py +170 -0
- evagix/ecosystems/utils.py +328 -0
- evagix/evidence.py +236 -0
- evagix/explain.py +98 -0
- evagix/fixes.py +102 -0
- evagix/generated_integrity.py +165 -0
- evagix/model.py +183 -0
- evagix/onboard.py +226 -0
- evagix/pr_risk.py +242 -0
- evagix/profiles.py +182 -0
- evagix/prompt_injection.py +321 -0
- evagix/py.typed +0 -0
- evagix/readme/__init__.py +1 -0
- evagix/readme/audit_engine.py +377 -0
- evagix/readme/claim_checks.py +350 -0
- evagix/readme/claim_registry.py +134 -0
- evagix/readme/claim_rules.py +5 -0
- evagix/readme/claim_text_scan.py +85 -0
- evagix/readme/command_extractor.py +209 -0
- evagix/readme/evidence_matcher.py +120 -0
- evagix/readme/findings.py +69 -0
- evagix/readme/source.py +84 -0
- evagix/readme/text_utils.py +396 -0
- evagix/readme_audit.py +25 -0
- evagix/renderers.py +53 -0
- evagix/rendering/__init__.py +1 -0
- evagix/rendering/context.py +148 -0
- evagix/rendering/fingerprints.py +12 -0
- evagix/rendering/payloads.py +99 -0
- evagix/rendering/sections.py +70 -0
- evagix/rendering/sections_core.py +157 -0
- evagix/rendering/sections_domain.py +135 -0
- evagix/rendering/sections_policy.py +161 -0
- evagix/rendering/target_renderers.py +139 -0
- evagix/report_models.py +65 -0
- evagix/reports/__init__.py +1 -0
- evagix/reports/context_pack.py +69 -0
- evagix/reports/json.py +20 -0
- evagix/reports/locations.py +79 -0
- evagix/reports/markdown.py +26 -0
- evagix/reports/pr_comment.py +33 -0
- evagix/reports/sarif.py +105 -0
- evagix/reports/text.py +17 -0
- evagix/repository_intent.py +344 -0
- evagix/rules/__init__.py +18 -0
- evagix/rules/_shared.py +19 -0
- evagix/rules/agent_compatibility.py +12 -0
- evagix/rules/aliases.py +30 -0
- evagix/rules/commands.py +12 -0
- evagix/rules/context.py +12 -0
- evagix/rules/mcp.py +12 -0
- evagix/rules/models.py +34 -0
- evagix/rules/pr_risk.py +12 -0
- evagix/rules/readme.py +12 -0
- evagix/rules/readme_source.py +9 -0
- evagix/rules/registry.py +400 -0
- evagix/rules/safety.py +12 -0
- evagix/rules/static_rules.py +78 -0
- evagix/safety.py +121 -0
- evagix/scanner.py +7 -0
- evagix/scanner_utils.py +362 -0
- evagix/scanners/__init__.py +1 -0
- evagix/scanners/agent_files.py +89 -0
- evagix/scanners/commands.py +21 -0
- evagix/scanners/context_pack.py +10 -0
- evagix/scanners/ecosystems.py +14 -0
- evagix/scanners/git_diff.py +9 -0
- evagix/scanners/mcp.py +33 -0
- evagix/scanners/readme.py +11 -0
- evagix/scanners/repository.py +22 -0
- evagix/scanners/safety.py +19 -0
- evagix/scanning/__init__.py +1 -0
- evagix/scanning/base.py +164 -0
- evagix/scanning/command_evidence.py +152 -0
- evagix/scanning/infrastructure.py +280 -0
- evagix/scanning/node_policy.py +179 -0
- evagix/scanning/polyglot_extra.py +113 -0
- evagix/scanning/project_evidence.py +384 -0
- evagix/scanning/python_evidence.py +398 -0
- evagix/scanning/repository.py +57 -0
- evagix/scanning/shared.py +82 -0
- evagix/schemas/__init__.py +1 -0
- evagix/schemas/audit-report.schema.json +145 -0
- evagix/schemas/changed-report.schema.json +40 -0
- evagix/schemas/classification-report.schema.json +55 -0
- evagix/schemas/context-eval.schema.json +154 -0
- evagix/schemas/decision-plan.schema.json +27 -0
- evagix/schemas/doctor-report.schema.json +117 -0
- evagix/schemas/drift-report.schema.json +91 -0
- evagix/schemas/evagix-config.schema.json +212 -0
- evagix/schemas/evidence.schema.json +62 -0
- evagix/schemas/policy-report.schema.json +45 -0
- evagix/schemas/pr-risk-report.schema.json +67 -0
- evagix/schemas/readme-audit.schema.json +326 -0
- evagix/schemas/scan-facts.schema.json +149 -0
- evagix/scoped.py +76 -0
- evagix/scoring/__init__.py +5 -0
- evagix/scoring/breakdown.py +10 -0
- evagix/scoring/engine.py +25 -0
- evagix/scoring/weights.py +9 -0
- evagix/security/__init__.py +12 -0
- evagix/security/assignment_patterns.py +45 -0
- evagix/security/labels.py +29 -0
- evagix/security/output.py +78 -0
- evagix/security/redaction.py +385 -0
- evagix/signatures.py +156 -0
- evagix/strict_scoring.py +243 -0
- evagix/targets.py +206 -0
- evagix/task_renderers.py +180 -0
- evagix/templates.py +186 -0
- evagix/text_diagnostics.py +33 -0
- evagix/thresholds.py +50 -0
- evagix/utils.py +86 -0
- evagix/validation/__init__.py +35 -0
- evagix/validation/audit.py +58 -0
- evagix/validation/audit_actions.py +37 -0
- evagix/validation/audit_rules.py +46 -0
- evagix/validation/checks.py +252 -0
- evagix/validation/doctor.py +228 -0
- evagix/validation/generated_context.py +253 -0
- evagix/validation/rendering.py +128 -0
- evagix/validation/scoring.py +197 -0
- evagix/validators.py +41 -0
- evagix-0.1.0.dist-info/METADATA +454 -0
- evagix-0.1.0.dist-info/RECORD +198 -0
- evagix-0.1.0.dist-info/WHEEL +5 -0
- evagix-0.1.0.dist-info/entry_points.txt +3 -0
- evagix-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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,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")
|