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.
Files changed (131) hide show
  1. vigil_codeintel-0.1.0.dist-info/METADATA +780 -0
  2. vigil_codeintel-0.1.0.dist-info/RECORD +131 -0
  3. vigil_codeintel-0.1.0.dist-info/WHEEL +5 -0
  4. vigil_codeintel-0.1.0.dist-info/entry_points.txt +3 -0
  5. vigil_codeintel-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. vigil_codeintel-0.1.0.dist-info/top_level.txt +3 -0
  7. vigil_forensic/__init__.py +224 -0
  8. vigil_forensic/_git_utils.py +178 -0
  9. vigil_forensic/_shared.py +510 -0
  10. vigil_forensic/_stubs.py +156 -0
  11. vigil_forensic/gate_checks/__init__.py +1 -0
  12. vigil_forensic/gate_checks/_ast_helpers.py +629 -0
  13. vigil_forensic/gate_checks/_deployment_detector.py +573 -0
  14. vigil_forensic/gate_checks/atomic_write_checks.py +1143 -0
  15. vigil_forensic/gate_checks/authority_checks.py +95 -0
  16. vigil_forensic/gate_checks/boundary_breach_checks.py +202 -0
  17. vigil_forensic/gate_checks/broad_except_checks.py +301 -0
  18. vigil_forensic/gate_checks/broad_except_hidden_sentinel_checks.py +365 -0
  19. vigil_forensic/gate_checks/common.py +253 -0
  20. vigil_forensic/gate_checks/config_safety_checks.py +704 -0
  21. vigil_forensic/gate_checks/config_ssot_checks.py +78 -0
  22. vigil_forensic/gate_checks/conflict_checks.py +193 -0
  23. vigil_forensic/gate_checks/context_fallback_checks.py +697 -0
  24. vigil_forensic/gate_checks/context_health_checks.py +289 -0
  25. vigil_forensic/gate_checks/contract_shape_drift_checks.py +459 -0
  26. vigil_forensic/gate_checks/dirty_baseline_check.py +274 -0
  27. vigil_forensic/gate_checks/duplication_checks.py +387 -0
  28. vigil_forensic/gate_checks/embedded_string_checks.py +123 -0
  29. vigil_forensic/gate_checks/empty_output_checks.py +87 -0
  30. vigil_forensic/gate_checks/encoding_checks.py +847 -0
  31. vigil_forensic/gate_checks/export_completeness_checks.py +156 -0
  32. vigil_forensic/gate_checks/fallback_checks.py +41 -0
  33. vigil_forensic/gate_checks/file_proliferation_checks.py +171 -0
  34. vigil_forensic/gate_checks/fix_without_test_checks.py +69 -0
  35. vigil_forensic/gate_checks/forensic_cluster_runners/__init__.py +9 -0
  36. vigil_forensic/gate_checks/forensic_cluster_runners/_helpers.py +71 -0
  37. vigil_forensic/gate_checks/forensic_cluster_runners/advanced_checks.py +322 -0
  38. vigil_forensic/gate_checks/forensic_cluster_runners/core.py +273 -0
  39. vigil_forensic/gate_checks/forensic_cluster_runners/integrity_checks.py +203 -0
  40. vigil_forensic/gate_checks/forensic_cluster_runners/quality_checks.py +666 -0
  41. vigil_forensic/gate_checks/forensic_clusters/__init__.py +193 -0
  42. vigil_forensic/gate_checks/forensic_clusters/allowlist.py +426 -0
  43. vigil_forensic/gate_checks/forensic_clusters/allowlist_writer.py +302 -0
  44. vigil_forensic/gate_checks/forensic_clusters/api_protocol.py +231 -0
  45. vigil_forensic/gate_checks/forensic_clusters/async_quality.py +1156 -0
  46. vigil_forensic/gate_checks/forensic_clusters/code_style.py +808 -0
  47. vigil_forensic/gate_checks/forensic_clusters/core.py +319 -0
  48. vigil_forensic/gate_checks/forensic_clusters/data_quality.py +763 -0
  49. vigil_forensic/gate_checks/forensic_clusters/dead_code.py +480 -0
  50. vigil_forensic/gate_checks/forensic_clusters/edit_mutation.py +842 -0
  51. vigil_forensic/gate_checks/forensic_clusters/exception_boundary.py +240 -0
  52. vigil_forensic/gate_checks/forensic_clusters/legacy_debt.py +556 -0
  53. vigil_forensic/gate_checks/forensic_clusters/static_analysis.py +834 -0
  54. vigil_forensic/gate_checks/forensic_clusters/structural_quality.py +298 -0
  55. vigil_forensic/gate_checks/god_object_zones_checks.py +173 -0
  56. vigil_forensic/gate_checks/hallucination_checks.py +566 -0
  57. vigil_forensic/gate_checks/hunter_artifact_completeness_check.py +139 -0
  58. vigil_forensic/gate_checks/implementation_overfit_checks.py +380 -0
  59. vigil_forensic/gate_checks/import_integrity_checks.py +233 -0
  60. vigil_forensic/gate_checks/imports_in_function_checks.py +283 -0
  61. vigil_forensic/gate_checks/ml_checks.py +318 -0
  62. vigil_forensic/gate_checks/performance_checks.py +106 -0
  63. vigil_forensic/gate_checks/project_specific_runner.py +691 -0
  64. vigil_forensic/gate_checks/provider_capability_checks.py +73 -0
  65. vigil_forensic/gate_checks/refactor_completeness_checks.py +274 -0
  66. vigil_forensic/gate_checks/reliability_checks.py +389 -0
  67. vigil_forensic/gate_checks/reporting_checks.py +55 -0
  68. vigil_forensic/gate_checks/runtime_behavior_checks.py +220 -0
  69. vigil_forensic/gate_checks/security_injection_checks.py +332 -0
  70. vigil_forensic/gate_checks/semantic_intent_checks.py +139 -0
  71. vigil_forensic/gate_checks/size_complexity_checks.py +336 -0
  72. vigil_forensic/gate_checks/stuck_feature_flag_checks.py +354 -0
  73. vigil_forensic/gate_checks/syntax_validity_checks.py +217 -0
  74. vigil_forensic/gate_checks/temporal_freshness_checks.py +79 -0
  75. vigil_forensic/gate_checks/test_quality_checks.py +946 -0
  76. vigil_forensic/gate_checks/testing_checks.py +149 -0
  77. vigil_forensic/gate_checks/toctou_checks.py +367 -0
  78. vigil_forensic/gate_checks/type_checking_checks.py +316 -0
  79. vigil_forensic/gate_models.py +392 -0
  80. vigil_forensic/gate_packs/__init__.py +1 -0
  81. vigil_forensic/gate_packs/universal.py +179 -0
  82. vigil_forensic/gate_profile.json +31 -0
  83. vigil_forensic/gate_registry.py +21 -0
  84. vigil_forensic/language_profiles.py +219 -0
  85. vigil_forensic/meta_findings.py +207 -0
  86. vigil_forensic/self_audit.py +725 -0
  87. vigil_forensic/source_analysis.py +175 -0
  88. vigil_mapper/__init__.py +103 -0
  89. vigil_mapper/_ast_helpers_minimal.py +229 -0
  90. vigil_mapper/_extract_imports_impl.py +123 -0
  91. vigil_mapper/_file_count_guard.py +129 -0
  92. vigil_mapper/_git_utils.py +178 -0
  93. vigil_mapper/_runtime_ast.py +438 -0
  94. vigil_mapper/_runtime_dispatch.py +137 -0
  95. vigil_mapper/_seed_helpers.py +82 -0
  96. vigil_mapper/authority_builder.py +1102 -0
  97. vigil_mapper/cli_entry.py +731 -0
  98. vigil_mapper/conflict_builder.py +818 -0
  99. vigil_mapper/data_contract_builder.py +446 -0
  100. vigil_mapper/findings_builder.py +716 -0
  101. vigil_mapper/fingerprint.py +53 -0
  102. vigil_mapper/hotspot_builder.py +539 -0
  103. vigil_mapper/map_common.py +449 -0
  104. vigil_mapper/map_errors.py +55 -0
  105. vigil_mapper/map_models.py +431 -0
  106. vigil_mapper/map_models_ext.py +206 -0
  107. vigil_mapper/map_models_findings.py +130 -0
  108. vigil_mapper/map_storage.py +455 -0
  109. vigil_mapper/parse_cache.py +795 -0
  110. vigil_mapper/refactor_boundary_builder.py +266 -0
  111. vigil_mapper/runtime_builder.py +527 -0
  112. vigil_mapper/runtime_tracer.py +243 -0
  113. vigil_mapper/runtime_tracer_entry.py +199 -0
  114. vigil_mapper/semantic_diff.py +71 -0
  115. vigil_mapper/source_adapters/__init__.py +109 -0
  116. vigil_mapper/source_adapters/_base.py +264 -0
  117. vigil_mapper/source_adapters/_ir.py +156 -0
  118. vigil_mapper/source_adapters/_lexer.py +309 -0
  119. vigil_mapper/source_adapters/_patterns.py +212 -0
  120. vigil_mapper/source_adapters/_treesitter.py +182 -0
  121. vigil_mapper/source_adapters/go.py +553 -0
  122. vigil_mapper/source_adapters/java.py +541 -0
  123. vigil_mapper/source_adapters/javascript.py +626 -0
  124. vigil_mapper/source_adapters/python.py +325 -0
  125. vigil_mapper/source_adapters/typescript.py +749 -0
  126. vigil_mapper/structural_builder.py +586 -0
  127. vigil_mcp/__init__.py +1 -0
  128. vigil_mcp/_jobs.py +587 -0
  129. vigil_mcp/_paths.py +93 -0
  130. vigil_mcp/forensic_server.py +419 -0
  131. 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
+ )