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,156 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import logging
5
+
6
+ from vigil_forensic._shared import EvidenceReference, GateCategory, GateImpact, GateSeverity, RepairKind
7
+ from vigil_forensic.gate_models import PostExecGateContext
8
+ from vigil_forensic.gate_checks.common import build_check_result, build_finding, normalize_path
9
+
10
+ _log = logging.getLogger(__name__)
11
+
12
+
13
+ def _extract_all_names(tree: ast.Module) -> list[str] | None:
14
+ """Return the list of names declared in a top-level ``__all__`` assignment.
15
+
16
+ Handles ``__all__ = [...]`` and ``__all__ = (...)``.
17
+ Returns None if no ``__all__`` assignment is found at module level.
18
+ Returns an empty list if ``__all__`` is found but contains no string constants.
19
+ """
20
+ for node in tree.body:
21
+ if not isinstance(node, ast.Assign):
22
+ continue
23
+ for target in node.targets:
24
+ if not (isinstance(target, ast.Name) and target.id == "__all__"):
25
+ continue
26
+ # Found an __all__ assignment — extract string elements
27
+ names: list[str] = []
28
+ value = node.value
29
+ if isinstance(value, (ast.List, ast.Tuple)):
30
+ for elt in value.elts:
31
+ if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
32
+ names.append(elt.value)
33
+ return names
34
+ return None
35
+
36
+
37
+ def _extract_defined_names(tree: ast.Module) -> set[str]:
38
+ """Return all names defined or imported at the top level of a module.
39
+
40
+ Covers:
41
+ - Top-level ``def`` and ``class`` declarations
42
+ - Top-level simple assignments: ``Name = ...`` (catches re-assignments like
43
+ ``MyClass = _impl.MyClass``)
44
+ - ``import X`` → ``X``
45
+ - ``import X as Z`` → ``Z``
46
+ - ``from X import Y`` → ``Y``
47
+ - ``from X import Y as Z`` → ``Z``
48
+ """
49
+ defined: set[str] = set()
50
+ for node in tree.body:
51
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
52
+ defined.add(node.name)
53
+ elif isinstance(node, ast.Assign):
54
+ for target in node.targets:
55
+ if isinstance(target, ast.Name):
56
+ defined.add(target.id)
57
+ elif isinstance(node, ast.AnnAssign):
58
+ if isinstance(node.target, ast.Name):
59
+ defined.add(node.target.id)
60
+ elif isinstance(node, ast.Import):
61
+ for alias in node.names:
62
+ # ``import X.Y`` → top-level name is ``X``
63
+ # ``import X.Y as Z`` → name is ``Z``
64
+ name = alias.asname if alias.asname else alias.name.split(".")[0]
65
+ defined.add(name)
66
+ elif isinstance(node, ast.ImportFrom):
67
+ for alias in node.names:
68
+ name = alias.asname if alias.asname else alias.name
69
+ # Wildcard imports: skip (cannot statically determine what they add)
70
+ if name != "*":
71
+ defined.add(name)
72
+ return defined
73
+
74
+
75
+ def run_export_completeness_checks(ctx: PostExecGateContext):
76
+ """Detect symbols in ``__all__`` that are not defined or imported in the same file.
77
+
78
+ Catches the "class extracted to new file but re-export not added" pattern
79
+ where a developer moves a class/function to a new module and forgets to
80
+ add ``from <new_module> import <symbol>`` back to the original file.
81
+ """
82
+ findings = []
83
+
84
+ for raw_path in ctx.changed_files_reported:
85
+ normalized = normalize_path(raw_path)
86
+
87
+ # Skip non-Python files and vendor/libs directory
88
+ if not normalized.endswith(".py"):
89
+ continue
90
+ if "SYSTEM/libs/" in normalized or normalized.startswith("SYSTEM/libs"):
91
+ continue
92
+
93
+ abs_path = ctx.project_dir / normalized
94
+ if not abs_path.exists():
95
+ continue
96
+
97
+ try:
98
+ source = abs_path.read_text(encoding="utf-8", errors="replace")
99
+ except OSError as exc:
100
+ _log.debug("export_completeness: cannot read %s: %s", normalized, exc)
101
+ continue
102
+
103
+ # Fail-open: skip unparseable files without crashing
104
+ try:
105
+ tree = ast.parse(source, filename=normalized)
106
+ except Exception as exc: # noqa: BLE001 — FX-V6-EC1 / intentional fail-open
107
+ _log.debug("export_completeness: cannot parse %s: %s", normalized, exc)
108
+ continue
109
+
110
+ all_names = _extract_all_names(tree)
111
+ if all_names is None:
112
+ # No __all__ in this file — nothing to check
113
+ continue
114
+
115
+ defined_names = _extract_defined_names(tree)
116
+ rel_path = normalized
117
+
118
+ for name in all_names:
119
+ if name in defined_names:
120
+ continue
121
+ findings.append(
122
+ build_finding(
123
+ check_id="export_completeness.missing_symbol",
124
+ category=GateCategory.CONTRACT,
125
+ title=f"__all__ declares '{name}' but it is not defined or imported",
126
+ severity=GateSeverity.HIGH,
127
+ impact=GateImpact.BLOCK,
128
+ summary=(
129
+ f"Module {rel_path!r} has __all__ = [..., {name!r}, ...] but "
130
+ f"'{name}' is neither defined nor imported in this file. "
131
+ "Likely a class/function was extracted to a new module and "
132
+ "the re-export was forgotten."
133
+ ),
134
+ recommendation=(
135
+ f"Add 'from <new_module> import {name}' to {rel_path}, "
136
+ f"or remove '{name}' from __all__"
137
+ ),
138
+ evidence=[
139
+ EvidenceReference(
140
+ kind="file",
141
+ path=rel_path,
142
+ detail=f"__all__ declares '{name}' but symbol absent",
143
+ )
144
+ ],
145
+ repair_kind=RepairKind.FIX_CONTRACT.value,
146
+ executor_action="Add missing re-export or remove from __all__",
147
+ proof_required="ImportError resolved; __all__ consistent with module contents",
148
+ allowlist_allowed=True,
149
+ )
150
+ )
151
+
152
+ return build_check_result(
153
+ check_id="export_completeness",
154
+ category=GateCategory.CONTRACT,
155
+ findings=findings,
156
+ )
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from vigil_forensic._shared import EvidenceReference, GateCategory, GateImpact, GateSeverity, RepairKind
4
+ from vigil_forensic.gate_models import PostExecGateContext
5
+ from .common import build_check_result, build_finding, iter_touched_snapshots
6
+ import logging
7
+ _log = logging.getLogger(__name__)
8
+
9
+
10
+ def run_fallback_checks(ctx: PostExecGateContext):
11
+ findings = []
12
+ profile = ctx.repo_profile
13
+ if profile is None:
14
+ return build_check_result(check_id="fallback", category=GateCategory.FALLBACK)
15
+ for snapshot in iter_touched_snapshots(ctx):
16
+ if not snapshot.exists or profile.is_generated_or_vendored(snapshot.path):
17
+ continue
18
+ text_lower = snapshot.text.lower()
19
+ for pattern, impact in profile.forbidden_fallback_patterns.items():
20
+ if pattern.lower() not in text_lower:
21
+ continue
22
+ severity = GateSeverity.CRITICAL if impact is GateImpact.BLOCK else GateSeverity.MEDIUM
23
+ if profile.is_critical(snapshot.path) and impact is GateImpact.REVISE:
24
+ severity = GateSeverity.HIGH
25
+ findings.append(
26
+ build_finding(
27
+ check_id="fallback.pattern",
28
+ category=GateCategory.FALLBACK,
29
+ title=f"Forbidden fallback or workaround marker in touched code: {pattern}",
30
+ severity=severity,
31
+ impact=impact,
32
+ summary=f"Touched file {snapshot.path} contains '{pattern}', which is disallowed or suspicious in this repo profile.",
33
+ recommendation="Remove the fallback/workaround or document and relocate it under an explicit owned policy path.",
34
+ evidence=[EvidenceReference(kind="file", path=snapshot.path, detail=pattern)],
35
+ repair_kind=RepairKind.REMOVE_FALLBACK.value,
36
+ executor_action="Remove/narrow fallback pattern",
37
+ proof_required="No fallback in file",
38
+ allowlist_allowed=False,
39
+ )
40
+ )
41
+ return build_check_result(check_id="fallback", category=GateCategory.FALLBACK, findings=findings)
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+
6
+ from vigil_forensic._shared import EvidenceReference, GateCategory, GateImpact, GateSeverity, RepairKind
7
+ from vigil_forensic.gate_models import PostExecGateContext
8
+ from ..source_analysis import get_generic_stems, is_source_file
9
+ from .common import build_check_result, build_finding, normalize_path
10
+ import logging
11
+ _log = logging.getLogger(__name__)
12
+
13
+
14
+ # Suffixes that suggest a "v2" clone rather than a proper edit.
15
+ _CLONE_PATTERNS = re.compile(
16
+ r'_v\d+|_new|_copy|_backup|_old|_fixed|_updated|_refactored|_alt'
17
+ r'|\.bak|\.orig|\.copy',
18
+ re.IGNORECASE,
19
+ )
20
+
21
+
22
+ def run_file_proliferation_checks(ctx: PostExecGateContext):
23
+ """Detect new files that duplicate or shadow existing project files.
24
+
25
+ Catches three patterns:
26
+ 1. Clone naming: executor creates foo_v2.py when foo.py exists
27
+ 2. Generic helper proliferation: executor creates new utils.py / helpers.py
28
+ in a directory that already has one nearby
29
+ 3. Shadow file: executor creates a file with the SAME basename in a
30
+ different directory when one already exists in the project
31
+ """
32
+ findings = []
33
+ if not ctx.changed_files_observed:
34
+ return build_check_result(check_id="file_proliferation", category=GateCategory.DUPLICATION)
35
+
36
+ # Build a set of pre-existing project file basenames for shadow detection.
37
+ # Only scan known source roots to keep it fast.
38
+ existing_basenames: dict[str, list[str]] = {}
39
+ for root_name in ctx.source_package_roots:
40
+ root = ctx.project_dir / root_name
41
+ if not root.is_dir():
42
+ continue
43
+ file_count = 0
44
+ for src_file in root.rglob("*"):
45
+ if not src_file.is_file():
46
+ continue
47
+ rel = str(src_file.relative_to(ctx.project_dir)).replace("\\", "/")
48
+ if not is_source_file(rel):
49
+ continue
50
+ file_count += 1
51
+ if file_count > 2000:
52
+ break
53
+ existing_basenames.setdefault(src_file.name, []).append(rel)
54
+
55
+ for raw_path in ctx.changed_files_observed:
56
+ normalized = normalize_path(raw_path)
57
+ if not is_source_file(normalized):
58
+ continue
59
+ # Only check NEW files (created by executor, not pre-existing edits)
60
+ if normalized not in set(normalize_path(p) for p in ctx.changed_files_reported):
61
+ continue
62
+
63
+ basename = os.path.basename(normalized)
64
+ stem = basename.rsplit(".", 1)[0] if "." in basename else basename
65
+
66
+ # Check 1: Clone naming (_v2, _new, _copy, etc.)
67
+ if _CLONE_PATTERNS.search(stem):
68
+ base_stem = _CLONE_PATTERNS.sub("", stem)
69
+ ext = basename.rsplit(".", 1)[1] if "." in basename else ""
70
+ base_name = f"{base_stem}.{ext}" if ext else base_stem
71
+ if base_name in existing_basenames:
72
+ base_paths = existing_basenames[base_name]
73
+ findings.append(
74
+ build_finding(
75
+ check_id="file_proliferation.clone_naming",
76
+ category=GateCategory.DUPLICATION,
77
+ title=f"Clone-named file created: {normalized}",
78
+ severity=GateSeverity.HIGH,
79
+ impact=GateImpact.REVISE,
80
+ summary=(
81
+ f"Executor created {normalized} which looks like a clone of "
82
+ f"existing {base_paths[0]}. Edit the original file instead "
83
+ "of creating a copy with a version suffix."
84
+ ),
85
+ recommendation=(
86
+ f"Modify {base_paths[0]} directly. If a new module is truly "
87
+ "needed, give it a distinct semantic name, not a version suffix."
88
+ ),
89
+ evidence=[
90
+ EvidenceReference(kind="file", path=normalized, detail="clone"),
91
+ EvidenceReference(kind="file", path=base_paths[0], detail="original"),
92
+ ],
93
+ repair_kind=RepairKind.EDIT_CANONICAL.value,
94
+ executor_action=f"Delete {normalized}; apply changes to canonical {base_paths[0]} instead",
95
+ proof_required="clone file deleted; canonical file contains the intended change",
96
+ allowlist_allowed=False,
97
+ )
98
+ )
99
+
100
+ # Check 2: Generic helper proliferation
101
+ stems_for_lang = get_generic_stems(normalized)
102
+ if stem.lower() in stems_for_lang:
103
+ dir_path = os.path.dirname(normalized)
104
+ siblings = [
105
+ path for bname, paths in existing_basenames.items()
106
+ for path in paths
107
+ if bname.rsplit(".", 1)[0].lower() in stems_for_lang
108
+ and os.path.dirname(path) == dir_path
109
+ and path != normalized
110
+ ]
111
+ if siblings:
112
+ findings.append(
113
+ build_finding(
114
+ check_id="file_proliferation.generic_helper",
115
+ category=GateCategory.DUPLICATION,
116
+ title=f"Generic helper file created alongside existing: {normalized}",
117
+ severity=GateSeverity.MEDIUM,
118
+ impact=GateImpact.REVISE,
119
+ summary=(
120
+ f"Executor created {normalized} but {siblings[0]} already "
121
+ "exists in the same directory. Adding another generic helper "
122
+ "file suggests logic should be added to the existing one."
123
+ ),
124
+ recommendation=f"Add the new functions to {siblings[0]} instead.",
125
+ evidence=[
126
+ EvidenceReference(kind="file", path=normalized, detail="new_generic"),
127
+ EvidenceReference(kind="file", path=siblings[0], detail="existing_generic"),
128
+ ],
129
+ repair_kind=RepairKind.EDIT_CANONICAL.value,
130
+ executor_action=f"Move content from {normalized} into {siblings[0]}; delete {normalized}",
131
+ proof_required="new file deleted; functions accessible from existing helper",
132
+ )
133
+ )
134
+
135
+ # Check 3: Shadow file (same basename, different directory)
136
+ if basename in existing_basenames:
137
+ other_locations = [
138
+ p for p in existing_basenames[basename]
139
+ if p != normalized and os.path.dirname(p) != os.path.dirname(normalized)
140
+ ]
141
+ if other_locations and not basename.startswith("__"):
142
+ findings.append(
143
+ build_finding(
144
+ check_id="file_proliferation.shadow_file",
145
+ category=GateCategory.DUPLICATION,
146
+ title=f"File with same name exists elsewhere: {basename}",
147
+ severity=GateSeverity.MEDIUM,
148
+ impact=GateImpact.REVISE,
149
+ summary=(
150
+ f"Executor created {normalized} but {other_locations[0]} "
151
+ "already exists with the same filename in a different directory. "
152
+ "This may cause import confusion or indicate a copy-paste."
153
+ ),
154
+ recommendation=(
155
+ "Verify this is intentional. If both files serve the same purpose, "
156
+ "consolidate into one and import from the canonical location."
157
+ ),
158
+ evidence=[
159
+ EvidenceReference(kind="file", path=normalized, detail="new"),
160
+ EvidenceReference(kind="file", path=other_locations[0], detail="existing"),
161
+ ],
162
+ repair_kind=RepairKind.EDIT_CANONICAL.value,
163
+ executor_action=f"Verify {normalized} vs {other_locations[0]} — if same purpose, consolidate and import from canonical",
164
+ )
165
+ )
166
+
167
+ return build_check_result(
168
+ check_id="file_proliferation",
169
+ category=GateCategory.DUPLICATION,
170
+ findings=findings,
171
+ )
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from vigil_forensic._shared import SOURCE_EXTENSIONS as _SOURCE_EXTENSIONS
4
+ from vigil_forensic._shared import EvidenceReference, GateCategory, GateImpact, GateSeverity
5
+ from vigil_forensic.gate_models import PostExecGateContext
6
+ from .common import build_check_result, build_finding, normalize_path
7
+ import logging
8
+ _log = logging.getLogger(__name__)
9
+
10
+ _FIX_KEYWORDS = frozenset({"fix", "repair", "bug", "patch", "hotfix", "bugfix"})
11
+ # Sprint C3 (2026-04-23): _SOURCE_EXTENSIONS imported above from
12
+ # SYSTEM.shared_helpers.file_extensions. Keep the private alias so existing
13
+ # call sites resolve without having to rewrite the suffix lookup.
14
+
15
+
16
+ def _intent_has_fix_keyword(task_intent: str) -> bool:
17
+ """Check if task_intent contains a fix-related keyword as a word token."""
18
+ tokens = set(task_intent.lower().replace("-", "_").split("_"))
19
+ # Also split on spaces for natural language intents
20
+ for word in task_intent.lower().split():
21
+ tokens.update(word.replace("-", "_").split("_"))
22
+ return bool(tokens & _FIX_KEYWORDS)
23
+
24
+
25
+ def run_fix_without_test_checks(ctx: PostExecGateContext):
26
+ findings = []
27
+ if ctx.task_intent == "metadata_only":
28
+ return build_check_result(check_id="fix_without_test", category=GateCategory.TESTING)
29
+ if not _intent_has_fix_keyword(ctx.task_intent):
30
+ return build_check_result(check_id="fix_without_test", category=GateCategory.TESTING)
31
+
32
+ has_source_changes = False
33
+ source_files = []
34
+ for raw_path in ctx.changed_files_observed:
35
+ normalized = normalize_path(raw_path)
36
+ suffix = "." + normalized.rsplit(".", 1)[-1] if "." in normalized else ""
37
+ if suffix.lower() in _SOURCE_EXTENSIONS:
38
+ has_source_changes = True
39
+ source_files.append(normalized)
40
+
41
+ if not has_source_changes:
42
+ return build_check_result(check_id="fix_without_test", category=GateCategory.TESTING)
43
+
44
+ if not ctx.tests_touched:
45
+ findings.append(
46
+ build_finding(
47
+ check_id="fix_without_test.no_tests",
48
+ category=GateCategory.TESTING,
49
+ title="Fix/repair task changed source files but no tests",
50
+ severity=GateSeverity.MEDIUM,
51
+ impact=GateImpact.REVISE,
52
+ summary=(
53
+ f"Task intent '{ctx.task_intent}' indicates a fix/repair, and "
54
+ f"{len(source_files)} source file(s) were changed, but no test files "
55
+ "were touched. Consider adding a regression test."
56
+ ),
57
+ recommendation="Add a test that reproduces the bug and verifies the fix.",
58
+ evidence=[
59
+ EvidenceReference(kind="file", path=source_files[0], detail="source_changed_no_test")
60
+ ],
61
+
62
+ repair_kind='add_regression_test',
63
+ executor_action='Add tests for coverage',
64
+ proof_required='Tests added/passing',
65
+ allowlist_allowed=True,
66
+ )
67
+ )
68
+
69
+ return build_check_result(check_id="fix_without_test", category=GateCategory.TESTING, findings=findings)
@@ -0,0 +1,9 @@
1
+ """forensic_cluster_runners -- package API.
2
+
3
+ Public surface: run_forensic_cluster_checks
4
+ Private symbols re-exported for test compatibility: _check_js_surface_coverage
5
+ """
6
+ from .core import run_forensic_cluster_checks
7
+ from .quality_checks import _check_js_surface_coverage
8
+
9
+ __all__ = ["run_forensic_cluster_checks", "_check_js_surface_coverage"]
@@ -0,0 +1,71 @@
1
+ """Shared helpers for forensic cluster runner sub-modules."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from typing import List
6
+
7
+ from ...gate_models import (
8
+ EvidenceReference,
9
+ GateCategory,
10
+ GateFinding,
11
+ GateImpact,
12
+ GateSeverity,
13
+ RepairKind,
14
+ )
15
+ from ..common import build_finding
16
+
17
+ _log = logging.getLogger(__name__)
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Global cap: each per-file scanner returns at most this many findings total
21
+ # ---------------------------------------------------------------------------
22
+
23
+ _MAX_FINDINGS_PER_CLUSTER = 30
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Internal helpers
28
+ # ---------------------------------------------------------------------------
29
+
30
+
31
+ def _safe_run(label: str, fn, findings: List[GateFinding], notes: List[str]) -> None:
32
+ """Run *fn*, extending *findings* with results; on error append error finding + note."""
33
+ try:
34
+ result = fn()
35
+ if isinstance(result, list):
36
+ if result:
37
+ findings.extend(result)
38
+ else:
39
+ notes.append(f"[forensic_clusters] {label}: not applicable")
40
+ elif result is not None:
41
+ # Fail-loud: unexpected return type is a runner bug, not a skip
42
+ _log.error("forensic_clusters: %s returned unexpected type %s", label, type(result).__name__)
43
+ findings.append(build_finding(
44
+ check_id=f"internal_failure.{label}",
45
+ category=GateCategory.CONTRACT,
46
+ title=f"[internal_failure] {label} — unexpected return type",
47
+ severity=GateSeverity.HIGH,
48
+ impact=GateImpact.REVISE,
49
+ summary=f"Cluster runner {label!r} returned {type(result).__name__} instead of list[GateFinding]",
50
+ recommendation="Fix the cluster runner to return list[GateFinding] or empty list.",
51
+ evidence=(EvidenceReference(kind="probe", detail=f"Unexpected return: {type(result)}", ok=False),),
52
+ repair_kind=RepairKind.INVESTIGATE_GATE_FAILURE.value,
53
+ executor_action=f"Fix return type in cluster runner {label!r}",
54
+ ))
55
+ notes.append(f"[forensic_clusters] {label} unexpected return type: {type(result).__name__}")
56
+ except Exception as exc: # noqa: BLE001
57
+ _log.error("forensic_clusters: %s internal error: %s", label, exc, exc_info=True)
58
+ detail = f"Cluster runner {label!r} raised {type(exc).__name__}: {exc}"
59
+ findings.append(build_finding(
60
+ check_id=f"internal_failure.{label}",
61
+ category=GateCategory.CONTRACT,
62
+ title=f"[internal_failure] {label}",
63
+ severity=GateSeverity.HIGH,
64
+ impact=GateImpact.REVISE,
65
+ summary=detail,
66
+ recommendation="Fix the internal error in the forensic cluster runner.",
67
+ evidence=(EvidenceReference(kind="probe", detail=detail, ok=False),),
68
+ repair_kind=RepairKind.INVESTIGATE_GATE_FAILURE.value,
69
+ executor_action=f"Fix internal failure in cluster runner {label!r}",
70
+ ))
71
+ notes.append(f"[forensic_clusters] {label} internal error: {exc}")