code-review-forge 2.0.0a1__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.
- code_forge/__init__.py +14 -0
- code_forge/__main__.py +8 -0
- code_forge/autofix.py +78 -0
- code_forge/baseline.py +216 -0
- code_forge/cli.py +983 -0
- code_forge/delta.py +65 -0
- code_forge/diagnose.py +109 -0
- code_forge/diff.py +82 -0
- code_forge/disposition.py +32 -0
- code_forge/e2e_check.py +641 -0
- code_forge/env_resolver.py +91 -0
- code_forge/errors.py +34 -0
- code_forge/exit_codes.py +37 -0
- code_forge/factories.py +191 -0
- code_forge/falsify.py +85 -0
- code_forge/gate_check.py +466 -0
- code_forge/git.py +351 -0
- code_forge/hold.py +126 -0
- code_forge/install_hooks.py +331 -0
- code_forge/lock.py +162 -0
- code_forge/machine.py +792 -0
- code_forge/mode_resolver.py +60 -0
- code_forge/mutation.py +380 -0
- code_forge/parsers/__init__.py +56 -0
- code_forge/parsers/_sarif.py +77 -0
- code_forge/parsers/base.py +65 -0
- code_forge/parsers/checkpatch.py +66 -0
- code_forge/parsers/clippy.py +85 -0
- code_forge/parsers/non_ascii.py +47 -0
- code_forge/parsers/ruff.py +18 -0
- code_forge/parsers/semgrep.py +18 -0
- code_forge/parsers/shellcheck.py +56 -0
- code_forge/registry.py +153 -0
- code_forge/reporter.py +133 -0
- code_forge/runner.py +205 -0
- code_forge/sarif.py +226 -0
- code_forge/skills/adversarial-qe/SKILL.md +272 -0
- code_forge/skills/code-forge/SKILL.md +1193 -0
- code_forge/skills/code-review-expert/SKILL.md +162 -0
- code_forge/skills/code-review-expert/references/code-quality-checklist.md +130 -0
- code_forge/skills/code-review-expert/references/removal-plan.md +52 -0
- code_forge/skills/code-review-expert/references/security-checklist.md +118 -0
- code_forge/skills/code-review-expert/references/solid-checklist.md +65 -0
- code_forge/skills/kernel-fp-verify/SKILL.md +101 -0
- code_forge/skills/qodo-review/SKILL.md +135 -0
- code_forge/skills/smoke-test/SKILL.md +253 -0
- code_forge/skills/smoke-test/references/boundary-cases.md +114 -0
- code_forge/skills/smoke-test/references/concurrency-patterns.md +306 -0
- code_forge/skills/smoke-test/references/injection-payloads.md +124 -0
- code_forge/skills/smoke-test/test-library/shell/README.md +271 -0
- code_forge/skills/smoke-test/test-library/shell/primitives.sh +352 -0
- code_forge/skills/smoke-test/test-library/shell/primitives_test.sh +324 -0
- code_forge/snapshot.py +196 -0
- code_forge/source.py +64 -0
- code_forge/state.py +246 -0
- code_forge/verdict.py +43 -0
- code_review_forge-2.0.0a1.dist-info/METADATA +237 -0
- code_review_forge-2.0.0a1.dist-info/RECORD +62 -0
- code_review_forge-2.0.0a1.dist-info/WHEEL +5 -0
- code_review_forge-2.0.0a1.dist-info/entry_points.txt +2 -0
- code_review_forge-2.0.0a1.dist-info/licenses/LICENSE +179 -0
- code_review_forge-2.0.0a1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
|
|
3
|
+
"""CLI-03 env override resolution (cli_value > env > default).
|
|
4
|
+
|
|
5
|
+
FORGE_MODE is intentionally NOT here -- 02-04 resolve_mode owns it.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Mapping, Optional
|
|
10
|
+
|
|
11
|
+
from .disposition import MAX_FIX_ATTEMPTS_PER_FINGERPRINT
|
|
12
|
+
from .errors import CliError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
DEFAULT_MAX_TOTAL_ROUNDS = 20
|
|
16
|
+
MAX_REASONABLE_FIX_ATTEMPTS = 100 # sanity bound
|
|
17
|
+
MAX_REASONABLE_TOTAL_ROUNDS = 1000 # sanity bound
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve_max_total_rounds(
|
|
21
|
+
cli_value: Optional[int], env: Mapping[str, str]
|
|
22
|
+
) -> int:
|
|
23
|
+
"""Resolve max total rounds: cli > env > default (20)."""
|
|
24
|
+
if cli_value is not None:
|
|
25
|
+
return _validate_int(
|
|
26
|
+
cli_value, "--max-total-rounds", MAX_REASONABLE_TOTAL_ROUNDS
|
|
27
|
+
)
|
|
28
|
+
raw = env.get("FORGE_MAX_TOTAL_ROUNDS")
|
|
29
|
+
if raw is None or raw == "":
|
|
30
|
+
return DEFAULT_MAX_TOTAL_ROUNDS
|
|
31
|
+
return _parse_env_int(
|
|
32
|
+
raw, "FORGE_MAX_TOTAL_ROUNDS", MAX_REASONABLE_TOTAL_ROUNDS
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_max_fix_attempts(
|
|
37
|
+
cli_value: Optional[int], env: Mapping[str, str]
|
|
38
|
+
) -> int:
|
|
39
|
+
"""Resolve max fix attempts: cli > env > default (3)."""
|
|
40
|
+
if cli_value is not None:
|
|
41
|
+
return _validate_int(
|
|
42
|
+
cli_value, "--max-fix-attempts", MAX_REASONABLE_FIX_ATTEMPTS
|
|
43
|
+
)
|
|
44
|
+
raw = env.get("FORGE_MAX_FIX_ATTEMPTS_PER_FINGERPRINT")
|
|
45
|
+
if raw is None or raw == "":
|
|
46
|
+
return MAX_FIX_ATTEMPTS_PER_FINGERPRINT
|
|
47
|
+
return _parse_env_int(
|
|
48
|
+
raw, "FORGE_MAX_FIX_ATTEMPTS_PER_FINGERPRINT",
|
|
49
|
+
MAX_REASONABLE_FIX_ATTEMPTS,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def resolve_falsification_engine(
|
|
54
|
+
cli_value: Optional[str], env: Mapping[str, str]
|
|
55
|
+
) -> str:
|
|
56
|
+
"""Resolve falsification engine: cli > env > default (auto)."""
|
|
57
|
+
if cli_value is not None:
|
|
58
|
+
return cli_value
|
|
59
|
+
raw = env.get("FORGE_FALSIFICATION_ENGINE")
|
|
60
|
+
if raw is None or raw == "":
|
|
61
|
+
return "auto"
|
|
62
|
+
key = raw.strip().lower()
|
|
63
|
+
if key not in {"auto", "stub", "real"}:
|
|
64
|
+
raise CliError(
|
|
65
|
+
"invalid FORGE_FALSIFICATION_ENGINE: %r "
|
|
66
|
+
"(expected auto|stub|real)" % raw
|
|
67
|
+
)
|
|
68
|
+
return key
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _parse_env_int(raw: str, name: str, sanity_cap: int) -> int:
|
|
72
|
+
"""Parse string env value to int with validation."""
|
|
73
|
+
try:
|
|
74
|
+
value = int(raw.strip())
|
|
75
|
+
except ValueError:
|
|
76
|
+
raise CliError("invalid %s: %r (expected int)" % (name, raw))
|
|
77
|
+
return _validate_int(value, name, sanity_cap)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _validate_int(value: int, name: str, sanity_cap: int) -> int:
|
|
81
|
+
"""Validate int >= 1 and <= sanity_cap."""
|
|
82
|
+
if value < 1:
|
|
83
|
+
raise CliError(
|
|
84
|
+
"invalid %s: %d (must be >= 1)" % (name, value)
|
|
85
|
+
)
|
|
86
|
+
if value > sanity_cap:
|
|
87
|
+
raise CliError(
|
|
88
|
+
"invalid %s: %d (exceeds sanity cap %d)"
|
|
89
|
+
% (name, value, sanity_cap)
|
|
90
|
+
)
|
|
91
|
+
return value
|
code_forge/errors.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
|
|
3
|
+
"""Forge-specific error types for state management."""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SchemaVersionMismatchError(Exception):
|
|
7
|
+
"""Raised when state.json schema_version does not match expected."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CorruptedStateError(Exception):
|
|
11
|
+
"""Raised when state.json is corrupt or internally inconsistent."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BaselineResolutionError(Exception):
|
|
15
|
+
"""Raised when baseline resolution fails (invalid ref, bad combination)."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SnapshotSchemaMismatchError(Exception):
|
|
19
|
+
"""Raised when snapshot schema_version does not match expected."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CorruptedSnapshotError(Exception):
|
|
23
|
+
"""Raised when snapshot file is corrupt or unparseable."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CliError(Exception):
|
|
27
|
+
"""Raised on invalid CLI args or env values.
|
|
28
|
+
|
|
29
|
+
main() catches and maps to EXIT_CLI_ERROR (exit 2).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ComponentsConfigError(Exception):
|
|
34
|
+
"""Raised when .code-forge/components.yaml fails schema validation."""
|
code_forge/exit_codes.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
|
|
3
|
+
"""CLI-02 exit code constants + Verdict -> exit mapping.
|
|
4
|
+
|
|
5
|
+
Phase 1 cli.py had EXIT_PASS / EXIT_FAIL inline. 02-05 promotes them
|
|
6
|
+
to a dedicated module and adds CLI_ERROR / BUSY / ESCALATED.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .state import Verdict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
EXIT_PASS = 0
|
|
14
|
+
EXIT_FAIL = 1
|
|
15
|
+
EXIT_CLI_ERROR = 2
|
|
16
|
+
EXIT_BUSY = 3
|
|
17
|
+
EXIT_ESCALATED = 4
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def verdict_to_exit(verdict: Verdict) -> int:
|
|
21
|
+
"""Map terminal Verdict to CLI-02 exit code.
|
|
22
|
+
|
|
23
|
+
Raises ValueError on Verdict.PENDING (caller bug: HOLD should have
|
|
24
|
+
been consumed by HOLD-resume loop before reaching this mapping).
|
|
25
|
+
"""
|
|
26
|
+
if verdict == Verdict.PASS:
|
|
27
|
+
return EXIT_PASS
|
|
28
|
+
if verdict == Verdict.FAIL:
|
|
29
|
+
return EXIT_FAIL
|
|
30
|
+
if verdict == Verdict.ESCALATED:
|
|
31
|
+
return EXIT_ESCALATED
|
|
32
|
+
if verdict == Verdict.PENDING:
|
|
33
|
+
raise ValueError(
|
|
34
|
+
"verdict_to_exit called with PENDING; HOLD-resume loop "
|
|
35
|
+
"must consume PENDING before terminal mapping"
|
|
36
|
+
)
|
|
37
|
+
raise ValueError("unknown verdict: %r" % verdict)
|
code_forge/factories.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
|
|
3
|
+
"""STATE-10 falsifier + autofixer + revert_fn factories.
|
|
4
|
+
|
|
5
|
+
Centralizes "which impl do we instantiate" decisions so cli.py stays
|
|
6
|
+
declarative and Phase 4 can swap impls without touching the CLI.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Callable
|
|
14
|
+
|
|
15
|
+
from .autofix import AutoFixer, FixOutcome, StubAutoFixer
|
|
16
|
+
from .baseline import ResolvedReview
|
|
17
|
+
from .disposition import Disposition
|
|
18
|
+
from .falsify import Falsifier, StubFalsifier
|
|
19
|
+
from .e2e_check import run_e2e_check
|
|
20
|
+
from .mutation import run_mutation
|
|
21
|
+
from .state import StateFinding
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_falsifier(engine: str) -> Falsifier:
|
|
25
|
+
"""STATE-10 engine factory.
|
|
26
|
+
|
|
27
|
+
engine = "auto": try Phase 4 import; fall back to stub if absent.
|
|
28
|
+
engine = "stub": always StubFalsifier.
|
|
29
|
+
engine = "real": Phase 4 falsifier (NOT shipped v2.0).
|
|
30
|
+
"""
|
|
31
|
+
if engine == "stub":
|
|
32
|
+
return StubFalsifier()
|
|
33
|
+
if engine == "auto":
|
|
34
|
+
try:
|
|
35
|
+
from .falsify_real import RealFalsifier # noqa: F401
|
|
36
|
+
return RealFalsifier()
|
|
37
|
+
except ImportError:
|
|
38
|
+
return StubFalsifier()
|
|
39
|
+
if engine == "real":
|
|
40
|
+
try:
|
|
41
|
+
from .falsify_real import RealFalsifier
|
|
42
|
+
return RealFalsifier()
|
|
43
|
+
except ImportError:
|
|
44
|
+
raise NotImplementedError(
|
|
45
|
+
"--falsification-engine=real requires Phase 4 "
|
|
46
|
+
"(not shipped in v2.0). Use "
|
|
47
|
+
"--falsification-engine=auto or =stub."
|
|
48
|
+
)
|
|
49
|
+
raise ValueError(
|
|
50
|
+
"unknown engine: %r (expected auto|stub|real)" % engine
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_autofixer(resolved: ResolvedReview) -> AutoFixer:
|
|
55
|
+
"""v2.0 returns StubAutoFixer; non-git wraps to prevent PARSE_FAIL.
|
|
56
|
+
|
|
57
|
+
R2-M1: non-git mode wraps StubAutoFixer in _NonGitSafeAutoFixer
|
|
58
|
+
to convert PARSE_FAIL -> NO_CHANGE, preventing revert_fn from
|
|
59
|
+
being invoked (which would raise NotImplementedError per B1).
|
|
60
|
+
"""
|
|
61
|
+
base = StubAutoFixer()
|
|
62
|
+
if resolved.mode_hint == "non-git":
|
|
63
|
+
return _NonGitSafeAutoFixer(base)
|
|
64
|
+
return base
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _NonGitSafeAutoFixer(AutoFixer):
|
|
68
|
+
"""R2-M1 wrapper: convert PARSE_FAIL -> NO_CHANGE in non-git mode.
|
|
69
|
+
|
|
70
|
+
Prevents StateMachine from calling revert_fn (which raises
|
|
71
|
+
NotImplementedError per B1 in non-git mode). Behavior:
|
|
72
|
+
- PARSE_FAIL -> NO_CHANGE (consumes fix budget; no revert call)
|
|
73
|
+
- SUCCESS / NO_CHANGE / EXCEPTION pass through unchanged
|
|
74
|
+
|
|
75
|
+
R3-7: fix signature byte-equal to AutoFixer ABC.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, inner: AutoFixer):
|
|
79
|
+
super().__init__()
|
|
80
|
+
self._inner = inner
|
|
81
|
+
|
|
82
|
+
def fix(self, finding: StateFinding, mode_hint: str) -> FixOutcome:
|
|
83
|
+
"""Wrap inner fix; convert PARSE_FAIL to NO_CHANGE."""
|
|
84
|
+
outcome = self._inner.fix(finding, mode_hint)
|
|
85
|
+
if outcome == FixOutcome.PARSE_FAIL:
|
|
86
|
+
return FixOutcome.NO_CHANGE
|
|
87
|
+
return outcome
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def build_revert_fn(
|
|
91
|
+
resolved: ResolvedReview, cwd: Path
|
|
92
|
+
) -> Callable[[StateFinding], None]:
|
|
93
|
+
"""Build revert_fn for StateMachine constructor.
|
|
94
|
+
|
|
95
|
+
Dispatches on resolved.mode_hint:
|
|
96
|
+
"git" -> git restore <file>
|
|
97
|
+
"non-git" -> NotImplementedError (B1: v2.0 limitation)
|
|
98
|
+
"""
|
|
99
|
+
if resolved.mode_hint == "git":
|
|
100
|
+
return _make_git_restore(cwd)
|
|
101
|
+
if resolved.mode_hint == "non-git":
|
|
102
|
+
return _make_snapshot_restore(cwd, resolved)
|
|
103
|
+
raise ValueError("unknown mode_hint: %r" % resolved.mode_hint)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _make_git_restore(
|
|
107
|
+
cwd: Path,
|
|
108
|
+
) -> Callable[[StateFinding], None]:
|
|
109
|
+
"""Git mode revert: restore file to index version."""
|
|
110
|
+
def _revert(finding: StateFinding) -> None:
|
|
111
|
+
subprocess.run(
|
|
112
|
+
["git", "restore", "--", finding.file],
|
|
113
|
+
cwd=str(cwd), check=True,
|
|
114
|
+
)
|
|
115
|
+
return _revert
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _make_snapshot_restore(
|
|
119
|
+
cwd: Path, resolved: ResolvedReview
|
|
120
|
+
) -> Callable[[StateFinding], None]:
|
|
121
|
+
"""B1: non-git revert NOT supported in v2.0.
|
|
122
|
+
|
|
123
|
+
02-03 Snapshot stores content_hash only, not raw content.
|
|
124
|
+
revert_fn raises NotImplementedError unconditionally.
|
|
125
|
+
"""
|
|
126
|
+
def _revert(finding: StateFinding) -> None:
|
|
127
|
+
raise NotImplementedError(
|
|
128
|
+
"non-git autofix revert is not supported in v2.0 "
|
|
129
|
+
"(02-03 Snapshot stores content_hash, not raw content). "
|
|
130
|
+
"Use git mode for autofix, or configure autofixer to "
|
|
131
|
+
"avoid PARSE_FAIL outcomes in non-git mode. "
|
|
132
|
+
"Tracked as v2.x candidate."
|
|
133
|
+
)
|
|
134
|
+
return _revert
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def build_l2_runner() -> Callable:
|
|
138
|
+
"""Build l2_runner (mutation testing) callable.
|
|
139
|
+
|
|
140
|
+
Returns a callable with signature:
|
|
141
|
+
(diff_files: list[str], baseline_cmd: list[str])
|
|
142
|
+
-> tuple[list[StateFinding], list[str]]
|
|
143
|
+
|
|
144
|
+
If mutmut is not on PATH, returns a no-op callable that produces
|
|
145
|
+
a single MUTATION_SKIPPED finding (soft dependency).
|
|
146
|
+
|
|
147
|
+
The returned callable delegates to run_mutation from the mutation
|
|
148
|
+
module when mutmut is available.
|
|
149
|
+
"""
|
|
150
|
+
if shutil.which("mutmut") is None:
|
|
151
|
+
# mutmut not available, return no-op with MUTATION_SKIPPED
|
|
152
|
+
def _no_mutation(
|
|
153
|
+
diff_files: list[str],
|
|
154
|
+
baseline_cmd: list[str],
|
|
155
|
+
) -> tuple[list[StateFinding], list[str]]:
|
|
156
|
+
findings = [
|
|
157
|
+
StateFinding(
|
|
158
|
+
id="MUTATION_SKIPPED",
|
|
159
|
+
fingerprint="mutation-unavailable",
|
|
160
|
+
source="MUTANT",
|
|
161
|
+
disposition=Disposition.DISMISSED,
|
|
162
|
+
file="",
|
|
163
|
+
line_range=[],
|
|
164
|
+
description="mutmut not installed (soft dependency)",
|
|
165
|
+
)
|
|
166
|
+
]
|
|
167
|
+
infra_errors = ["mutmut not found on PATH"]
|
|
168
|
+
return (findings, infra_errors)
|
|
169
|
+
return _no_mutation
|
|
170
|
+
|
|
171
|
+
# mutmut is available, delegate to run_mutation
|
|
172
|
+
return run_mutation
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def build_e2e_checker() -> Callable:
|
|
176
|
+
"""Build e2e_checker callable for R3 coverage heuristic.
|
|
177
|
+
|
|
178
|
+
Returns a callable with signature:
|
|
179
|
+
(diff_text: str, repo_root: Path)
|
|
180
|
+
-> tuple[list[StateFinding], list[str]]
|
|
181
|
+
|
|
182
|
+
Unlike build_l2_runner, there is no external-binary availability check:
|
|
183
|
+
e2e_check has no soft dependency (unidiff is a hard dep already used by
|
|
184
|
+
diff.py). The factory returns run_e2e_check directly.
|
|
185
|
+
|
|
186
|
+
The factory exists for symmetry with build_l2_runner so plan 03-03 can
|
|
187
|
+
inject it into the state machine the same way as l2_runner, and a future
|
|
188
|
+
variant (e.g. with config loading) can be swapped without touching
|
|
189
|
+
machine.py.
|
|
190
|
+
"""
|
|
191
|
+
return run_e2e_check
|
code_forge/falsify.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026, Minxi Hou <houminxi@gmail.com>
|
|
3
|
+
"""Abstract Falsifier interface + Stub implementation.
|
|
4
|
+
|
|
5
|
+
Phase 4 will provide the real Falsifier. 02-01 stub allows state machine
|
|
6
|
+
to be built and tested without Phase 4.
|
|
7
|
+
|
|
8
|
+
This module imports StateFinding from code_forge.state (NOT the Phase 1
|
|
9
|
+
forge.parsers.base.Finding). StateFinding is the persisted-in-state.json
|
|
10
|
+
representation; parsers.base.Finding is the parser-emitted record.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from .disposition import Disposition
|
|
19
|
+
from .state import StateFinding
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Falsifier(ABC):
|
|
23
|
+
"""Abstract base for falsification engines.
|
|
24
|
+
|
|
25
|
+
falsify() must NOT return Disposition.FIXED (FIXED is a state machine
|
|
26
|
+
transition after auto-fix, not a falsifier output).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
@abstractmethod
|
|
30
|
+
def falsify(self, finding: StateFinding) -> Disposition:
|
|
31
|
+
"""Classify a finding as CONFIRMED, DISMISSED, or UNCERTAIN.
|
|
32
|
+
|
|
33
|
+
Returning FIXED raises ValueError (state machine concern).
|
|
34
|
+
"""
|
|
35
|
+
...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class StubFalsifier(Falsifier):
|
|
39
|
+
"""Returns configurable Dispositions for tests.
|
|
40
|
+
|
|
41
|
+
Config format (JSON):
|
|
42
|
+
{
|
|
43
|
+
"default": "CONFIRMED",
|
|
44
|
+
"dispositions": {"fp-xxx": "DISMISSED", ...},
|
|
45
|
+
"errors": {"fp-yyy": "timeout", ...}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
Precedence: errors checked before dispositions/default.
|
|
49
|
+
FIXED in any disposition position is rejected at constructor AND
|
|
50
|
+
at falsify() time (defense in depth).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, fixture_path: Optional[Path] = None):
|
|
54
|
+
self._dispositions: dict[str, Disposition] = {}
|
|
55
|
+
self._errors: dict[str, str] = {}
|
|
56
|
+
self._default = Disposition.CONFIRMED
|
|
57
|
+
if fixture_path:
|
|
58
|
+
data = json.loads(fixture_path.read_text())
|
|
59
|
+
self._default = Disposition(data.get("default", "CONFIRMED"))
|
|
60
|
+
if self._default == Disposition.FIXED:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
"FIXED is not a valid falsifier output (default)"
|
|
63
|
+
)
|
|
64
|
+
self._dispositions = {}
|
|
65
|
+
for fp, d in data.get("dispositions", {}).items():
|
|
66
|
+
disp = Disposition(d)
|
|
67
|
+
if disp == Disposition.FIXED:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
"FIXED is not a valid falsifier output "
|
|
70
|
+
"(fingerprint %s)" % fp
|
|
71
|
+
)
|
|
72
|
+
self._dispositions[fp] = disp
|
|
73
|
+
self._errors = dict(data.get("errors", {}))
|
|
74
|
+
|
|
75
|
+
def falsify(self, finding: StateFinding) -> Disposition:
|
|
76
|
+
"""Return configured disposition or raise on error key."""
|
|
77
|
+
if finding.fingerprint in self._errors:
|
|
78
|
+
raise RuntimeError(
|
|
79
|
+
"stub-simulated falsification error: %s"
|
|
80
|
+
% self._errors[finding.fingerprint]
|
|
81
|
+
)
|
|
82
|
+
disp = self._dispositions.get(finding.fingerprint, self._default)
|
|
83
|
+
if disp == Disposition.FIXED:
|
|
84
|
+
raise ValueError("FIXED is not a valid falsifier output")
|
|
85
|
+
return disp
|