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,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}")
|