shockharness 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.
- shockharness/__init__.py +13 -0
- shockharness/__main__.py +5 -0
- shockharness/analysis.py +447 -0
- shockharness/api.py +114 -0
- shockharness/cli.py +596 -0
- shockharness/complexity/__init__.py +8 -0
- shockharness/complexity/assemble.py +119 -0
- shockharness/complexity/callgraph.py +754 -0
- shockharness/complexity/cognitive.py +96 -0
- shockharness/complexity/coupling.py +350 -0
- shockharness/complexity/cyclomatic.py +45 -0
- shockharness/complexity/metrics.py +148 -0
- shockharness/complexity/nesting.py +50 -0
- shockharness/complexity/npath.py +110 -0
- shockharness/complexity/regressions.py +176 -0
- shockharness/complexity/size.py +28 -0
- shockharness/config.py +502 -0
- shockharness/constants.py +77 -0
- shockharness/defaults.toml +102 -0
- shockharness/discovery.py +143 -0
- shockharness/errors.py +42 -0
- shockharness/findings.py +87 -0
- shockharness/gate.py +232 -0
- shockharness/gating/__init__.py +1 -0
- shockharness/gating/baseline.py +349 -0
- shockharness/gating/diff.py +260 -0
- shockharness/globs.py +142 -0
- shockharness/history/__init__.py +1 -0
- shockharness/history/cochange.py +73 -0
- shockharness/history/git.py +182 -0
- shockharness/history/hotspots.py +163 -0
- shockharness/ir/__init__.py +1 -0
- shockharness/ir/javascript.py +181 -0
- shockharness/ir/javascript_catches.py +570 -0
- shockharness/ir/javascript_defaults.py +874 -0
- shockharness/ir/javascript_functions.py +1135 -0
- shockharness/ir/lines.py +54 -0
- shockharness/ir/model.py +411 -0
- shockharness/ir/python.py +185 -0
- shockharness/ir/python_catches.py +441 -0
- shockharness/ir/python_defaults.py +917 -0
- shockharness/ir/python_functions.py +865 -0
- shockharness/output/__init__.py +1 -0
- shockharness/output/json.py +15 -0
- shockharness/output/sarif.py +203 -0
- shockharness/output/text.py +248 -0
- shockharness/profiles/__init__.py +1 -0
- shockharness/profiles/minimal.toml +20 -0
- shockharness/profiles/recommended.toml +7 -0
- shockharness/profiles/strict.toml +5 -0
- shockharness/report.py +188 -0
- shockharness/rules/__init__.py +1 -0
- shockharness/rules/complexity.py +207 -0
- shockharness/rules/defaults.py +285 -0
- shockharness/rules/exceptions.py +318 -0
- shockharness/rules/magic_numbers.py +59 -0
- shockharness/rules/registry.py +917 -0
- shockharness/rules/severity.py +23 -0
- shockharness/schema.py +271 -0
- shockharness/skills/__init__.py +91 -0
- shockharness/skills/adapters.py +106 -0
- shockharness/skills/fix-violations/SKILL.md +393 -0
- shockharness/skills/install.py +336 -0
- shockharness/skills/pre-commit-review/SKILL.md +304 -0
- shockharness/skills/refactor-complexity/SKILL.md +413 -0
- shockharness/skills/write-compliant-code/SKILL.md +400 -0
- shockharness/source.py +52 -0
- shockharness/suppressions.py +195 -0
- shockharness/version.py +3 -0
- shockharness-0.1.0.dist-info/METADATA +447 -0
- shockharness-0.1.0.dist-info/RECORD +74 -0
- shockharness-0.1.0.dist-info/WHEEL +4 -0
- shockharness-0.1.0.dist-info/entry_points.txt +2 -0
- shockharness-0.1.0.dist-info/licenses/LICENSE +21 -0
shockharness/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""ShockHarness: a strict static-analysis quality gate for Python and JavaScript.
|
|
2
|
+
|
|
3
|
+
Three default-on policy families: exception transparency, no implicit defaults,
|
|
4
|
+
and no magic numbers (only 0 and 1 are permitted numeric literals). Findings are
|
|
5
|
+
deterministic, explained, and never averaged into a single score.
|
|
6
|
+
|
|
7
|
+
Public library API lives in :mod:`shockharness.api`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from shockharness.api import analyze_paths, analyze_source, evaluate_gate
|
|
11
|
+
from shockharness.version import __version__
|
|
12
|
+
|
|
13
|
+
__all__ = ["__version__", "analyze_paths", "analyze_source", "evaluate_gate"]
|
shockharness/__main__.py
ADDED
shockharness/analysis.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""Per-file analysis pipeline: read, decode, parse, run rules, apply suppressions.
|
|
2
|
+
|
|
3
|
+
A file that cannot be read, decoded, or parsed gets an explicit non-``analyzed``
|
|
4
|
+
status and zero findings. It is never reported as clean; the gate decides what
|
|
5
|
+
that status means (``parse_errors`` = fail or warn).
|
|
6
|
+
|
|
7
|
+
Since Milestone E the pipeline has two phases. :func:`prepare_file` runs the
|
|
8
|
+
per-file rules and measures the file's units; :func:`analyze_files` then builds
|
|
9
|
+
the cross-file call graph over every parsed file (``CPLX005`` needs it) and
|
|
10
|
+
only after that applies suppressions and assigns fingerprints, so a graph
|
|
11
|
+
finding can be suppressed and baselined like any other.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from collections.abc import Mapping, Sequence
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from shockharness.complexity.assemble import ComplexityOutcome, assemble
|
|
20
|
+
from shockharness.complexity.callgraph import ModuleInput
|
|
21
|
+
from shockharness.complexity.metrics import UnitMetrics, measure_units
|
|
22
|
+
from shockharness.config import EffectiveConfig
|
|
23
|
+
from shockharness.discovery import DiscoveredFile
|
|
24
|
+
from shockharness.findings import Finding
|
|
25
|
+
from shockharness.gating.baseline import assign_fingerprints
|
|
26
|
+
from shockharness.globs import compile_globset
|
|
27
|
+
from shockharness.ir.javascript import extract_javascript_facts
|
|
28
|
+
from shockharness.ir.model import ModuleFacts, ParseFailure
|
|
29
|
+
from shockharness.ir.python import extract_python_facts
|
|
30
|
+
from shockharness.rules import registry
|
|
31
|
+
from shockharness.rules.complexity import run_unit_rules
|
|
32
|
+
from shockharness.rules.defaults import DEFAULT_RULES, run_default_rules
|
|
33
|
+
from shockharness.rules.exceptions import run_exception_rules
|
|
34
|
+
from shockharness.rules.magic_numbers import run_magic001
|
|
35
|
+
from shockharness.source import SourceText
|
|
36
|
+
from shockharness.suppressions import apply_suppressions
|
|
37
|
+
|
|
38
|
+
STATUS_ANALYZED = "analyzed"
|
|
39
|
+
STATUS_PARSE_ERROR = "parse_error"
|
|
40
|
+
STATUS_DECODE_ERROR = "decode_error"
|
|
41
|
+
STATUS_READ_ERROR = "read_error"
|
|
42
|
+
FAILURE_STATUSES = frozenset({STATUS_PARSE_ERROR, STATUS_DECODE_ERROR, STATUS_READ_ERROR})
|
|
43
|
+
|
|
44
|
+
ORIGIN_CONFIG = "config"
|
|
45
|
+
ORIGIN_OVERRIDE = "override"
|
|
46
|
+
ORIGIN_CONFIGURATION_FILE = "configuration_file"
|
|
47
|
+
|
|
48
|
+
COMPLEXITY_SECTION = "complexity"
|
|
49
|
+
UNIT_RULES = (registry.CPLX001, registry.CPLX002, registry.CPLX003, registry.CPLX004)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class AnalysisOptions:
|
|
54
|
+
inline_suppressions: bool
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class RuleDisable:
|
|
59
|
+
rule_id: str
|
|
60
|
+
reason: str
|
|
61
|
+
origin: str
|
|
62
|
+
|
|
63
|
+
def to_json_dict(self) -> dict[str, str]:
|
|
64
|
+
return {"rule_id": self.rule_id, "reason": self.reason, "origin": self.origin}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class FileResult:
|
|
69
|
+
path: str
|
|
70
|
+
language: str
|
|
71
|
+
parser: str | None
|
|
72
|
+
status: str
|
|
73
|
+
line_count: int
|
|
74
|
+
rules_applied: tuple[str, ...]
|
|
75
|
+
rules_disabled: tuple[RuleDisable, ...]
|
|
76
|
+
parse_failure: ParseFailure | None
|
|
77
|
+
error: str | None
|
|
78
|
+
findings: tuple[Finding, ...]
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def failed(self) -> bool:
|
|
82
|
+
return self.status in FAILURE_STATUSES
|
|
83
|
+
|
|
84
|
+
def to_json_dict(self) -> dict[str, Any]:
|
|
85
|
+
return {
|
|
86
|
+
"path": self.path,
|
|
87
|
+
"language": self.language,
|
|
88
|
+
"parser": self.parser,
|
|
89
|
+
"status": self.status,
|
|
90
|
+
"line_count": self.line_count,
|
|
91
|
+
"rules_applied": list(self.rules_applied),
|
|
92
|
+
"rules_disabled": [d.to_json_dict() for d in self.rules_disabled],
|
|
93
|
+
"parse_failure": None
|
|
94
|
+
if self.parse_failure is None
|
|
95
|
+
else self.parse_failure.to_json_dict(),
|
|
96
|
+
"error": self.error,
|
|
97
|
+
"finding_count": len(self.findings),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class PendingFile:
|
|
103
|
+
"""A file after the per-file phase, before suppressions and fingerprints.
|
|
104
|
+
|
|
105
|
+
``source``/``facts``/``units`` are ``None`` for a file that failed to read,
|
|
106
|
+
decode or parse; ``findings`` are the per-file rule findings without
|
|
107
|
+
suppression markers.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
path: str
|
|
111
|
+
language: str
|
|
112
|
+
parser: str | None
|
|
113
|
+
status: str
|
|
114
|
+
line_count: int
|
|
115
|
+
rules_applied: tuple[str, ...]
|
|
116
|
+
rules_disabled: tuple[RuleDisable, ...]
|
|
117
|
+
parse_failure: ParseFailure | None
|
|
118
|
+
error: str | None
|
|
119
|
+
source: SourceText | None
|
|
120
|
+
facts: ModuleFacts | None
|
|
121
|
+
units: tuple[UnitMetrics, ...]
|
|
122
|
+
findings: tuple[Finding, ...]
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def failed(self) -> bool:
|
|
126
|
+
return self.status in FAILURE_STATUSES
|
|
127
|
+
|
|
128
|
+
def disabled_ids(self) -> frozenset[str]:
|
|
129
|
+
return frozenset(d.rule_id for d in self.rules_disabled)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class AnalysisOutcome:
|
|
134
|
+
files: tuple[FileResult, ...]
|
|
135
|
+
metrics: Mapping[str, Any] # ``complexity`` and ``graph`` sections of metrics
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _disabled_rules(
|
|
139
|
+
relative_path: str, language: str, config: EffectiveConfig
|
|
140
|
+
) -> tuple[RuleDisable, ...]:
|
|
141
|
+
disabled: dict[str, RuleDisable] = {}
|
|
142
|
+
rules_config: Mapping[str, Any] = config.values["rules"]
|
|
143
|
+
for rule in registry.implemented_rules():
|
|
144
|
+
if rule.config_section is None:
|
|
145
|
+
continue
|
|
146
|
+
section = config.section(rule.config_section)
|
|
147
|
+
if section["enabled"] is False:
|
|
148
|
+
disabled[rule.rule_id] = RuleDisable(
|
|
149
|
+
rule.rule_id, f"{rule.config_section}.enabled = false", ORIGIN_CONFIG
|
|
150
|
+
)
|
|
151
|
+
magic = rules_config["magic_numbers"]
|
|
152
|
+
if magic["exclude_configuration_files"] is True and registry.MAGIC001.rule_id not in disabled:
|
|
153
|
+
patterns = compile_globset(list(magic["configuration_files"]))
|
|
154
|
+
matched = patterns.first_match(relative_path)
|
|
155
|
+
if matched is not None:
|
|
156
|
+
disabled[registry.MAGIC001.rule_id] = RuleDisable(
|
|
157
|
+
registry.MAGIC001.rule_id,
|
|
158
|
+
f"file matches configuration_files pattern {matched!r}",
|
|
159
|
+
ORIGIN_CONFIGURATION_FILE,
|
|
160
|
+
)
|
|
161
|
+
for index, override in enumerate(config.values["overrides"]):
|
|
162
|
+
paths = compile_globset(list(override["paths"]))
|
|
163
|
+
if not paths.matches(relative_path):
|
|
164
|
+
continue
|
|
165
|
+
for rule_id in override["disable"]:
|
|
166
|
+
if rule_id in disabled:
|
|
167
|
+
continue
|
|
168
|
+
disabled[rule_id] = RuleDisable(rule_id, override["reason"], f"overrides[{index}]")
|
|
169
|
+
return tuple(disabled[key] for key in sorted(disabled))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _extract(language: str, source: SourceText, filename: str) -> ModuleFacts:
|
|
173
|
+
if language == registry.PYTHON:
|
|
174
|
+
return extract_python_facts(source, filename)
|
|
175
|
+
if language == registry.JAVASCRIPT:
|
|
176
|
+
return extract_javascript_facts(source, filename)
|
|
177
|
+
raise ValueError(f"no parser adapter for language {language!r}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _failed(
|
|
181
|
+
relative_path: str,
|
|
182
|
+
language: str,
|
|
183
|
+
parser: str | None,
|
|
184
|
+
status: str,
|
|
185
|
+
line_count: int,
|
|
186
|
+
disabled: tuple[RuleDisable, ...],
|
|
187
|
+
parse_failure: ParseFailure | None,
|
|
188
|
+
error: str | None,
|
|
189
|
+
) -> PendingFile:
|
|
190
|
+
return PendingFile(
|
|
191
|
+
path=relative_path,
|
|
192
|
+
language=language,
|
|
193
|
+
parser=parser,
|
|
194
|
+
status=status,
|
|
195
|
+
line_count=line_count,
|
|
196
|
+
rules_applied=(),
|
|
197
|
+
rules_disabled=disabled,
|
|
198
|
+
parse_failure=parse_failure,
|
|
199
|
+
error=error,
|
|
200
|
+
source=None,
|
|
201
|
+
facts=None,
|
|
202
|
+
units=(),
|
|
203
|
+
findings=(),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def prepare_text(
|
|
208
|
+
relative_path: str, language: str, text: str, config: EffectiveConfig
|
|
209
|
+
) -> PendingFile:
|
|
210
|
+
"""Phase one: parse, run the per-file rules, measure the units."""
|
|
211
|
+
source = SourceText.from_text(text)
|
|
212
|
+
facts = _extract(language, source, relative_path)
|
|
213
|
+
disabled = _disabled_rules(relative_path, language, config)
|
|
214
|
+
disabled_ids = {d.rule_id for d in disabled}
|
|
215
|
+
|
|
216
|
+
if facts.parse_failure is not None:
|
|
217
|
+
return _failed(
|
|
218
|
+
relative_path,
|
|
219
|
+
language,
|
|
220
|
+
facts.parser,
|
|
221
|
+
STATUS_PARSE_ERROR,
|
|
222
|
+
source.line_count,
|
|
223
|
+
disabled,
|
|
224
|
+
facts.parse_failure,
|
|
225
|
+
facts.parse_failure.message,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
applied: list[str] = []
|
|
229
|
+
findings: list[Finding] = []
|
|
230
|
+
if registry.MAGIC001.rule_id not in disabled_ids:
|
|
231
|
+
applied.append(registry.MAGIC001.rule_id)
|
|
232
|
+
findings.extend(
|
|
233
|
+
run_magic001(
|
|
234
|
+
facts, source, config.section(str(registry.MAGIC001.config_section)), relative_path
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
active_default_rules = frozenset(
|
|
239
|
+
rule.rule_id for rule in DEFAULT_RULES if rule.rule_id not in disabled_ids
|
|
240
|
+
)
|
|
241
|
+
active_exception_rules = frozenset(
|
|
242
|
+
rule.rule_id
|
|
243
|
+
for rule in (registry.EXC001, registry.EXC002, registry.EXC003)
|
|
244
|
+
if rule.rule_id not in disabled_ids
|
|
245
|
+
)
|
|
246
|
+
if len(active_exception_rules) > 0:
|
|
247
|
+
applied.extend(sorted(active_exception_rules))
|
|
248
|
+
findings.extend(
|
|
249
|
+
run_exception_rules(
|
|
250
|
+
facts,
|
|
251
|
+
source,
|
|
252
|
+
config.section(str(registry.EXC001.config_section)),
|
|
253
|
+
relative_path,
|
|
254
|
+
language,
|
|
255
|
+
active_exception_rules,
|
|
256
|
+
registry.DEF005.rule_id in active_default_rules,
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
if len(active_default_rules) > 0:
|
|
260
|
+
applied.extend(sorted(active_default_rules))
|
|
261
|
+
findings.extend(
|
|
262
|
+
run_default_rules(
|
|
263
|
+
facts,
|
|
264
|
+
source,
|
|
265
|
+
config.section(str(registry.DEF001.config_section)),
|
|
266
|
+
relative_path,
|
|
267
|
+
language,
|
|
268
|
+
active_default_rules,
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
complexity = config.section(COMPLEXITY_SECTION)
|
|
273
|
+
units: tuple[UnitMetrics, ...] = ()
|
|
274
|
+
if complexity["enabled"] is True:
|
|
275
|
+
units = measure_units(relative_path, facts.functions, complexity)
|
|
276
|
+
active_unit_rules = frozenset(
|
|
277
|
+
rule.rule_id for rule in UNIT_RULES if rule.rule_id not in disabled_ids
|
|
278
|
+
)
|
|
279
|
+
if len(active_unit_rules) > 0:
|
|
280
|
+
applied.extend(sorted(active_unit_rules))
|
|
281
|
+
findings.extend(
|
|
282
|
+
run_unit_rules(units, source, complexity, relative_path, active_unit_rules)
|
|
283
|
+
)
|
|
284
|
+
if registry.CPLX005.rule_id not in disabled_ids:
|
|
285
|
+
applied.append(registry.CPLX005.rule_id)
|
|
286
|
+
|
|
287
|
+
return PendingFile(
|
|
288
|
+
path=relative_path,
|
|
289
|
+
language=language,
|
|
290
|
+
parser=facts.parser,
|
|
291
|
+
status=STATUS_ANALYZED,
|
|
292
|
+
line_count=source.line_count,
|
|
293
|
+
rules_applied=tuple(applied),
|
|
294
|
+
rules_disabled=disabled,
|
|
295
|
+
parse_failure=None,
|
|
296
|
+
error=None,
|
|
297
|
+
source=source,
|
|
298
|
+
facts=facts,
|
|
299
|
+
units=units,
|
|
300
|
+
findings=tuple(findings),
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _read_source(path: Path) -> tuple[str | None, str | None, str | None]:
|
|
305
|
+
"""Return (text, status, error). Exactly one of text / error is set."""
|
|
306
|
+
# Boundary (declared in .shockharness.toml): reading and decoding analyzed files.
|
|
307
|
+
try:
|
|
308
|
+
raw = path.read_bytes()
|
|
309
|
+
except OSError as exc:
|
|
310
|
+
return None, STATUS_READ_ERROR, f"cannot read file: {exc}"
|
|
311
|
+
try:
|
|
312
|
+
text = raw.decode("utf-8-sig")
|
|
313
|
+
except UnicodeDecodeError as exc:
|
|
314
|
+
return None, STATUS_DECODE_ERROR, f"file is not valid UTF-8: {exc}"
|
|
315
|
+
return text, None, None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def prepare_file(file: DiscoveredFile, config: EffectiveConfig) -> PendingFile:
|
|
319
|
+
text, status, error = _read_source(file.path)
|
|
320
|
+
if text is None:
|
|
321
|
+
assert status is not None
|
|
322
|
+
return _failed(
|
|
323
|
+
file.relative,
|
|
324
|
+
file.language,
|
|
325
|
+
None,
|
|
326
|
+
status,
|
|
327
|
+
0,
|
|
328
|
+
_disabled_rules(file.relative, file.language, config),
|
|
329
|
+
None,
|
|
330
|
+
error,
|
|
331
|
+
)
|
|
332
|
+
return prepare_text(file.relative, file.language, text, config)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def finish(pending: PendingFile, extra: Sequence[Finding], options: AnalysisOptions) -> FileResult:
|
|
336
|
+
"""Phase three: suppressions (SUPP001/SUPP002), ordering, fingerprints."""
|
|
337
|
+
if pending.failed:
|
|
338
|
+
return FileResult(
|
|
339
|
+
path=pending.path,
|
|
340
|
+
language=pending.language,
|
|
341
|
+
parser=pending.parser,
|
|
342
|
+
status=pending.status,
|
|
343
|
+
line_count=pending.line_count,
|
|
344
|
+
rules_applied=(),
|
|
345
|
+
rules_disabled=pending.rules_disabled,
|
|
346
|
+
parse_failure=pending.parse_failure,
|
|
347
|
+
error=pending.error,
|
|
348
|
+
findings=(),
|
|
349
|
+
)
|
|
350
|
+
assert pending.source is not None and pending.facts is not None
|
|
351
|
+
disabled_ids = pending.disabled_ids()
|
|
352
|
+
applied = list(pending.rules_applied)
|
|
353
|
+
supp001_enabled = registry.SUPP001.rule_id not in disabled_ids
|
|
354
|
+
supp002_enabled = registry.SUPP002.rule_id not in disabled_ids
|
|
355
|
+
if supp001_enabled:
|
|
356
|
+
applied.append(registry.SUPP001.rule_id)
|
|
357
|
+
if supp002_enabled:
|
|
358
|
+
applied.append(registry.SUPP002.rule_id)
|
|
359
|
+
findings = apply_suppressions(
|
|
360
|
+
pending.path,
|
|
361
|
+
[*pending.findings, *extra],
|
|
362
|
+
pending.facts.comments,
|
|
363
|
+
pending.source,
|
|
364
|
+
options.inline_suppressions,
|
|
365
|
+
supp001_enabled,
|
|
366
|
+
supp002_enabled,
|
|
367
|
+
)
|
|
368
|
+
findings.sort(key=lambda f: f.sort_key())
|
|
369
|
+
findings = assign_fingerprints(findings)
|
|
370
|
+
return FileResult(
|
|
371
|
+
path=pending.path,
|
|
372
|
+
language=pending.language,
|
|
373
|
+
parser=pending.parser,
|
|
374
|
+
status=STATUS_ANALYZED,
|
|
375
|
+
line_count=pending.line_count,
|
|
376
|
+
rules_applied=tuple(sorted(applied)),
|
|
377
|
+
rules_disabled=pending.rules_disabled,
|
|
378
|
+
parse_failure=None,
|
|
379
|
+
error=None,
|
|
380
|
+
findings=tuple(findings),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def cross_file(pendings: Sequence[PendingFile], config: EffectiveConfig) -> ComplexityOutcome:
|
|
385
|
+
"""Phase two: the call graph over every parsed file, and the CPLX005 findings."""
|
|
386
|
+
modules: list[ModuleInput] = []
|
|
387
|
+
sources: dict[str, SourceText] = {}
|
|
388
|
+
cplx005_paths: set[str] = set()
|
|
389
|
+
for pending in pendings:
|
|
390
|
+
if pending.failed or pending.facts is None or pending.source is None:
|
|
391
|
+
continue
|
|
392
|
+
modules.append(
|
|
393
|
+
ModuleInput(
|
|
394
|
+
path=pending.path,
|
|
395
|
+
language=pending.language,
|
|
396
|
+
units=pending.units,
|
|
397
|
+
imports=pending.facts.imports,
|
|
398
|
+
bindings=pending.facts.bindings,
|
|
399
|
+
)
|
|
400
|
+
)
|
|
401
|
+
sources[pending.path] = pending.source
|
|
402
|
+
if registry.CPLX005.rule_id in pending.rules_applied:
|
|
403
|
+
cplx005_paths.add(pending.path)
|
|
404
|
+
return assemble(modules, sources, config, frozenset(cplx005_paths))
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def complete(
|
|
408
|
+
pendings: Sequence[PendingFile], config: EffectiveConfig, options: AnalysisOptions
|
|
409
|
+
) -> AnalysisOutcome:
|
|
410
|
+
outcome = cross_file(pendings, config)
|
|
411
|
+
results: list[FileResult] = []
|
|
412
|
+
for pending in pendings:
|
|
413
|
+
extra: Sequence[Finding] = ()
|
|
414
|
+
if pending.path in outcome.graph_findings:
|
|
415
|
+
extra = outcome.graph_findings[pending.path]
|
|
416
|
+
results.append(finish(pending, extra, options))
|
|
417
|
+
return AnalysisOutcome(
|
|
418
|
+
files=tuple(results),
|
|
419
|
+
metrics={"complexity": outcome.complexity, "graph": outcome.graph},
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def analyze_files(
|
|
424
|
+
files: Sequence[DiscoveredFile], config: EffectiveConfig, options: AnalysisOptions
|
|
425
|
+
) -> AnalysisOutcome:
|
|
426
|
+
"""The whole pipeline for a set of discovered files."""
|
|
427
|
+
pendings = [prepare_file(file, config) for file in files]
|
|
428
|
+
return complete(pendings, config, options)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def analyze_text(
|
|
432
|
+
relative_path: str,
|
|
433
|
+
language: str,
|
|
434
|
+
text: str,
|
|
435
|
+
config: EffectiveConfig,
|
|
436
|
+
options: AnalysisOptions,
|
|
437
|
+
) -> FileResult:
|
|
438
|
+
"""One in-memory file through the whole pipeline (its graph is the file alone)."""
|
|
439
|
+
outcome = complete([prepare_text(relative_path, language, text, config)], config, options)
|
|
440
|
+
return outcome.files[0]
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def analyze_file(
|
|
444
|
+
file: DiscoveredFile, config: EffectiveConfig, options: AnalysisOptions
|
|
445
|
+
) -> FileResult:
|
|
446
|
+
outcome = complete([prepare_file(file, config)], config, options)
|
|
447
|
+
return outcome.files[0]
|
shockharness/api.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Public library API.
|
|
2
|
+
|
|
3
|
+
Library consumers never need the CLI. All inputs are explicit (no defaults);
|
|
4
|
+
all failures are typed exceptions from :mod:`shockharness.errors` or explicit
|
|
5
|
+
statuses inside the returned structures.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from dataclasses import replace
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from shockharness import gate as gate_module
|
|
13
|
+
from shockharness.analysis import AnalysisOptions, FileResult, analyze_files, analyze_text
|
|
14
|
+
from shockharness.config import EffectiveConfig, load_config
|
|
15
|
+
from shockharness.discovery import discover
|
|
16
|
+
from shockharness.errors import UsageError
|
|
17
|
+
from shockharness.gate import NO_REGRESSIONS, GateResult
|
|
18
|
+
from shockharness.gating.baseline import Baseline
|
|
19
|
+
from shockharness.gating.baseline import apply_baseline as apply_baseline_fingerprints
|
|
20
|
+
from shockharness.history.hotspots import collect_history
|
|
21
|
+
from shockharness.report import Report, build_report, rebuild_with_findings
|
|
22
|
+
from shockharness.rules import registry
|
|
23
|
+
|
|
24
|
+
SOURCE_PSEUDO_PATH = "<source>"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def analyze_source(source: str, language: str, config: EffectiveConfig) -> FileResult:
|
|
28
|
+
"""Analyze one in-memory source text. ``language`` is ``python`` or ``javascript``."""
|
|
29
|
+
if language not in registry.ALL_LANGUAGES:
|
|
30
|
+
raise UsageError(
|
|
31
|
+
f"unsupported language {language!r}; use one of {', '.join(registry.ALL_LANGUAGES)}",
|
|
32
|
+
None,
|
|
33
|
+
)
|
|
34
|
+
return analyze_text(
|
|
35
|
+
SOURCE_PSEUDO_PATH, language, source, config, AnalysisOptions(inline_suppressions=True)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def analyze_paths(
|
|
40
|
+
paths: Sequence[Path],
|
|
41
|
+
root: Path,
|
|
42
|
+
config: EffectiveConfig,
|
|
43
|
+
options: AnalysisOptions,
|
|
44
|
+
) -> Report:
|
|
45
|
+
"""Discover files under ``paths`` (relative to ``root``), analyze them, evaluate the gate.
|
|
46
|
+
|
|
47
|
+
Pure analysis: no git is run. ``metrics.complexity`` and ``metrics.graph`` are
|
|
48
|
+
complete; ``metrics.history`` says ``not_collected`` until :func:`with_history`
|
|
49
|
+
is applied. Complexity never affects the gate here.
|
|
50
|
+
"""
|
|
51
|
+
discovery = discover(root, list(paths), config)
|
|
52
|
+
outcome = analyze_files(discovery.files, config, options)
|
|
53
|
+
findings = [f for result in outcome.files for f in result.findings]
|
|
54
|
+
verdict = gate_module.evaluate(findings, outcome.files, config, None, NO_REGRESSIONS)
|
|
55
|
+
return build_report(outcome.files, discovery, config, verdict, outcome.metrics)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def apply_baseline(report: Report, baseline: Baseline, config: EffectiveConfig) -> Report:
|
|
59
|
+
"""Mark the report's findings against ``baseline`` and re-evaluate the gate.
|
|
60
|
+
|
|
61
|
+
``gates.legacy`` decides what a match means: ``baseline`` (counted, never fails),
|
|
62
|
+
``ignore`` (not counted), ``fail`` (the baseline is recorded but not applied).
|
|
63
|
+
"""
|
|
64
|
+
marked, application = apply_baseline_fingerprints(
|
|
65
|
+
report.findings, baseline, config.values["gates"]["legacy"], config.digest
|
|
66
|
+
)
|
|
67
|
+
verdict = gate_module.evaluate(marked, report.files, config, application, NO_REGRESSIONS)
|
|
68
|
+
return rebuild_with_findings(report, marked, verdict)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def evaluate_gate(report: Report, baseline: Baseline | None, config: EffectiveConfig) -> GateResult:
|
|
72
|
+
"""Re-evaluate a report's gate under ``config``, optionally against ``baseline``."""
|
|
73
|
+
if baseline is None:
|
|
74
|
+
return gate_module.evaluate(
|
|
75
|
+
list(report.findings), list(report.files), config, None, NO_REGRESSIONS
|
|
76
|
+
)
|
|
77
|
+
if not isinstance(baseline, Baseline):
|
|
78
|
+
raise UsageError(
|
|
79
|
+
"baseline must be a shockharness.gating.baseline.Baseline, "
|
|
80
|
+
f"got {type(baseline).__name__}",
|
|
81
|
+
None,
|
|
82
|
+
)
|
|
83
|
+
return apply_baseline(report, baseline, config).gate
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def with_history(report: Report, root: Path, revision: str, config: EffectiveConfig) -> Report:
|
|
87
|
+
"""Attach ``metrics.history`` read from local git for the report's files at ``revision``.
|
|
88
|
+
|
|
89
|
+
Degrades explicitly: without git, outside a repository, or with
|
|
90
|
+
``history.enabled = false`` the metrics say so and why. Hotspots are computed from
|
|
91
|
+
the churn and ``metrics.complexity``. Never affects the gate.
|
|
92
|
+
"""
|
|
93
|
+
history = collect_history(
|
|
94
|
+
root, revision, [f.path for f in report.files], config, report.metrics["complexity"]
|
|
95
|
+
)
|
|
96
|
+
metrics = dict(report.metrics)
|
|
97
|
+
metrics["history"] = history
|
|
98
|
+
return replace(report, metrics=metrics)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
__all__ = [
|
|
102
|
+
"AnalysisOptions",
|
|
103
|
+
"Baseline",
|
|
104
|
+
"EffectiveConfig",
|
|
105
|
+
"FileResult",
|
|
106
|
+
"GateResult",
|
|
107
|
+
"Report",
|
|
108
|
+
"analyze_paths",
|
|
109
|
+
"analyze_source",
|
|
110
|
+
"apply_baseline",
|
|
111
|
+
"evaluate_gate",
|
|
112
|
+
"load_config",
|
|
113
|
+
"with_history",
|
|
114
|
+
]
|