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.
Files changed (74) hide show
  1. shockharness/__init__.py +13 -0
  2. shockharness/__main__.py +5 -0
  3. shockharness/analysis.py +447 -0
  4. shockharness/api.py +114 -0
  5. shockharness/cli.py +596 -0
  6. shockharness/complexity/__init__.py +8 -0
  7. shockharness/complexity/assemble.py +119 -0
  8. shockharness/complexity/callgraph.py +754 -0
  9. shockharness/complexity/cognitive.py +96 -0
  10. shockharness/complexity/coupling.py +350 -0
  11. shockharness/complexity/cyclomatic.py +45 -0
  12. shockharness/complexity/metrics.py +148 -0
  13. shockharness/complexity/nesting.py +50 -0
  14. shockharness/complexity/npath.py +110 -0
  15. shockharness/complexity/regressions.py +176 -0
  16. shockharness/complexity/size.py +28 -0
  17. shockharness/config.py +502 -0
  18. shockharness/constants.py +77 -0
  19. shockharness/defaults.toml +102 -0
  20. shockharness/discovery.py +143 -0
  21. shockharness/errors.py +42 -0
  22. shockharness/findings.py +87 -0
  23. shockharness/gate.py +232 -0
  24. shockharness/gating/__init__.py +1 -0
  25. shockharness/gating/baseline.py +349 -0
  26. shockharness/gating/diff.py +260 -0
  27. shockharness/globs.py +142 -0
  28. shockharness/history/__init__.py +1 -0
  29. shockharness/history/cochange.py +73 -0
  30. shockharness/history/git.py +182 -0
  31. shockharness/history/hotspots.py +163 -0
  32. shockharness/ir/__init__.py +1 -0
  33. shockharness/ir/javascript.py +181 -0
  34. shockharness/ir/javascript_catches.py +570 -0
  35. shockharness/ir/javascript_defaults.py +874 -0
  36. shockharness/ir/javascript_functions.py +1135 -0
  37. shockharness/ir/lines.py +54 -0
  38. shockharness/ir/model.py +411 -0
  39. shockharness/ir/python.py +185 -0
  40. shockharness/ir/python_catches.py +441 -0
  41. shockharness/ir/python_defaults.py +917 -0
  42. shockharness/ir/python_functions.py +865 -0
  43. shockharness/output/__init__.py +1 -0
  44. shockharness/output/json.py +15 -0
  45. shockharness/output/sarif.py +203 -0
  46. shockharness/output/text.py +248 -0
  47. shockharness/profiles/__init__.py +1 -0
  48. shockharness/profiles/minimal.toml +20 -0
  49. shockharness/profiles/recommended.toml +7 -0
  50. shockharness/profiles/strict.toml +5 -0
  51. shockharness/report.py +188 -0
  52. shockharness/rules/__init__.py +1 -0
  53. shockharness/rules/complexity.py +207 -0
  54. shockharness/rules/defaults.py +285 -0
  55. shockharness/rules/exceptions.py +318 -0
  56. shockharness/rules/magic_numbers.py +59 -0
  57. shockharness/rules/registry.py +917 -0
  58. shockharness/rules/severity.py +23 -0
  59. shockharness/schema.py +271 -0
  60. shockharness/skills/__init__.py +91 -0
  61. shockharness/skills/adapters.py +106 -0
  62. shockharness/skills/fix-violations/SKILL.md +393 -0
  63. shockharness/skills/install.py +336 -0
  64. shockharness/skills/pre-commit-review/SKILL.md +304 -0
  65. shockharness/skills/refactor-complexity/SKILL.md +413 -0
  66. shockharness/skills/write-compliant-code/SKILL.md +400 -0
  67. shockharness/source.py +52 -0
  68. shockharness/suppressions.py +195 -0
  69. shockharness/version.py +3 -0
  70. shockharness-0.1.0.dist-info/METADATA +447 -0
  71. shockharness-0.1.0.dist-info/RECORD +74 -0
  72. shockharness-0.1.0.dist-info/WHEEL +4 -0
  73. shockharness-0.1.0.dist-info/entry_points.txt +2 -0
  74. shockharness-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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"]
@@ -0,0 +1,5 @@
1
+ """Allow ``python -m shockharness``."""
2
+
3
+ from shockharness.cli import entry
4
+
5
+ entry()
@@ -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
+ ]