vigil-codeintel 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.
- vigil_codeintel-0.1.0.dist-info/METADATA +780 -0
- vigil_codeintel-0.1.0.dist-info/RECORD +131 -0
- vigil_codeintel-0.1.0.dist-info/WHEEL +5 -0
- vigil_codeintel-0.1.0.dist-info/entry_points.txt +3 -0
- vigil_codeintel-0.1.0.dist-info/licenses/LICENSE +21 -0
- vigil_codeintel-0.1.0.dist-info/top_level.txt +3 -0
- vigil_forensic/__init__.py +224 -0
- vigil_forensic/_git_utils.py +178 -0
- vigil_forensic/_shared.py +510 -0
- vigil_forensic/_stubs.py +156 -0
- vigil_forensic/gate_checks/__init__.py +1 -0
- vigil_forensic/gate_checks/_ast_helpers.py +629 -0
- vigil_forensic/gate_checks/_deployment_detector.py +573 -0
- vigil_forensic/gate_checks/atomic_write_checks.py +1143 -0
- vigil_forensic/gate_checks/authority_checks.py +95 -0
- vigil_forensic/gate_checks/boundary_breach_checks.py +202 -0
- vigil_forensic/gate_checks/broad_except_checks.py +301 -0
- vigil_forensic/gate_checks/broad_except_hidden_sentinel_checks.py +365 -0
- vigil_forensic/gate_checks/common.py +253 -0
- vigil_forensic/gate_checks/config_safety_checks.py +704 -0
- vigil_forensic/gate_checks/config_ssot_checks.py +78 -0
- vigil_forensic/gate_checks/conflict_checks.py +193 -0
- vigil_forensic/gate_checks/context_fallback_checks.py +697 -0
- vigil_forensic/gate_checks/context_health_checks.py +289 -0
- vigil_forensic/gate_checks/contract_shape_drift_checks.py +459 -0
- vigil_forensic/gate_checks/dirty_baseline_check.py +274 -0
- vigil_forensic/gate_checks/duplication_checks.py +387 -0
- vigil_forensic/gate_checks/embedded_string_checks.py +123 -0
- vigil_forensic/gate_checks/empty_output_checks.py +87 -0
- vigil_forensic/gate_checks/encoding_checks.py +847 -0
- vigil_forensic/gate_checks/export_completeness_checks.py +156 -0
- vigil_forensic/gate_checks/fallback_checks.py +41 -0
- vigil_forensic/gate_checks/file_proliferation_checks.py +171 -0
- vigil_forensic/gate_checks/fix_without_test_checks.py +69 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/__init__.py +9 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/_helpers.py +71 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/advanced_checks.py +322 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/core.py +273 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/integrity_checks.py +203 -0
- vigil_forensic/gate_checks/forensic_cluster_runners/quality_checks.py +666 -0
- vigil_forensic/gate_checks/forensic_clusters/__init__.py +193 -0
- vigil_forensic/gate_checks/forensic_clusters/allowlist.py +426 -0
- vigil_forensic/gate_checks/forensic_clusters/allowlist_writer.py +302 -0
- vigil_forensic/gate_checks/forensic_clusters/api_protocol.py +231 -0
- vigil_forensic/gate_checks/forensic_clusters/async_quality.py +1156 -0
- vigil_forensic/gate_checks/forensic_clusters/code_style.py +808 -0
- vigil_forensic/gate_checks/forensic_clusters/core.py +319 -0
- vigil_forensic/gate_checks/forensic_clusters/data_quality.py +763 -0
- vigil_forensic/gate_checks/forensic_clusters/dead_code.py +480 -0
- vigil_forensic/gate_checks/forensic_clusters/edit_mutation.py +842 -0
- vigil_forensic/gate_checks/forensic_clusters/exception_boundary.py +240 -0
- vigil_forensic/gate_checks/forensic_clusters/legacy_debt.py +556 -0
- vigil_forensic/gate_checks/forensic_clusters/static_analysis.py +834 -0
- vigil_forensic/gate_checks/forensic_clusters/structural_quality.py +298 -0
- vigil_forensic/gate_checks/god_object_zones_checks.py +173 -0
- vigil_forensic/gate_checks/hallucination_checks.py +566 -0
- vigil_forensic/gate_checks/hunter_artifact_completeness_check.py +139 -0
- vigil_forensic/gate_checks/implementation_overfit_checks.py +380 -0
- vigil_forensic/gate_checks/import_integrity_checks.py +233 -0
- vigil_forensic/gate_checks/imports_in_function_checks.py +283 -0
- vigil_forensic/gate_checks/ml_checks.py +318 -0
- vigil_forensic/gate_checks/performance_checks.py +106 -0
- vigil_forensic/gate_checks/project_specific_runner.py +691 -0
- vigil_forensic/gate_checks/provider_capability_checks.py +73 -0
- vigil_forensic/gate_checks/refactor_completeness_checks.py +274 -0
- vigil_forensic/gate_checks/reliability_checks.py +389 -0
- vigil_forensic/gate_checks/reporting_checks.py +55 -0
- vigil_forensic/gate_checks/runtime_behavior_checks.py +220 -0
- vigil_forensic/gate_checks/security_injection_checks.py +332 -0
- vigil_forensic/gate_checks/semantic_intent_checks.py +139 -0
- vigil_forensic/gate_checks/size_complexity_checks.py +336 -0
- vigil_forensic/gate_checks/stuck_feature_flag_checks.py +354 -0
- vigil_forensic/gate_checks/syntax_validity_checks.py +217 -0
- vigil_forensic/gate_checks/temporal_freshness_checks.py +79 -0
- vigil_forensic/gate_checks/test_quality_checks.py +946 -0
- vigil_forensic/gate_checks/testing_checks.py +149 -0
- vigil_forensic/gate_checks/toctou_checks.py +367 -0
- vigil_forensic/gate_checks/type_checking_checks.py +316 -0
- vigil_forensic/gate_models.py +392 -0
- vigil_forensic/gate_packs/__init__.py +1 -0
- vigil_forensic/gate_packs/universal.py +179 -0
- vigil_forensic/gate_profile.json +31 -0
- vigil_forensic/gate_registry.py +21 -0
- vigil_forensic/language_profiles.py +219 -0
- vigil_forensic/meta_findings.py +207 -0
- vigil_forensic/self_audit.py +725 -0
- vigil_forensic/source_analysis.py +175 -0
- vigil_mapper/__init__.py +103 -0
- vigil_mapper/_ast_helpers_minimal.py +229 -0
- vigil_mapper/_extract_imports_impl.py +123 -0
- vigil_mapper/_file_count_guard.py +129 -0
- vigil_mapper/_git_utils.py +178 -0
- vigil_mapper/_runtime_ast.py +438 -0
- vigil_mapper/_runtime_dispatch.py +137 -0
- vigil_mapper/_seed_helpers.py +82 -0
- vigil_mapper/authority_builder.py +1102 -0
- vigil_mapper/cli_entry.py +731 -0
- vigil_mapper/conflict_builder.py +818 -0
- vigil_mapper/data_contract_builder.py +446 -0
- vigil_mapper/findings_builder.py +716 -0
- vigil_mapper/fingerprint.py +53 -0
- vigil_mapper/hotspot_builder.py +539 -0
- vigil_mapper/map_common.py +449 -0
- vigil_mapper/map_errors.py +55 -0
- vigil_mapper/map_models.py +431 -0
- vigil_mapper/map_models_ext.py +206 -0
- vigil_mapper/map_models_findings.py +130 -0
- vigil_mapper/map_storage.py +455 -0
- vigil_mapper/parse_cache.py +795 -0
- vigil_mapper/refactor_boundary_builder.py +266 -0
- vigil_mapper/runtime_builder.py +527 -0
- vigil_mapper/runtime_tracer.py +243 -0
- vigil_mapper/runtime_tracer_entry.py +199 -0
- vigil_mapper/semantic_diff.py +71 -0
- vigil_mapper/source_adapters/__init__.py +109 -0
- vigil_mapper/source_adapters/_base.py +264 -0
- vigil_mapper/source_adapters/_ir.py +156 -0
- vigil_mapper/source_adapters/_lexer.py +309 -0
- vigil_mapper/source_adapters/_patterns.py +212 -0
- vigil_mapper/source_adapters/_treesitter.py +182 -0
- vigil_mapper/source_adapters/go.py +553 -0
- vigil_mapper/source_adapters/java.py +541 -0
- vigil_mapper/source_adapters/javascript.py +626 -0
- vigil_mapper/source_adapters/python.py +325 -0
- vigil_mapper/source_adapters/typescript.py +749 -0
- vigil_mapper/structural_builder.py +586 -0
- vigil_mcp/__init__.py +1 -0
- vigil_mcp/_jobs.py +587 -0
- vigil_mcp/_paths.py +93 -0
- vigil_mcp/forensic_server.py +419 -0
- vigil_mcp/map_server.py +452 -0
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
"""Dynamic loader and runner for project-specific forensic gates.
|
|
2
|
+
|
|
3
|
+
Project-specific gates live under ``.prompt-engineer/forensic_gates/``.
|
|
4
|
+
|
|
5
|
+
Layout:
|
|
6
|
+
- legacy/manual gates may still live directly in ``forensic_gates/``
|
|
7
|
+
- generated gates live in ``forensic_gates/generated/``
|
|
8
|
+
|
|
9
|
+
Each gate is a Python module with a ``run_check(file_path, content) -> list[dict]``
|
|
10
|
+
function. Modules are loaded dynamically on every run, so newly created gates are
|
|
11
|
+
picked up automatically without runtime restart.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
import importlib.util
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from vigil_forensic._shared import EvidenceReference, GateCategory, GateCheckResult, GateImpact, GateSeverity
|
|
24
|
+
from vigil_forensic.gate_models import PostExecGateContext
|
|
25
|
+
from .common import build_check_result, build_finding, iter_touched_snapshots
|
|
26
|
+
|
|
27
|
+
_log = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
GATES_DIR_NAME = "forensic_gates"
|
|
31
|
+
GATES_PARENT = ".prompt-engineer"
|
|
32
|
+
GENERATED_SUBDIR = "generated"
|
|
33
|
+
RULES_FILE = "project_rules.json"
|
|
34
|
+
STATUS_FILE = "_generation_status.json"
|
|
35
|
+
PROMPT_FILE = "_generation_prompt.txt"
|
|
36
|
+
RAW_OUTPUT_FILE = "_raw_generation_output.txt"
|
|
37
|
+
RAW_STDERR_FILE = "_raw_generation_stderr.txt"
|
|
38
|
+
_MAX_FINDINGS_PER_GATE = 20
|
|
39
|
+
_MAX_TOTAL_FINDINGS = 100
|
|
40
|
+
_ALLOWED_IMPORT_MODULES = frozenset({"re"})
|
|
41
|
+
_BLOCKED_CALL_NAMES = frozenset({"open", "exec", "eval", "compile", "input", "__import__"})
|
|
42
|
+
_BLOCKED_CALL_PREFIXES = ("os.", "subprocess.", "socket.", "pathlib.", "shutil.")
|
|
43
|
+
_STALE_GENERATION_SECONDS = 600 # 10 min -- only marks stale if no active lease found
|
|
44
|
+
_LEGACY_TRANSIENT_FILES = frozenset({STATUS_FILE, PROMPT_FILE, RAW_OUTPUT_FILE, RAW_STDERR_FILE})
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _gates_dir(project_dir: Path) -> Path:
|
|
48
|
+
"""Return the project-specific forensic gates root directory."""
|
|
49
|
+
return Path(project_dir) / GATES_PARENT / GATES_DIR_NAME
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _generated_gates_dir(project_dir: Path) -> Path:
|
|
53
|
+
"""Return the generated-gates directory under the forensic gates root."""
|
|
54
|
+
return _gates_dir(project_dir) / GENERATED_SUBDIR
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _has_manual_gate_content(root_dir: Path) -> bool:
|
|
58
|
+
"""Return True when the legacy/manual root contains actual gate content."""
|
|
59
|
+
if not root_dir.is_dir():
|
|
60
|
+
return False
|
|
61
|
+
if any(root_dir.glob("gate_*.py")):
|
|
62
|
+
return True
|
|
63
|
+
if (root_dir / RULES_FILE).exists():
|
|
64
|
+
return True
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _cleanup_legacy_generation_artifacts(project_dir: Path) -> None:
|
|
69
|
+
"""Move legacy transient generation files into the canonical generated/ folder.
|
|
70
|
+
|
|
71
|
+
Safety rules:
|
|
72
|
+
- never touch the legacy root if it contains manual gate files/manifest
|
|
73
|
+
- only migrate the known transient generation files
|
|
74
|
+
- skip cleanup entirely if the generated directory already has conflicting files
|
|
75
|
+
"""
|
|
76
|
+
root_dir = _gates_dir(project_dir)
|
|
77
|
+
if not root_dir.is_dir() or _has_manual_gate_content(root_dir):
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
transient_files = [path for path in root_dir.iterdir() if path.is_file() and path.name in _LEGACY_TRANSIENT_FILES]
|
|
81
|
+
if not transient_files:
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
unknown_files = [path for path in root_dir.iterdir() if path.is_file() and path.name not in _LEGACY_TRANSIENT_FILES]
|
|
85
|
+
if unknown_files:
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
generated_dir = _generated_gates_dir(project_dir)
|
|
89
|
+
if any((generated_dir / path.name).exists() for path in transient_files):
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
generated_dir.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
for path in transient_files:
|
|
94
|
+
target = generated_dir / path.name
|
|
95
|
+
if path.name == STATUS_FILE:
|
|
96
|
+
payload = _load_json_file(path)
|
|
97
|
+
if isinstance(payload, dict):
|
|
98
|
+
target.write_text(
|
|
99
|
+
json.dumps(_status_file_payload(payload, source_kind="generated"), indent=2, ensure_ascii=False) + "\n",
|
|
100
|
+
encoding="utf-8",
|
|
101
|
+
)
|
|
102
|
+
path.unlink()
|
|
103
|
+
continue
|
|
104
|
+
path.replace(target)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _gate_source_dirs(project_dir: Path) -> list[tuple[str, Path]]:
|
|
108
|
+
"""Return source-kind/dir pairs in load order."""
|
|
109
|
+
root_dir = _gates_dir(project_dir)
|
|
110
|
+
generated_dir = _generated_gates_dir(project_dir)
|
|
111
|
+
dirs: list[tuple[str, Path]] = []
|
|
112
|
+
if generated_dir.is_dir():
|
|
113
|
+
dirs.append(("generated", generated_dir))
|
|
114
|
+
if _has_manual_gate_content(root_dir):
|
|
115
|
+
dirs.append(("manual", root_dir))
|
|
116
|
+
return dirs
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _source_label(source_kind: str) -> str:
|
|
120
|
+
return {
|
|
121
|
+
"generated": "Generated project gates",
|
|
122
|
+
"manual": "Manual / legacy project gates",
|
|
123
|
+
}.get(source_kind, source_kind.replace("_", " ").title())
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _load_json_file(path: Path) -> Any | None:
|
|
127
|
+
try:
|
|
128
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
129
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
130
|
+
_log.warning("Failed to load JSON %s: %s", path, exc)
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _status_file_payload(data: dict[str, Any], *, source_kind: str) -> dict[str, Any]:
|
|
135
|
+
"""Return the persisted subset for generation status files."""
|
|
136
|
+
payload: dict[str, Any] = {
|
|
137
|
+
"status": str(data.get("status") or "unknown"),
|
|
138
|
+
"gates_count": int(data.get("gates_count") or 0),
|
|
139
|
+
"source_kind": source_kind,
|
|
140
|
+
}
|
|
141
|
+
for key in ("error", "invalid_gates", "started_at", "finished_at"):
|
|
142
|
+
value = data.get(key)
|
|
143
|
+
if value not in (None, "", []):
|
|
144
|
+
payload[key] = value
|
|
145
|
+
return payload
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _call_name(node: ast.AST) -> str:
|
|
149
|
+
if isinstance(node, ast.Name):
|
|
150
|
+
return node.id
|
|
151
|
+
if isinstance(node, ast.Attribute):
|
|
152
|
+
prefix = _call_name(node.value)
|
|
153
|
+
return f"{prefix}.{node.attr}" if prefix else node.attr
|
|
154
|
+
return ""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _validate_gate_source(file_name: str, source: str) -> list[str]:
|
|
158
|
+
"""Return validation errors for a generated/manual gate source file."""
|
|
159
|
+
errors: list[str] = []
|
|
160
|
+
try:
|
|
161
|
+
tree = ast.parse(source, filename=file_name)
|
|
162
|
+
except SyntaxError as exc:
|
|
163
|
+
return [f"syntax error: {exc.msg} (line {exc.lineno})"]
|
|
164
|
+
|
|
165
|
+
run_check_defs = []
|
|
166
|
+
for node in tree.body:
|
|
167
|
+
if isinstance(node, ast.Expr) and isinstance(getattr(node, "value", None), ast.Constant):
|
|
168
|
+
if isinstance(node.value.value, str):
|
|
169
|
+
continue
|
|
170
|
+
if isinstance(node, ast.Import):
|
|
171
|
+
for alias in node.names:
|
|
172
|
+
if alias.name not in _ALLOWED_IMPORT_MODULES:
|
|
173
|
+
errors.append(f"disallowed import: {alias.name}")
|
|
174
|
+
continue
|
|
175
|
+
if isinstance(node, ast.ImportFrom):
|
|
176
|
+
if node.module not in _ALLOWED_IMPORT_MODULES:
|
|
177
|
+
errors.append(f"disallowed import-from: {node.module}")
|
|
178
|
+
continue
|
|
179
|
+
if isinstance(node, ast.FunctionDef) and node.name == "run_check":
|
|
180
|
+
run_check_defs.append(node)
|
|
181
|
+
continue
|
|
182
|
+
errors.append(f"unsupported top-level statement: {type(node).__name__}")
|
|
183
|
+
|
|
184
|
+
if len(run_check_defs) != 1:
|
|
185
|
+
errors.append("gate must define exactly one top-level run_check() function")
|
|
186
|
+
elif [arg.arg for arg in run_check_defs[0].args.args] != ["file_path", "content"]:
|
|
187
|
+
errors.append("run_check must have exact signature: run_check(file_path, content)")
|
|
188
|
+
|
|
189
|
+
for node in ast.walk(tree):
|
|
190
|
+
if isinstance(node, ast.Call):
|
|
191
|
+
call_name = _call_name(node.func)
|
|
192
|
+
if call_name in _BLOCKED_CALL_NAMES or any(call_name.startswith(p) for p in _BLOCKED_CALL_PREFIXES):
|
|
193
|
+
errors.append(f"disallowed call: {call_name}")
|
|
194
|
+
if isinstance(node, ast.Attribute):
|
|
195
|
+
attr_name = _call_name(node)
|
|
196
|
+
if any(attr_name.startswith(p) for p in _BLOCKED_CALL_PREFIXES):
|
|
197
|
+
errors.append(f"disallowed attribute access: {attr_name}")
|
|
198
|
+
|
|
199
|
+
deduped: list[str] = []
|
|
200
|
+
for item in errors:
|
|
201
|
+
if item not in deduped:
|
|
202
|
+
deduped.append(item)
|
|
203
|
+
return deduped
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _discover_gate_files(project_dir: Path) -> list[dict[str, Any]]:
|
|
207
|
+
"""Return structured metadata for all gate_*.py files."""
|
|
208
|
+
discovered: list[dict[str, Any]] = []
|
|
209
|
+
for source_kind, source_dir in _gate_source_dirs(project_dir):
|
|
210
|
+
for gate_file in sorted(source_dir.glob("gate_*.py")):
|
|
211
|
+
try:
|
|
212
|
+
source = gate_file.read_text(encoding="utf-8")
|
|
213
|
+
except OSError as exc:
|
|
214
|
+
discovered.append({
|
|
215
|
+
"name": gate_file.stem,
|
|
216
|
+
"file": gate_file.name,
|
|
217
|
+
"path": str(gate_file),
|
|
218
|
+
"source_kind": source_kind,
|
|
219
|
+
"source_label": _source_label(source_kind),
|
|
220
|
+
"valid": False,
|
|
221
|
+
"errors": [f"read error: {exc}"],
|
|
222
|
+
})
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
errors = _validate_gate_source(gate_file.name, source)
|
|
226
|
+
discovered.append({
|
|
227
|
+
"name": gate_file.stem,
|
|
228
|
+
"file": gate_file.name,
|
|
229
|
+
"path": str(gate_file),
|
|
230
|
+
"source_kind": source_kind,
|
|
231
|
+
"source_label": _source_label(source_kind),
|
|
232
|
+
"valid": not errors,
|
|
233
|
+
"errors": errors,
|
|
234
|
+
})
|
|
235
|
+
return discovered
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _load_project_rules(project_dir: Path) -> list[dict[str, Any]]:
|
|
239
|
+
"""Load and merge project rule manifests from generated + manual sources."""
|
|
240
|
+
merged: list[dict[str, Any]] = []
|
|
241
|
+
for source_kind, source_dir in _gate_source_dirs(project_dir):
|
|
242
|
+
rules_path = source_dir / RULES_FILE
|
|
243
|
+
if not rules_path.exists():
|
|
244
|
+
continue
|
|
245
|
+
data = _load_json_file(rules_path)
|
|
246
|
+
if data is None:
|
|
247
|
+
continue
|
|
248
|
+
rules = data if isinstance(data, list) else data.get("rules", [])
|
|
249
|
+
if not isinstance(rules, list):
|
|
250
|
+
continue
|
|
251
|
+
for item in rules:
|
|
252
|
+
if not isinstance(item, dict):
|
|
253
|
+
continue
|
|
254
|
+
entry = dict(item)
|
|
255
|
+
entry.setdefault("file", "")
|
|
256
|
+
entry["source_kind"] = source_kind
|
|
257
|
+
entry["source_label"] = _source_label(source_kind)
|
|
258
|
+
entry["manifest_path"] = str(rules_path)
|
|
259
|
+
merged.append(entry)
|
|
260
|
+
return merged
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _load_gate_modules(project_dir: Path) -> list[tuple[str, Any]]:
|
|
264
|
+
"""Dynamically load all valid gate modules from the project gates directories."""
|
|
265
|
+
modules: list[tuple[str, Any]] = []
|
|
266
|
+
for info in _discover_gate_files(project_dir):
|
|
267
|
+
if not info["valid"]:
|
|
268
|
+
_log.warning("Skipping invalid gate %s: %s", info["file"], "; ".join(info["errors"]))
|
|
269
|
+
continue
|
|
270
|
+
gate_path = Path(info["path"])
|
|
271
|
+
module_name = f"project_gate_{info['source_kind']}_{gate_path.stem}"
|
|
272
|
+
try:
|
|
273
|
+
spec = importlib.util.spec_from_file_location(module_name, str(gate_path))
|
|
274
|
+
if spec is None or spec.loader is None:
|
|
275
|
+
_log.warning("Cannot load gate module: %s", gate_path)
|
|
276
|
+
continue
|
|
277
|
+
module = importlib.util.module_from_spec(spec)
|
|
278
|
+
spec.loader.exec_module(module)
|
|
279
|
+
if not hasattr(module, "run_check") or not callable(module.run_check):
|
|
280
|
+
_log.warning("Gate module %s has no run_check function", gate_path.name)
|
|
281
|
+
continue
|
|
282
|
+
modules.append((gate_path.stem, module))
|
|
283
|
+
except Exception as exc:
|
|
284
|
+
_log.warning("Failed to load gate %s: %s", gate_path.name, exc)
|
|
285
|
+
return modules
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _load_generation_status(project_dir: Path) -> dict[str, Any] | None:
|
|
289
|
+
"""Load generation status, preferring the generated directory."""
|
|
290
|
+
candidates = [
|
|
291
|
+
("generated", _generated_gates_dir(project_dir) / STATUS_FILE),
|
|
292
|
+
("manual", _gates_dir(project_dir) / STATUS_FILE),
|
|
293
|
+
]
|
|
294
|
+
for source_kind, status_path in candidates:
|
|
295
|
+
if not status_path.exists():
|
|
296
|
+
continue
|
|
297
|
+
data = _load_json_file(status_path)
|
|
298
|
+
if not isinstance(data, dict):
|
|
299
|
+
continue
|
|
300
|
+
data = dict(data)
|
|
301
|
+
data.setdefault("status", "unknown")
|
|
302
|
+
data["status_path"] = str(status_path)
|
|
303
|
+
data["source_kind"] = source_kind
|
|
304
|
+
data["source_label"] = _source_label(source_kind)
|
|
305
|
+
try:
|
|
306
|
+
data["updated_at"] = status_path.stat().st_mtime
|
|
307
|
+
except OSError:
|
|
308
|
+
data["updated_at"] = 0.0
|
|
309
|
+
raw_output = status_path.parent / RAW_OUTPUT_FILE
|
|
310
|
+
if raw_output.exists():
|
|
311
|
+
data["raw_output_path"] = str(raw_output)
|
|
312
|
+
|
|
313
|
+
persisted_source_kind = str((_load_json_file(status_path) or {}).get("source_kind") or "")
|
|
314
|
+
if persisted_source_kind != source_kind:
|
|
315
|
+
try:
|
|
316
|
+
status_path.write_text(
|
|
317
|
+
json.dumps(_status_file_payload(data, source_kind=source_kind), indent=2, ensure_ascii=False) + "\n",
|
|
318
|
+
encoding="utf-8",
|
|
319
|
+
)
|
|
320
|
+
except OSError as _exc:
|
|
321
|
+
_log.debug("gate status write failed for %s: %s", status_path, _exc)
|
|
322
|
+
|
|
323
|
+
if data.get("status") == "running":
|
|
324
|
+
# standalone: claude run-lease unavailable
|
|
325
|
+
lease = None
|
|
326
|
+
age = max(0.0, time.time() - float(data.get("updated_at") or 0.0))
|
|
327
|
+
if lease is None and age > _STALE_GENERATION_SECONDS:
|
|
328
|
+
data["status"] = "failed"
|
|
329
|
+
data.setdefault("error", "Generation status became stale; no active Claude run was found")
|
|
330
|
+
try:
|
|
331
|
+
status_path.write_text(
|
|
332
|
+
json.dumps(_status_file_payload(data, source_kind=source_kind), indent=2, ensure_ascii=False) + "\n",
|
|
333
|
+
encoding="utf-8",
|
|
334
|
+
)
|
|
335
|
+
except OSError as _exc:
|
|
336
|
+
_log.debug("gate stale-failure status write failed for %s: %s", status_path, _exc)
|
|
337
|
+
return data
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def describe_gate_inventory(project_dir: Path) -> dict[str, Any]:
|
|
342
|
+
"""Return structured inventory for UI/API consumption."""
|
|
343
|
+
_cleanup_legacy_generation_artifacts(project_dir)
|
|
344
|
+
root_dir = _gates_dir(project_dir)
|
|
345
|
+
generated_dir = _generated_gates_dir(project_dir)
|
|
346
|
+
rules = _load_project_rules(project_dir)
|
|
347
|
+
file_inventory = _discover_gate_files(project_dir)
|
|
348
|
+
valid_modules = [item["name"] for item in file_inventory if item["valid"]]
|
|
349
|
+
generation_status = _load_generation_status(project_dir)
|
|
350
|
+
|
|
351
|
+
groups: list[dict[str, Any]] = []
|
|
352
|
+
for source_kind, source_dir in _gate_source_dirs(project_dir):
|
|
353
|
+
source_files = [item for item in file_inventory if item["source_kind"] == source_kind]
|
|
354
|
+
source_rules = [item for item in rules if item["source_kind"] == source_kind]
|
|
355
|
+
groups.append({
|
|
356
|
+
"source_kind": source_kind,
|
|
357
|
+
"source_label": _source_label(source_kind),
|
|
358
|
+
"directory": str(source_dir),
|
|
359
|
+
"rules": source_rules,
|
|
360
|
+
"files": source_files,
|
|
361
|
+
"valid_count": sum(1 for item in source_files if item["valid"]),
|
|
362
|
+
"invalid_count": sum(1 for item in source_files if not item["valid"]),
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
"root_dir": str(root_dir),
|
|
367
|
+
"generated_dir": str(generated_dir),
|
|
368
|
+
"exists": root_dir.is_dir() or generated_dir.is_dir(),
|
|
369
|
+
"rules": rules,
|
|
370
|
+
"modules": valid_modules,
|
|
371
|
+
"module_details": file_inventory,
|
|
372
|
+
"gates_count": len(valid_modules),
|
|
373
|
+
"groups": groups,
|
|
374
|
+
"generation_status": generation_status,
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _execute_gate(
|
|
379
|
+
gate_name: str,
|
|
380
|
+
gate_module: Any,
|
|
381
|
+
file_path: str,
|
|
382
|
+
content: str,
|
|
383
|
+
) -> list[dict[str, Any]]:
|
|
384
|
+
"""Execute a single gate's run_check and return findings."""
|
|
385
|
+
try:
|
|
386
|
+
results = gate_module.run_check(file_path, content)
|
|
387
|
+
if not isinstance(results, list):
|
|
388
|
+
return []
|
|
389
|
+
valid: list[dict[str, Any]] = []
|
|
390
|
+
for item in results:
|
|
391
|
+
if not isinstance(item, dict) or "message" not in item:
|
|
392
|
+
continue
|
|
393
|
+
valid.append({
|
|
394
|
+
"line": int(item.get("line") or 0),
|
|
395
|
+
"message": str(item["message"]),
|
|
396
|
+
"severity": str(item.get("severity", "medium")),
|
|
397
|
+
"gate": gate_name,
|
|
398
|
+
})
|
|
399
|
+
return valid
|
|
400
|
+
except Exception as exc:
|
|
401
|
+
_log.debug("Gate %s failed on %s: %s", gate_name, file_path, exc)
|
|
402
|
+
return []
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def run_project_specific_checks(ctx: PostExecGateContext) -> GateCheckResult:
|
|
406
|
+
"""Run project-specific forensic gates from .prompt-engineer/forensic_gates/."""
|
|
407
|
+
project_dir = ctx.project_dir
|
|
408
|
+
inventory = describe_gate_inventory(project_dir)
|
|
409
|
+
|
|
410
|
+
if not inventory["exists"]:
|
|
411
|
+
return build_check_result(
|
|
412
|
+
check_id="project_specific",
|
|
413
|
+
category=GateCategory.CONTRACT,
|
|
414
|
+
notes=["No project-specific forensic gates found "
|
|
415
|
+
f"(looked in {GATES_PARENT}/{GATES_DIR_NAME}/)"],
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
gate_modules = _load_gate_modules(project_dir)
|
|
419
|
+
if not gate_modules:
|
|
420
|
+
return build_check_result(
|
|
421
|
+
check_id="project_specific",
|
|
422
|
+
category=GateCategory.CONTRACT,
|
|
423
|
+
notes=[f"Gates directory exists but no valid gate_*.py modules found in {inventory['root_dir']}"],
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
findings = []
|
|
427
|
+
notes = [f"Loaded {len(gate_modules)} project-specific gate(s): {', '.join(name for name, _ in gate_modules)}"]
|
|
428
|
+
if inventory.get("generation_status"):
|
|
429
|
+
status = inventory["generation_status"]
|
|
430
|
+
notes.append(f"Last generation status: {status.get('status')} ({status.get('source_label')})")
|
|
431
|
+
|
|
432
|
+
total_count = 0
|
|
433
|
+
for snapshot in iter_touched_snapshots(ctx):
|
|
434
|
+
if not snapshot.exists or not snapshot.text:
|
|
435
|
+
continue
|
|
436
|
+
if total_count >= _MAX_TOTAL_FINDINGS:
|
|
437
|
+
notes.append(f"Stopped scanning -- reached {_MAX_TOTAL_FINDINGS} findings cap")
|
|
438
|
+
break
|
|
439
|
+
|
|
440
|
+
for gate_name, gate_module in gate_modules:
|
|
441
|
+
if total_count >= _MAX_TOTAL_FINDINGS:
|
|
442
|
+
break
|
|
443
|
+
|
|
444
|
+
results = _execute_gate(gate_name, gate_module, snapshot.path, snapshot.text)
|
|
445
|
+
for item in results[:_MAX_FINDINGS_PER_GATE]:
|
|
446
|
+
if total_count >= _MAX_TOTAL_FINDINGS:
|
|
447
|
+
break
|
|
448
|
+
|
|
449
|
+
severity_map = {
|
|
450
|
+
"high": GateSeverity.HIGH,
|
|
451
|
+
"medium": GateSeverity.MEDIUM,
|
|
452
|
+
"low": GateSeverity.LOW,
|
|
453
|
+
}
|
|
454
|
+
findings.append(build_finding(
|
|
455
|
+
check_id=f"project_gate.{gate_name}",
|
|
456
|
+
category=GateCategory.CONTRACT,
|
|
457
|
+
title=f"[{gate_name}] {item['message'][:80]}",
|
|
458
|
+
severity=severity_map.get(item["severity"], GateSeverity.MEDIUM),
|
|
459
|
+
impact=GateImpact.REVISE,
|
|
460
|
+
summary=item["message"],
|
|
461
|
+
recommendation=f"Fix the violation detected by project gate '{gate_name}'",
|
|
462
|
+
evidence=(
|
|
463
|
+
EvidenceReference(
|
|
464
|
+
kind="file",
|
|
465
|
+
path=snapshot.path,
|
|
466
|
+
detail=f"line:{item['line']}" if item["line"] else "",
|
|
467
|
+
),
|
|
468
|
+
),
|
|
469
|
+
|
|
470
|
+
repair_kind='refactor',
|
|
471
|
+
executor_action='Address finding details',
|
|
472
|
+
proof_required='Issue fixed',
|
|
473
|
+
allowlist_allowed=False,
|
|
474
|
+
))
|
|
475
|
+
total_count += 1
|
|
476
|
+
|
|
477
|
+
return build_check_result(
|
|
478
|
+
check_id="project_specific",
|
|
479
|
+
category=GateCategory.CONTRACT,
|
|
480
|
+
findings=findings,
|
|
481
|
+
notes=notes,
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
GATE_GENERATION_PROMPT = """\
|
|
486
|
+
ROLE: Project Forensic Gate Architect (Opus-level analysis)
|
|
487
|
+
|
|
488
|
+
You are creating automated quality gates for a software project.
|
|
489
|
+
These gates run on every code review to catch project-specific invariant violations.
|
|
490
|
+
|
|
491
|
+
===========================================================
|
|
492
|
+
STEP 1: READ THE PROJECT CONTEXT
|
|
493
|
+
===========================================================
|
|
494
|
+
|
|
495
|
+
First, read the prompt-engineer directory to understand the project:
|
|
496
|
+
- Read `.prompt-engineer/README.md` (if exists) -- project overview
|
|
497
|
+
- Read `.prompt-engineer/CONTRACT_INDEX.md` (if exists) -- key contracts
|
|
498
|
+
- Read `.prompt-engineer/project_map.md` (if exists) -- architecture map
|
|
499
|
+
- Read `CLAUDE.md` or `.claude/CLAUDE.md` (if exists) -- project rules
|
|
500
|
+
|
|
501
|
+
Then read 5-10 key source files to understand patterns, invariants,
|
|
502
|
+
and common mistakes in this codebase.
|
|
503
|
+
|
|
504
|
+
===========================================================
|
|
505
|
+
STEP 2: UNDERSTAND EXISTING GATES
|
|
506
|
+
===========================================================
|
|
507
|
+
|
|
508
|
+
The framework already has {universal_cluster_count} universal checks covering:
|
|
509
|
+
{builtin_categories}
|
|
510
|
+
|
|
511
|
+
DO NOT duplicate these. Your gates must check PROJECT-SPECIFIC invariants
|
|
512
|
+
that universal checks cannot know about.
|
|
513
|
+
|
|
514
|
+
Existing project-specific rules (if any):
|
|
515
|
+
{existing_rules}
|
|
516
|
+
|
|
517
|
+
===========================================================
|
|
518
|
+
STEP 3: ANALYZE AND IDENTIFY INVARIANTS
|
|
519
|
+
===========================================================
|
|
520
|
+
|
|
521
|
+
Based on your reading, identify 3-8 project-specific invariants:
|
|
522
|
+
- Architecture boundaries (what modules should NOT import from each other)
|
|
523
|
+
- Naming conventions specific to THIS project
|
|
524
|
+
- Required patterns (every handler must have X, every test must check Y)
|
|
525
|
+
- Forbidden patterns (this project must NEVER use Z)
|
|
526
|
+
- Data flow rules (this type must always go through this pipeline)
|
|
527
|
+
- Configuration rules (these settings must always be consistent)
|
|
528
|
+
|
|
529
|
+
===========================================================
|
|
530
|
+
STEP 4: CREATE GATE FILES
|
|
531
|
+
===========================================================
|
|
532
|
+
|
|
533
|
+
For each invariant, create a gate file in:
|
|
534
|
+
{gates_dir}/
|
|
535
|
+
|
|
536
|
+
File naming: `gate_<rule_id>.py` (e.g. `gate_no_cross_module_import.py`)
|
|
537
|
+
|
|
538
|
+
Each gate file must follow this EXACT format:
|
|
539
|
+
|
|
540
|
+
```python
|
|
541
|
+
\"\"\"<One-line description of what this gate checks>.
|
|
542
|
+
|
|
543
|
+
Rationale: <Why this invariant matters for THIS project>.
|
|
544
|
+
\"\"\"
|
|
545
|
+
import re
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def run_check(file_path: str, content: str) -> list[dict]:
|
|
549
|
+
\"\"\"Check a single file for violations.
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
file_path: Relative path like "src/handlers/auth.py"
|
|
553
|
+
content: Full file content as string
|
|
554
|
+
|
|
555
|
+
Returns:
|
|
556
|
+
List of findings, each: {{"line": int, "message": str, "severity": "high"|"medium"|"low"}}
|
|
557
|
+
\"\"\"
|
|
558
|
+
findings = []
|
|
559
|
+
# ... check logic using content.splitlines(), re.search, etc ...
|
|
560
|
+
return findings
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
CONSTRAINTS:
|
|
564
|
+
- Function signature EXACTLY: `def run_check(file_path: str, content: str) -> list[dict]`
|
|
565
|
+
- Only `import re` allowed (no os, subprocess, pathlib, network, file I/O)
|
|
566
|
+
- Must be deterministic: same input = same output
|
|
567
|
+
- Must handle empty/malformed input without crashing
|
|
568
|
+
- Each finding dict: {{"line": int, "message": str, "severity": "high"|"medium"|"low"}}
|
|
569
|
+
|
|
570
|
+
===========================================================
|
|
571
|
+
STEP 5: CREATE MANIFEST
|
|
572
|
+
===========================================================
|
|
573
|
+
|
|
574
|
+
After creating all gate files, write the manifest:
|
|
575
|
+
{gates_dir}/project_rules.json
|
|
576
|
+
|
|
577
|
+
Format:
|
|
578
|
+
```json
|
|
579
|
+
[
|
|
580
|
+
{{
|
|
581
|
+
"rule_id": "no_cross_module_import",
|
|
582
|
+
"description": "Handlers must not import from core directly",
|
|
583
|
+
"rationale": "Architecture boundary: handlers -> services -> core",
|
|
584
|
+
"file": "gate_no_cross_module_import.py"
|
|
585
|
+
}}
|
|
586
|
+
]
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
===========================================================
|
|
590
|
+
STEP 6: VERIFY
|
|
591
|
+
===========================================================
|
|
592
|
+
|
|
593
|
+
After writing all files:
|
|
594
|
+
1. Read back each gate file to verify it's syntactically correct
|
|
595
|
+
2. Verify project_rules.json is valid JSON and references existing files
|
|
596
|
+
3. Report a summary of what you created
|
|
597
|
+
|
|
598
|
+
QUALITY BAR:
|
|
599
|
+
- 3-8 precise gates > 20 noisy ones
|
|
600
|
+
- If a check would flag correct code as wrong, DO NOT include it
|
|
601
|
+
- Every gate must have a clear, project-specific rationale
|
|
602
|
+
- Think: "Would a senior developer on this project agree this is an invariant?"
|
|
603
|
+
|
|
604
|
+
PROJECT INFO:
|
|
605
|
+
{project_context}
|
|
606
|
+
|
|
607
|
+
SAMPLE FILE LIST:
|
|
608
|
+
{file_list}
|
|
609
|
+
|
|
610
|
+
DETECTED PATTERNS:
|
|
611
|
+
{detected_patterns}
|
|
612
|
+
"""
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def build_generation_prompt(
|
|
616
|
+
project_dir: Path,
|
|
617
|
+
file_sample_limit: int = 50,
|
|
618
|
+
) -> str:
|
|
619
|
+
"""Build the prompt for gate generation from project context."""
|
|
620
|
+
import os
|
|
621
|
+
|
|
622
|
+
excluded_dirs = {
|
|
623
|
+
"__pycache__", ".venv", "node_modules", ".git", ".mypy_cache",
|
|
624
|
+
".cortex", ".a1", ".claude", ".vendor", ".prompt-engineer",
|
|
625
|
+
"dist", "build", ".pytest_cache",
|
|
626
|
+
}
|
|
627
|
+
all_files: list[str] = []
|
|
628
|
+
for root, dirs, files in os.walk(str(project_dir)):
|
|
629
|
+
dirs[:] = [d for d in dirs if d not in excluded_dirs]
|
|
630
|
+
for name in files:
|
|
631
|
+
if name.endswith((".py", ".js", ".ts", ".rb", ".go")):
|
|
632
|
+
rel = os.path.relpath(os.path.join(root, name), str(project_dir)).replace("\\", "/")
|
|
633
|
+
all_files.append(rel)
|
|
634
|
+
|
|
635
|
+
def _priority(path: str) -> tuple[int, str]:
|
|
636
|
+
if path.startswith(("SYSTEM/", "BRAIN/", "INTERFACE/", "TESTS/")):
|
|
637
|
+
return (0, path)
|
|
638
|
+
if path.startswith("tests/"):
|
|
639
|
+
return (1, path)
|
|
640
|
+
return (2, path)
|
|
641
|
+
|
|
642
|
+
all_files = sorted(all_files, key=_priority)
|
|
643
|
+
file_list = "\n".join(all_files[:file_sample_limit])
|
|
644
|
+
if len(all_files) > file_sample_limit:
|
|
645
|
+
file_list += f"\n... +{len(all_files) - file_sample_limit} more files"
|
|
646
|
+
|
|
647
|
+
patterns: list[str] = []
|
|
648
|
+
for rel in all_files[:20]:
|
|
649
|
+
path = project_dir / rel
|
|
650
|
+
try:
|
|
651
|
+
content = path.read_text(encoding="utf-8", errors="replace")[:2000]
|
|
652
|
+
except OSError:
|
|
653
|
+
continue
|
|
654
|
+
if "from flask" in content or "from django" in content:
|
|
655
|
+
patterns.append("Web framework: Flask/Django detected")
|
|
656
|
+
if "from fastapi" in content:
|
|
657
|
+
patterns.append("Web framework: FastAPI detected")
|
|
658
|
+
if "import torch" in content or "import tensorflow" in content:
|
|
659
|
+
patterns.append("ML framework: PyTorch/TensorFlow detected")
|
|
660
|
+
if "def test_" in content or "TestCase" in content:
|
|
661
|
+
patterns.append("Testing: pytest/unittest patterns detected")
|
|
662
|
+
if "async def" in content:
|
|
663
|
+
patterns.append("Async code: async/await patterns detected")
|
|
664
|
+
if "dataclass" in content:
|
|
665
|
+
patterns.append("Dataclasses: frozen/mutable dataclass patterns")
|
|
666
|
+
if "subprocess" in content:
|
|
667
|
+
patterns.append("Shell execution: subprocess usage detected")
|
|
668
|
+
|
|
669
|
+
existing_rules = _load_project_rules(project_dir)
|
|
670
|
+
existing_rules_text = (
|
|
671
|
+
"\n".join(f"- {item.get('rule_id', '?')}: {item.get('description', '')}" for item in existing_rules)
|
|
672
|
+
if existing_rules else "None"
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
project_name = project_dir.name or str(project_dir)
|
|
676
|
+
project_context = f"Project root: {project_name}\nTotal files: {len(all_files)}"
|
|
677
|
+
# Build builtin categories summary for the prompt
|
|
678
|
+
from ..forensic_gate_catalog import UNIVERSAL_FORENSIC_CLUSTERS
|
|
679
|
+
builtin_cats = sorted(set(cl["title"] for cl in UNIVERSAL_FORENSIC_CLUSTERS))
|
|
680
|
+
builtin_categories = chr(10).join(f"- {t}" for t in builtin_cats)
|
|
681
|
+
gates_dir = str(_generated_gates_dir(project_dir)).replace("\\", "/")
|
|
682
|
+
|
|
683
|
+
return GATE_GENERATION_PROMPT.format(
|
|
684
|
+
project_context=project_context,
|
|
685
|
+
file_list=file_list,
|
|
686
|
+
detected_patterns=chr(10).join(sorted(set(patterns))) if patterns else "No specific patterns detected",
|
|
687
|
+
existing_rules=existing_rules_text,
|
|
688
|
+
universal_cluster_count=len(UNIVERSAL_FORENSIC_CLUSTERS),
|
|
689
|
+
builtin_categories=builtin_categories,
|
|
690
|
+
gates_dir=gates_dir,
|
|
691
|
+
)
|