detective-spec 0.1.0__tar.gz

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 (71) hide show
  1. detective_spec-0.1.0/.github/workflows/ci.yml +39 -0
  2. detective_spec-0.1.0/.gitignore +20 -0
  3. detective_spec-0.1.0/.python-version +1 -0
  4. detective_spec-0.1.0/ARCHITECTURE.md +481 -0
  5. detective_spec-0.1.0/Detective/__init__.py +29 -0
  6. detective_spec-0.1.0/Detective/__main__.py +10 -0
  7. detective_spec-0.1.0/Detective/audit.py +136 -0
  8. detective_spec-0.1.0/Detective/call_sites.py +192 -0
  9. detective_spec-0.1.0/Detective/capture.py +82 -0
  10. detective_spec-0.1.0/Detective/certify.py +230 -0
  11. detective_spec-0.1.0/Detective/cli.py +1116 -0
  12. detective_spec-0.1.0/Detective/cognitive_complexity.py +97 -0
  13. detective_spec-0.1.0/Detective/converge.py +663 -0
  14. detective_spec-0.1.0/Detective/decompose.py +312 -0
  15. detective_spec-0.1.0/Detective/decompose_apply.py +490 -0
  16. detective_spec-0.1.0/Detective/engine.py +885 -0
  17. detective_spec-0.1.0/Detective/equivalence.py +312 -0
  18. detective_spec-0.1.0/Detective/equivalents.py +87 -0
  19. detective_spec-0.1.0/Detective/mcp_server.py +45 -0
  20. detective_spec-0.1.0/Detective/minimize.py +107 -0
  21. detective_spec-0.1.0/Detective/parallel.py +192 -0
  22. detective_spec-0.1.0/Detective/purity.py +177 -0
  23. detective_spec-0.1.0/Detective/py.typed +0 -0
  24. detective_spec-0.1.0/Detective/samples.py +100 -0
  25. detective_spec-0.1.0/Detective/scope.py +172 -0
  26. detective_spec-0.1.0/Detective/suite_edit.py +102 -0
  27. detective_spec-0.1.0/Detective/synthesis/__init__.py +12 -0
  28. detective_spec-0.1.0/Detective/synthesis/characterization.py +275 -0
  29. detective_spec-0.1.0/Detective/synthesis/oracle_light.py +652 -0
  30. detective_spec-0.1.0/Detective/synthesis/typed_synthesis.py +197 -0
  31. detective_spec-0.1.0/Detective/synthesis/writer.py +187 -0
  32. detective_spec-0.1.0/Detective/verdict_cache.py +138 -0
  33. detective_spec-0.1.0/LICENSE +21 -0
  34. detective_spec-0.1.0/PKG-INFO +307 -0
  35. detective_spec-0.1.0/README.md +282 -0
  36. detective_spec-0.1.0/conftest.py +12 -0
  37. detective_spec-0.1.0/dev/BUILD_PLAN.md +94 -0
  38. detective_spec-0.1.0/dev/generators/README.md +12 -0
  39. detective_spec-0.1.0/dev/generators/gen_scope_oracle_tests.py +281 -0
  40. detective_spec-0.1.0/pyproject.toml +104 -0
  41. detective_spec-0.1.0/tests/_support.py +56 -0
  42. detective_spec-0.1.0/tests/conftest.py +9 -0
  43. detective_spec-0.1.0/tests/fixtures/.gitkeep +0 -0
  44. detective_spec-0.1.0/tests/test__collect_reads_synth.py +11 -0
  45. detective_spec-0.1.0/tests/test__collect_writes_synth.py +11 -0
  46. detective_spec-0.1.0/tests/test__compared_literals_synth.py +14 -0
  47. detective_spec-0.1.0/tests/test__covering_test_files_synth.py +12 -0
  48. detective_spec-0.1.0/tests/test__has_exit_statement_synth.py +14 -0
  49. detective_spec-0.1.0/tests/test__literal_values_synth.py +14 -0
  50. detective_spec-0.1.0/tests/test__test_names_in_source_synth.py +19 -0
  51. detective_spec-0.1.0/tests/test__wanted_test_names_synth.py +18 -0
  52. detective_spec-0.1.0/tests/test_capture_call_inputs_synth.py +12 -0
  53. detective_spec-0.1.0/tests/test_capture_native.py +113 -0
  54. detective_spec-0.1.0/tests/test_certify_native.py +108 -0
  55. detective_spec-0.1.0/tests/test_characterization_native.py +196 -0
  56. detective_spec-0.1.0/tests/test_cli_converge_output_native.py +142 -0
  57. detective_spec-0.1.0/tests/test_cli_native.py +137 -0
  58. detective_spec-0.1.0/tests/test_converge_native.py +135 -0
  59. detective_spec-0.1.0/tests/test_decompose_native.py +64 -0
  60. detective_spec-0.1.0/tests/test_equivalence_native.py +180 -0
  61. detective_spec-0.1.0/tests/test_find_extraction_candidates_synth.py +17 -0
  62. detective_spec-0.1.0/tests/test_minimize_native.py +49 -0
  63. detective_spec-0.1.0/tests/test_oracle_light_native.py +381 -0
  64. detective_spec-0.1.0/tests/test_oracle_light_roundtrip_native.py +274 -0
  65. detective_spec-0.1.0/tests/test_passes_to_complete_synth.py +25 -0
  66. detective_spec-0.1.0/tests/test_purity_native.py +211 -0
  67. detective_spec-0.1.0/tests/test_scope_native.py +53 -0
  68. detective_spec-0.1.0/tests/test_scope_oracle.py +802 -0
  69. detective_spec-0.1.0/tests/test_typed_synthesis_native.py +159 -0
  70. detective_spec-0.1.0/tests/test_writer_native.py +225 -0
  71. detective_spec-0.1.0/uv.lock +1002 -0
@@ -0,0 +1,39 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ types: [opened, synchronize, reopened]
8
+
9
+ # Wesker resolves from PyPI (`Wesker>=0.4.0`), so CI no longer checks it out beside
10
+ # Detective — the sibling checkout existed only for the editable `../Wesker` path source,
11
+ # which the PyPI publish removed. CI now installs exactly what a user installs.
12
+ jobs:
13
+ test:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ with:
18
+ path: Detective
19
+ fetch-depth: 0
20
+
21
+ - uses: astral-sh/setup-uv@v4
22
+ with:
23
+ version: "latest"
24
+
25
+ - name: Install dependencies
26
+ working-directory: Detective
27
+ run: uv sync
28
+
29
+ - name: Lint
30
+ working-directory: Detective
31
+ run: uv run ruff check Detective/ tests/
32
+
33
+ - name: Format check
34
+ working-directory: Detective
35
+ run: uv run ruff format --check Detective/ tests/
36
+
37
+ - name: Test with coverage
38
+ working-directory: Detective
39
+ run: uv run pytest tests/ --cov=Detective --cov-report=xml:coverage.xml -q
@@ -0,0 +1,20 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .detective/
7
+ .lintgate/
8
+ .prism/
9
+ .wesker/
10
+ .wesker-report.json
11
+ .coverage
12
+ coverage.xml
13
+ .venv/
14
+ .mypy_cache/
15
+ .ruff_cache/
16
+ .pytest_cache/
17
+ .DS_Store
18
+ .serena/
19
+ .env
20
+ .env.*
@@ -0,0 +1 @@
1
+ 3.11
@@ -0,0 +1,481 @@
1
+ # Detective — Architecture & Operational Map
2
+
3
+ The single reference for **what Detective does, how it does it, and where to look
4
+ when something breaks.** Cold start: read §1 (mental model), skim §3 (data structures)
5
+ and §5 (the full CLI), keep §9 (debug map) open — a symptom there points at the exact
6
+ function and why it fails.
7
+
8
+ Detective is a **clean-room** package on **Wesker + stdlib only** (no lintgate in the
9
+ runtime import graph). Runtime dep: `Wesker` (git URL). Console scripts: `detective`
10
+ (CLI) and `detective-mcp` (optional MCP). Everything below is operational.
11
+
12
+ ---
13
+
14
+ ## 0. Thesis (the one idea)
15
+
16
+ A function's **mutation profile is a complete map of the behavioral distinctions it
17
+ makes.** Killed mutant = a distinction the tests pin. Survivor = a degree of freedom
18
+ no test distinguishes. Read backwards, that map is a *specification*: it says exactly
19
+ which behaviors are unpinned, so you can pin them with warranted tests — or recognize
20
+ that nothing *can* pin them (equivalent mutants). Every command consumes that map.
21
+
22
+ **Value-specification vs run-specification (the load-bearing distinction).** A kill is
23
+ only a *value* specification if a test **assertion** distinguishes the mutant — it pins
24
+ *what the function returns*. A kill by **crash** or **timeout** proves only that the code
25
+ *runs*, not what it computes, so it is an **unspecified value-DOF** (a value-survivor).
26
+ Detective's "specified/complete" always means *value*-specified:
27
+
28
+ - `value_killed` = assertion kills only.
29
+ - `value_survived` = true survivors **+** crash/timeout kills.
30
+ - **Mutation-completeness** (`functionally_complete`) = every *killable* mutant is killed
31
+ by an assertion; the only survivors left have no distinguishing input (equivalents).
32
+
33
+ **Two orthogonal axes** — do not conflate them:
34
+ - **Mutation completeness** — the *proof* metric. It is what makes a decomposition
35
+ provably behavior-preserving and a suite a real specification.
36
+ - **Line completeness** — the standard "every executable line is covered." Weaker and
37
+ *orthogonal*: a line whose mutants are all killed is specified whether or not coverage
38
+ counts it, and a covered line whose mutants survive proves nothing. Line-completeness
39
+ is reported, never used as a proof gate.
40
+
41
+ ---
42
+
43
+ ## 1. Mental model (the pipeline in one breath)
44
+
45
+ ```
46
+ your function ──▶ Wesker (mutate + run covering tests) ──▶ ProfilingResult
47
+
48
+ ┌────────────────────────────────────────────────────────┤
49
+ ▼ ▼ ▼ ▼ ▼
50
+ scope classify_survivors minimize line-cov decompose seams
51
+ (diagnose) (killable / equiv / (2-axis (baseline (find_extraction_
52
+ unclassified, by cover) trace) candidates)
53
+ EXECUTION)
54
+ │ │ │ │ │
55
+ └──────────▶ converge · audit · decompose ◀────────────────┘
56
+
57
+
58
+ clean pytest files on disk + a full report on disk + a terse FINAL banner in the CLI
59
+ ```
60
+
61
+ Detective **owns one function at a time.** Every command takes `file.py::function`,
62
+ asks Wesker to profile it, then reshapes/acts on the result. Detective holds no
63
+ cross-run RAM state; persisted state is on disk (§8). Profiling is **content-cached**
64
+ and **adaptively parallelized** — both transparent, both verdict-identical to a plain
65
+ serial run (§7).
66
+
67
+ ---
68
+
69
+ ## 2. The two input surfaces
70
+
71
+ ### 2a. INPUT FROM WESKER (the engine seam) — `Detective/engine.py`, `Detective/scope.py`
72
+
73
+ Detective imports exactly these from Wesker (the *entire* dependency surface):
74
+
75
+ | Import | From | Used for |
76
+ |---|---|---|
77
+ | `run_function_profiling(node, func_key, categories, tests, original, *, budget_ms, mem_budget_mb, max_per_category, pass_index, progress, scope_tests, mutant_slice, precomputed_line_data, pregenerated)` | `Wesker.engine` | THE profile call — mutate + run **covering** tests + baseline line-coverage |
78
+ | `generate_mutants(func_node, categories, *, max_per_category, pass_index)` → `list[Mutant]` | `Wesker.engine` | the deterministic mutant set (reused across probe/shards; witness search) |
79
+ | `estimate_universe_size`, `greedy_coverage_guarantee` | `Wesker.engine` | DOF count + the a-priori greedy coverage floor (the converge "stats flex") |
80
+ | `ProfilingResult`, `CategoryResult`, `MutationCategory` | `Wesker.engine` | the result type (see §3) |
81
+ | `discover_test_callables(root, rel, func_names, extra_dirs)` → `list[Callable]` | `Wesker.ci` | find the real tests exercising a function (pytest-aware, binds parametrize) |
82
+ | `walk_functions(tree)` → `[(qualname, node), …]` | `Wesker.ci` | enumerate functions in a module |
83
+ | `filter_categories(node, pure)` → `set[MutationCategory]` | `Wesker.filter` | which mutation categories apply (drops STATE for pure fns) |
84
+ | `prioritize_categories(cats, state)` | `Wesker.filter` | learned-weak ordering (`diagnose --learn`) |
85
+ | `worker_count`, `apply_address_limit` | `Wesker.memory_guard` | parallel fleet size (portable memory guarantee) + best-effort `RLIMIT_AS` |
86
+ | `telemetry`, `purge_caches` | `Wesker.memory_guard` | CLI footer + `purge` command |
87
+
88
+ **What Detective gets back — `ProfilingResult`** (the single most important object):
89
+
90
+ | Field | Type | Meaning |
91
+ |---|---|---|
92
+ | `function_key` | str | `rel/path.py::qualname` — the identity everywhere |
93
+ | `total_mutants / total_killed / total_survived` | int | raw headline counts |
94
+ | `per_category` | list[`CategoryResult`] | per-cat `total/killed/survived/killed_by_assertion/killed_by_crash/timed_out` |
95
+ | `kill_matrix` | dict[mutant_desc → list[test]] | which tests kill which mutant → minimize |
96
+ | `survivor_records` / `killed_records` | list[dict] | each carries `mutant_id`, `category`, `diff_summary`, `killed_by`, `elapsed_ms` |
97
+ | `line_coverage` | dict[test → list[int]] | which target lines each test covers (baseline pass) |
98
+ | `executable_lines` | list[int] | statement lines of the target (the denominator) |
99
+ | `failing_tests` | list[str] | tests that `assert`-fail on the UNMUTATED function → audit ⚠ |
100
+ | `tests_discovered` | int | how many test callables were found (`0` = "nothing to kill with", `-1` = unknown) |
101
+ | `budget_exhausted` | bool | time/MEMORY budget stopped the run early |
102
+ | **DERIVED properties** (never stored — cannot drift) | | |
103
+ | `value_killed` | int (property) | Σ `killed_by_assertion` — value-specified |
104
+ | `value_survived` | int (property) | true survivors + crash/timeout kills — value-*un*specified |
105
+ | `value_survivor_records` | list[dict] (property) | survivor-shaped record for every value-survivor (incl. crash-kills, with their diff for witness search) |
106
+
107
+ `line_coverage`/`executable_lines`/`failing_tests` come from Wesker's **baseline pass**
108
+ (`Wesker/line_coverage.py`, via `sys.settrace`) run once against the original before the
109
+ untraced mutation loop.
110
+
111
+ ### 2b. INPUT FROM THE USER
112
+
113
+ | Surface | Where | What |
114
+ |---|---|---|
115
+ | CLI target | `file.py::function` positional | the one function to act on |
116
+ | Common flags | `--project-root`, `--json`, `--parallel`/`--serial` (diagnose/converge/audit) | per-command behavior (§5) |
117
+ | `--input "(…)"` | converge, decompose | a **Zone-2 residual** — a Python-literal positional-arg tuple the tool asked for (the semantic prior synthesis couldn't build) |
118
+ | `.detective/equivalents.json` | project root | **manual equivalence flags** (user data — §8); read by `classify_survivors` |
119
+ | `WESKER_MEM_BUDGET_MB` | env var | user-selectable memory ceiling (§7) |
120
+ | existing test files | project `tests/` etc. | the suite `audit` assesses / `converge` augments (Wesker-discovered) |
121
+ | covering tests' runtime inputs | project `tests/` | when synthesis provably can't build a param (a domain object), `capture_call_inputs` reuses the REAL args those tests already pass — never fabricated (the honest alternative to abstaining) |
122
+
123
+ ---
124
+
125
+ ## 3. Core data structures (what flows between stages)
126
+
127
+ All in `Detective/`. Frozen dataclasses unless noted.
128
+
129
+ - **`ScopeMap`** (`scope.py`) — *diagnose output.* `regime` (A tractable / B entangled),
130
+ `specification` (variants/pinned/unspecified/inert — *value*-pinned), `kill_quality`
131
+ (`by_value_assertion` vs `by_crash` + warning), `behavioral_dof`, `tests_discovered`,
132
+ `learned_priors` (opt-in `--learn`), **`decompose_seams`** (structural extraction count —
133
+ the STRUCTURAL half of "is this >1 thing"; regime B is the behavioral half). Produced by
134
+ `scope_from_profiling` + `diagnose`; consumed by `_format_scope`.
135
+ - **`Witness`** (`equivalence.py`) — a concrete input where original and mutant differ by a
136
+ **value**: `args`, `original`, `mutant`. A value-witness is PROOF of value-killability =
137
+ a concrete killing assertion. Produced by `find_witness` — which **skips "mutant newly
138
+ raises"** differences (a crash-kill does not pin value; see §9).
139
+ - **`MutantVerdict` / `SurvivorReport`** (`equivalence.py`) — one survivor classified
140
+ (`killable`, `witness`, `diff_summary`) and the per-function roll-up (`.killable` /
141
+ `.equivalent` / `unclassified` / `manual_equivalent`). Disjoint buckets; completeness
142
+ reads them directly.
143
+ - **`SourceExpr`** (`equivalence.py`) — a synthesized **non-literal** input (AST node /
144
+ built object): runs as its live `value`, renders as its constructor `expr` (via
145
+ `__repr__`), threads its `imports`. How AST/object inputs both *run* and *render*.
146
+ - **`ExecutableProperty`** (`synthesis/oracle_light.py`) — one test-to-be (`setup_code`,
147
+ `assertion_code`, `needs_oracle`, `golden_case`). The unit the writer renders.
148
+ - **`ConvergeResult`** (`converge.py`) — converge output: `functionally_complete`,
149
+ `line_complete`, `at_ceiling`, `survivor_report`, `missing_lines`, `redundant_tests`,
150
+ `minimal_test_count`, `universe_size`, `fast`, **`coverage_guarantee`** (the proven greedy
151
+ floor), `signature`/`param_names` (for the `--input` residual template), `written_path`,
152
+ `wiring`. `.complete` = functionally ∧ line complete.
153
+ - **`SuiteAudit`** (`audit.py`) — audit output: `mutant_complete`, `line_complete`,
154
+ `redundant_tests`, `failing_tests`, `killable_gaps`, `missing_lines`, `minimal_test_count`,
155
+ `candidate_equivalent`/`unclassified`/`manual_equivalent`, `.complete`,
156
+ `.complete_modulo_equivalent`, `.bloat`.
157
+ - **`Extraction` / `Decomposition` / `DecompositionApply`** (`decompose_apply.py`) — a
158
+ generated helper (`helper_name`, `params`, `returns`, `new_source`) and the outcome
159
+ (`applied`, `proposed`, `unsafe_blocks`, **`proof`** = the converge run, so the CLI can
160
+ surface the residual `--input` when it cannot prove).
161
+ - **`EquivalenceFlag`** (`equivalents.py`) — a manual equivalence assertion keyed by
162
+ `func_key` + `sha256(diff)[:16]`; the diff embeds the code, so it is content-validated.
163
+
164
+ ---
165
+
166
+ ## 4. Module map (responsibility · key functions)
167
+
168
+ | Module | Responsibility | Key functions |
169
+ |---|---|---|
170
+ | `engine.py` | **THE Wesker adapter** + caching + adaptive parallelism + witness classification + input synthesis | `profile`, `diagnose`, `classify_survivors`, `representative_site`, `learn_priors`, `_count_decompose_seams`, `_load_original` |
171
+ | `verdict_cache.py` | **content-hashed profile cache** (§7) | `cache_key`, `get`, `put`, `_to_json`/`_from_json`, `tests_fingerprint` |
172
+ | `parallel.py` | **model-A fan-out** + adaptive merge (§7) | `parallel_profile`, `merge_results`, `shard_bounds`, `mean_mutant_ms` |
173
+ | `scope.py` | reshape ProfilingResult → behavioral map | `scope_from_profiling` |
174
+ | `equivalence.py` | classify survivor killable/equivalent BY EXECUTION; typing; SourceExpr | `find_witness`, `classify_survivor`, `_outcome`, `synth_ast_input`, `unwrap` |
175
+ | `purity.py` | is-pure predicate (gates STATE + golden capture) | `is_pure`, `analyze_function` |
176
+ | `call_sites.py` | recover inputs/types from how a fn is CALLED across the repo (static, literal args) | `discover_call_site_inputs`, `infer_param_types` |
177
+ | `capture.py` | **runtime harvest** of REAL args from the covering tests when synthesis can't build a domain-object input (`sys.setprofile` on the target's code object) | `capture_call_inputs` |
178
+ | `synthesis/{typed_synthesis,characterization,oracle_light,writer}.py` | make inputs, capture goldens, build properties, render pytest | `synthesize_value`, `capture_golden`, `golden_assert_line`, `generate_executable_property`, `render_module`, `individual_test_names` |
179
+ | `converge.py` | **the closed loop**: diagnose→synth-sound→write→re-profile to the ceiling | `converge`, `property_holds`, `passes_to_complete`, `_golden_properties`, `_witness_property`, `_raises_witness_property` |
180
+ | `certify.py` | one-shot synth (library API, no longer a CLI command) + pytest wiring | `certify`, `wire_pytest`, `verify_under_pytest`, `ensure_conftest` |
181
+ | `minimize.py` | two-axis set cover (kill ∪ line) | `minimal_cover_2axis`, `redundant_2axis`, `missing_lines` |
182
+ | `audit.py` | read-only assessment of an EXISTING suite | `audit_suite` |
183
+ | `suite_edit.py` | apply confirmed test deletions | `apply_removals` |
184
+ | `decompose.py` | propose extraction candidates — **STRUCTURE-gated** (not survivor-gated) | `decompose`, `find_extraction_candidates`, `compute_cognitive_complexity` |
185
+ | `decompose_apply.py` | **extract-function**: converge (proof) → cluster → trial-apply → prove → apply | `apply_decomposition`, `extract_candidate` |
186
+ | `equivalents.py` | persist/read manual equivalence flags | `add_flag`, `load_flags`, `flag_key` |
187
+ | `cli.py` | arg parsing + formatting (`--version`, streaming narrative, minimal terse view + `--full`); **zero compute** | `main`, `_run`, `_build_parser`, `_parallel_mode`, `_format_converge`/`_format_converge_terse`, `_final_banner`, `_plain_terms`, `_boundary_hint`, `_notify_stderr`, `_write_converge_report` |
188
+ | `mcp_server.py` | optional MCP surface (`detective-mcp`): exposes `diagnose`/`certify`; zero compute | `build_server`, `main` |
189
+ | (Wesker) `memory_guard.py` | RAM budget, fleet sizing, `RLIMIT_AS`, telemetry, purge | `resolve_budget`, `worker_count`, `apply_address_limit`, `over_budget`, `telemetry` |
190
+
191
+ ---
192
+
193
+ ## 5. The CLI — every command, fully explained
194
+
195
+ Shape: `detective <command> file.py::function [flags]`. `cli._run` splits the target,
196
+ calls the library, prints a formatter; `cli.main` emits a `memory_guard.telemetry()` footer
197
+ to **stderr**. `detective --version` reports the package version. Live mutation progress and
198
+ the converge phase narrative also stream to **stderr**, so stdout stays clean for the result
199
+ / `--json` (and the terse `FINAL` banner stays the last stdout line). Common to most
200
+ commands: `--project-root` (default `.`), `--json`. Commands: `converge`, `audit`,
201
+ `decompose`, `diagnose`, `flag`, `purge` (`certify` is a library API, not a CLI command).
202
+
203
+ **Parallelism (diagnose · converge · audit).** Default is **adaptive auto**: a tiny
204
+ serial probe measures this function's real per-mutant cost, then it fans mutants across
205
+ worker processes *only if the remaining work justifies the spawn tax* — so small/fast
206
+ functions stay serial (≈2 ms overhead) and slow ones parallelize. `--parallel` forces the
207
+ whole run parallel (streaming disabled); `--serial` forces serial. Verdicts are **identical**
208
+ in every mode; the memory guarantee holds by construction (§7).
209
+
210
+ ### `diagnose file::fn [--learn] [--parallel|--serial]` — read-only
211
+ **Purpose:** show a function's behavioral scope and point at the right next command.
212
+ **Operation:** `engine.diagnose` → `profile` → `scope_from_profiling`, plus a structural
213
+ read (`_count_decompose_seams` = `find_extraction_candidates`). No writes.
214
+ **You see:** regime (A/B); variants / value-pinned / unspecified / inert; kill quality
215
+ (value-assertion vs crash, with a ⚠ if crash-dominated); a plain-language "what to run
216
+ next"; and the **decompose guidance from two independent signals**:
217
+ - regime B **and** a structural seam → **`★ LOOK HERE FIRST`** (both methods agree it's
218
+ really >1 thing — the high-value decompose target);
219
+ - regime B, no seam → "entangled but structurally one piece — `converge`, not decompose";
220
+ - a seam but cohesive behavior → "clean seam exists — `decompose` is safe if you want it".
221
+ `--learn` also folds this run's per-category value-survival into
222
+ `.wesker/mutation_report.json` and prints the **learned-weak** priors (which categories
223
+ THIS project recurrently leaves value-unspecified).
224
+
225
+ ### `converge file::fn [--write-dir tests] [--max-iterations N] [--fast] [--full] [--input "(…)"] [--parallel|--serial]` — the flagship, writes tests
226
+ **Purpose:** generate a **mutation-complete, line-complete, minimal** pytest suite.
227
+ **Operation:**
228
+ 1. **Loop** (≤ N passes): `profile` → value-survivors → synthesize `ExecutableProperty`s
229
+ (oracle-light per survivor + golden captures for pure fns via `representative_site`);
230
+ keep only those that **hold on the unmutated fn** (`property_holds`); render the UNION
231
+ across passes (`render_module`) and write. Stop at 0 value-survivors / no progress.
232
+ 2. **Witness pass** (`classify_survivors`): for each *value*-witness, auto-write the
233
+ killing test — a golden for a value-returning original, a `pytest.raises` for a raising
234
+ one (`_raises_witness_property`). Auto-apply because a witness is deterministic proof.
235
+ When synthesis can't build an input, `classify_survivors` first **harvests a real one**
236
+ from the covering tests (`capture_call_inputs`) rather than abstain.
237
+ 3. **Final authoritative profile** → `functionally_complete`, line/minimal/redundant via
238
+ `minimize`, pytest wiring (`wire_pytest`).
239
+ 4. **Minimize before shipping:** drop any test WE generated that is redundant for BOTH
240
+ kills and lines (`redundant_2axis` + `individual_test_names` maps the finding back to its
241
+ property), re-render, re-profile — so the written suite IS minimal by construction, not
242
+ merely accompanied by removal proposals. A non-generation, not a deletion (never auto).
243
+ **Modes:** default **comprehensive** (every mutant, first pass); `--fast` greedy-samples a
244
+ `(1−1/e)`-optimal subset per category per pass. `--max-iterations` caps passes.
245
+ **Output:** live phase narrative streams to stderr (`_notify_stderr`); the default stdout is
246
+ a **minimal terse block** — a plain-language verdict, the one quick action, a report pointer,
247
+ ending in a greppable `FINAL …` banner that is ALWAYS the last line. The full report is
248
+ written to `.detective/reports/converge_<fn>.txt`; `--full` prints it to the terminal too.
249
+ **You see (in the report / `--full`):** a COMPLETE/INCOMPLETE verdict (tiered: complete /
250
+ complete-modulo-equivalent / incomplete, naming the concrete cause — killable residual or
251
+ line gap); score + the **DOF stats flex** (universe · mode · measured % · proven greedy
252
+ floor); per-pass survivors; the **spec-completeness ETA in passes** (or "structure exhausted
253
+ — supply `--input`"); the written test file + wired `conftest.py`; and for any residual, the
254
+ exact `--input "(<slots>)"` to supply — plus, for a BOUNDARY residual, the **distinguishing
255
+ input named** (`_boundary_hint`: the equality edge, e.g. `supply an input where units == 100`).
256
+ `--input` supplies that residual and re-runs to close the loop.
257
+
258
+ ### `audit file::fn [--remove] [--parallel|--serial]` — read-only (unless `--remove`)
259
+ **Purpose:** assess an **existing** suite on both axes without changing it.
260
+ **Operation:** one `profile` of the current suite → `redundant_2axis` (pointless tests) +
261
+ `missing_lines` + `minimal_cover_2axis` + `classify_survivors` (killable gaps vs
262
+ equivalents). **You see:** N existing tests; kills %; mutant-complete / line-complete
263
+ (tiered, incl. "complete modulo N candidate-equivalent — flag to confirm"); the minimal
264
+ cover + bloat; pointless tests to prune; killable gaps with the input that kills them;
265
+ failing-test warnings; and `[audit reads only — writes nothing]`. `--remove` **confirms**
266
+ deletion of the proposed pointless tests (`apply_removals`), then re-audits. Deletion is
267
+ never automatic.
268
+
269
+ ### `decompose file::fn [--apply] [--input "(…)"]` — proves, then writes (with `--apply`)
270
+ **Purpose:** extract a compound block into a helper, **provably behavior-preserving** and
271
+ SICP-cleaner. **Operation** (`apply_decomposition`): (1) **converge** the target to a
272
+ mutation-complete suite = the behavioral spec/proof; (2) **cluster** the body into clean
273
+ extraction candidates (`find_extraction_candidates`: single-exit, small interface,
274
+ cognitive-complexity ≥3) — this is **structure-gated**, independent of test coverage;
275
+ (3) trial-apply each, re-run the suite, keep only what stays green (proof of preservation).
276
+ The proof gate is **mutation-completeness** (not line-completeness) — and **Detective need
277
+ not be the suite's author**: when converge writes nothing *because the pre-existing
278
+ hand-written suite already kills every killable mutant* (the best case), the proof is those
279
+ files. `_covering_test_files` resolves them from the `kill_matrix`, so only tests that
280
+ provably killed a mutant OF THIS TARGET can stand as proof — never the whole discovered
281
+ suite, which would let an unrelated passing test stand in. **You see:** `✓ APPLIED
282
+ (specified behavior preserved, auto)` with the extracted helper + thinned caller — but only
283
+ when the suite proved it. If converge could not reach mutation-completeness (a killable
284
+ mutant synthesis couldn't reach — the genuine "semantic prior" case), it says so and
285
+ surfaces the exact `decompose … --apply --input "(<slots>)"` to supply and close the loop.
286
+ `--apply` writes the file; without it, proposals are shown, never written.
287
+
288
+ ### `flag file::fn MUTANT_ID [--note "why"]` — manual oracle
289
+ **Purpose:** assert a surviving mutant is truly equivalent (nothing can kill it).
290
+ **Operation:** `profile` to find the survivor's `diff_summary` → `add_flag` →
291
+ `.detective/equivalents.json`. Future `classify_survivors` treats it as
292
+ `manual_equivalent`. **You see:** the survivor no longer counts as a gap. A later real
293
+ witness overrides it (proof beats opinion). Content-keyed: a code change to that line
294
+ invalidates the flag by design.
295
+
296
+ ### `purge [--project-root .]`
297
+ Delete regeneratable analysis cruft (`.wesker/*.json`). Never touches generated tests,
298
+ `conftest.py`, or `.detective/` (user data).
299
+
300
+ `certify()` is no longer a CLI command (superseded by `converge`'s loop). It remains a
301
+ library API (`from Detective import certify`) and its module still backs the pytest wiring
302
+ (`wire_pytest`, `verify_under_pytest`) that `decompose` depends on.
303
+
304
+ **MCP** (`detective-mcp`): exposes `diagnose`/`certify` only; zero compute.
305
+
306
+ ---
307
+
308
+ ## 6. The synthesis stack (how inputs are made AND rendered)
309
+
310
+ Witness search and golden capture both need inputs that (a) run the function and (b)
311
+ render into a runnable test. Literals cover scalars/containers; `SourceExpr` bridges
312
+ non-literals.
313
+
314
+ ```
315
+ annotation ──_type_of──▶ type name
316
+ scalar (int/str/float/bool) → _grid_for / _SCALAR_SAMPLE (literal)
317
+ container (list[int], dict[...]) → _synth_from_ann (recurse elements) (literal)
318
+ dataclass → _synth_value (build from fields) (object)
319
+ ast.* (FunctionDef, expr, …) → synth_ast_input (parse a snippet) → SourceExpr
320
+ unannotated → infer_param_types (call-site) → int fallback (§10)
321
+ synthesis raises on every grid → capture_call_inputs: reuse a REAL arg the covering
322
+ tests pass (runtime harvest) → the honest last resort
323
+ ```
324
+
325
+ - **Harvest, don't fabricate.** When every synthesized candidate raises (a domain object no
326
+ grid builds), `capture_call_inputs` installs a `sys.setprofile` hook keyed to the target's
327
+ code object, runs the discovered covering tests, and records the actual bound arguments —
328
+ reusing a real input instead of guessing one. Fires lazily (only when the soundness gate
329
+ would otherwise abstain); the abstention stays the honest Zone-3 fallback when even the
330
+ tests don't exercise the DOF.
331
+ - **Minimal by construction.** After the final profile, converge drops any test it generated
332
+ that is redundant for both kills and lines (`individual_test_names` maps a `redundant_2axis`
333
+ finding back to the property) and re-profiles — the written suite is the minimal cover, not
334
+ the full set plus removal proposals.
335
+
336
+ - **Call sites** `unwrap(arg)` so a `SourceExpr` runs as its live value; **render sites**
337
+ use `repr(arg)` so `SourceExpr.__repr__` emits round-trippable constructor code, with
338
+ imports threaded into the test header.
339
+ - **Assertion rendering** uses value-equality for set-containing outputs (repr order is
340
+ hash-seed-dependent → flaky) else exact repr-equality.
341
+ - **Zone contract:** Zone-1 provable → auto-emit; **Zone-2** partial → the CLI emits the
342
+ exact `--input` residual, the human supplies *that value*, the AST builds the test;
343
+ Zone-3 can't-exercise → typed hand-off. The human never authors a test.
344
+
345
+ ---
346
+
347
+ ## 7. Performance & memory (the layers that keep it fast and bounded)
348
+
349
+ **Coverage-scoped test selection** (`run_function_profiling`, `scope_tests=True`): each
350
+ mutant runs only against the tests that **execute its mutated line** (each mutant carries
351
+ its `mutated_line` from the mutator fire site). Verdict-preserving — a test that never runs
352
+ the mutated line cannot observe the mutation — and the dominant speedup (4–7× locally, more
353
+ on large suites). Failing-baseline tests are folded into every scoped set so it stays
354
+ exactly identical to a full run.
355
+
356
+ **Content-hashed verdict cache** (`verdict_cache.py`, `.detective/verdict_cache.json`):
357
+ `profile()` serves an unchanged function's result from disk. Key = `func_key : AST-dump-hash
358
+ : tests-source-hash : max_per_category : pass_index`. The AST hash is position-independent
359
+ (editing *other* functions never invalidates this one); any edit to the function or its
360
+ tests misses. Single-valid-copy: a new hash purges the function's prior entry. A cache hit
361
+ is byte-for-byte identical to a fresh run; only complete (non-budget-exhausted) runs cache.
362
+
363
+ **Adaptive parallelism** (`parallel.py`, model A): the default. A small serial **probe**
364
+ times this function's real per-mutant cost (killing the stale-rate guessing problem), and —
365
+ if the remaining serial work exceeds the spawn tax — fans the remainder across `spawn`
366
+ worker processes (each re-imports, re-discovers, evaluates one contiguous mutant slice) and
367
+ merges. The probe is reused as shard 0 (and its baseline is shared), so the measurement is
368
+ ≈free. Merge concatenates records in mutant order → **bit-identical to serial** (verified).
369
+ Progress can't stream across processes, so a fanned run prints a one-line `⚡ N mutants
370
+ across W workers` notice instead.
371
+
372
+ **Portable memory guarantee** (`memory_guard.py`): the fleet is sized so
373
+ `workers × per_worker_peak ≤ budget` **by construction** — pure arithmetic, identical on
374
+ Mac/Windows/Linux (`worker_count = min(cores−2, ⌊budget/peak⌋)`, budget = a RAM fraction
375
+ clamped to a ceiling). This is the *hard* guarantee, needing no OS resource limit.
376
+ `RLIMIT_AS` (best-effort, Linux only — macOS rejects it, Windows lacks `resource`) and a
377
+ `getrusage` self-check are bonus enforcement where the OS cooperates. `resource` is imported
378
+ defensively, so Wesker also imports on Windows. `WESKER_MEM_BUDGET_MB` overrides the budget.
379
+
380
+ ---
381
+
382
+ ## 8. Persisted state (what's on disk, who owns it)
383
+
384
+ | Path | Owner | Regeneratable? | Purged by `purge`? |
385
+ |---|---|---|---|
386
+ | `tests/test_<fn>_synth.py` | Detective (product output) | yes | never |
387
+ | `conftest.py` (root) | Detective (pytest wiring) | yes | never |
388
+ | `.detective/reports/converge_<fn>.txt` | Detective (full converge report; terminal stays terse) | yes | (under `.detective/`) |
389
+ | `.detective/equivalents.json` | **user** (manual flags) | **no** | **never** |
390
+ | `.detective/inputs.json` | **user** (supplied `--input` samples — `samples.py`) | **no** | **never** |
391
+ | `.detective/verdict_cache.json` | Detective (profile cache) | yes | (content-invalidated) |
392
+ | `~/.detective/telemetry.json` | Detective (per-machine per-mutant EMA) | yes | — |
393
+ | `.wesker/function_cache.json`, `.wesker/*_report.json` | Wesker | yes | yes |
394
+
395
+ Detective holds **no** cross-run RAM state (MCP server is stateless; CLI frees on exit).
396
+
397
+ ---
398
+
399
+ ## 9. DEBUG MAP (symptom → touch this → why)
400
+
401
+ | Symptom | Touch | Why |
402
+ |---|---|---|
403
+ | Kills all show as **crash**, "0 pinned" on a real fn | this is the crash-vs-value split — check the synthesized input actually RETURNS (not crashes) | `value_killed` counts assertion kills only; a crash-killed mutant is a value-survivor |
404
+ | A mutant a value-assertion *should* kill stays a survivor | `Wesker.engine.evaluate_mutant` (value-precedence: assertion kill beats a crash kill; keep scanning past crash kills) | else a crash-killer that runs first stamps `killed_by=crash` and hides the value-kill |
405
+ | `find_witness` suggests a crash input as "killable" | `equivalence.find_witness` (skips "mutant newly raises") | a crash-kill doesn't pin value; keep searching for a value-witness |
406
+ | `decompose` says "no separable blocks" on a big fn | `decompose.find_extraction_candidates` gates (single-exit, ≤4in/≤2out, CC≥3) | flat/wide fns (dict-builders) have no small-interface block — correct, not a bug |
407
+ | `decompose` won't prove a clearly-decomposable fn | `decompose_apply.apply_decomposition` proof gate = `functionally_complete` (NOT `line_complete`) | mutation-completeness is the proof; line-completeness is orthogonal |
408
+ | `decompose` refuses a fn whose EXISTING suite already specifies it | `decompose_apply._covering_test_files` — the proof falls back to the hand-written covering files when converge writes nothing (`written_path` None ∧ `functionally_complete`) | the proof is mutation-completeness, not authorship; gating on "Detective wrote it" rejected the best case |
409
+ | `decompose` says "not mutation-complete" but converge reports COMPLETE | `cli._format_decompose` — a complete suite that rejects the rewrite reads `REJECTED … PROVES this extraction changes behavior` | the three causes (no suite / incomplete suite / disproved) are distinct verdicts and must not share one message |
410
+ | `decompose` can't prove & gives no way forward | `_format_decompose` residual block (reads `result.proof`) | surface the `--input` the internal converge computed |
411
+ | `diagnose` says "decompose" but decompose finds nothing | `_format_scope` convergent signal (`regime B` **and** `decompose_seams`) | only flag decompose when a structural seam exists |
412
+ | extracted helper carries the PARENT's docstring (and the parent loses its own) | `decompose.find_extraction_candidates` skips a leading docstring (`ast.get_docstring`) | a docstring belongs to the function, never to an extracted block |
413
+ | converge writes a test its own audit then calls redundant | converge step 4 minimize (`redundant_2axis` + `writer.individual_test_names`) | ship the minimal cover, not the full set + removal proposals |
414
+ | Parallel/auto result differs from serial | `parallel.merge_results` (shard-order concat) + `run_function_profiling` `mutant_slice` | records must concatenate in mutant-index order |
415
+ | Cache serves a stale result | `verdict_cache.cache_key` | must hash fn-AST + tests-source + params; content, never path |
416
+ | Generated test is **flaky** (set output) | `characterization.golden_assert_line` | set repr order is hash-seed-dependent → value-equality |
417
+ | `verify_under_pytest` reports 0 passed for a passing suite | `certify.verify_under_pytest` | `-o addopts=` so the target's `-q` doesn't become `-qq` |
418
+ | Survivor reads "uncertain — inputs don't exercise" | `engine._input_grids` / `representative_site` / `call_sites` / `capture.capture_call_inputs` (runtime harvest) | synthesis can't build a fitting value AND no covering test exercises it (domain-value / unannotated — §10) |
419
+ | a BOUNDARY residual says "supply an input" but not WHICH | `cli._boundary_hint` names the equality edge (`left == right`) | a `>`↔`>=` shift differs exactly when operands are equal — the valid relation, not a generic template |
420
+ | memory grows on a huge run | `run_function_profiling` mutant loop + `memory_guard.over_budget` | guard bounds accumulation; parallel bounds the fleet by construction |
421
+
422
+ ---
423
+
424
+ ## 10. Known boundaries & open gaps (honest)
425
+
426
+ **Synthesis boundaries (the real limits behind most "uncertain"/"equivalent" verdicts).**
427
+ Unannotated params fall back to `int` (after a call-site inference attempt); **domain-value
428
+ inputs** (lookup keys, specific source strings, valid domain dicts) aren't synthesizable →
429
+ they surface as a Zone-2 `--input` residual (the correct hand-back, not a defect); self-only
430
+ methods have no receiver synthesis; integration fns (subprocess/file-IO) and engine-core
431
+ (the profiler itself) can't self-profile. The fix is always richer input synthesis or a
432
+ supplied `--input` / manual `flag` — **never a hand-written test**.
433
+
434
+ **Open / deferred (not blocking).** σ-based spec-completeness ETA (upgrade the "≈N passes"
435
+ estimate to a paper-grounded model); occasional `converge` multi-pass progress cosmetics;
436
+ tuning of the adaptive-parallel thresholds (`PROBE_SIZE`, `PARALLEL_MIN_REMAINING_MS`).
437
+
438
+ **The decompose↔spec coupling (design, not bug).** A function converge can fully specify
439
+ (pure, simple inputs) decomposes cleanly cold; one it can't (dicts/methods) needs a supplied
440
+ `--input` first — the tool surfaces exactly that input, and the loop closes **whenever the
441
+ residual is expressible as a Python literal**.
442
+
443
+ **Where the `--input` loop does NOT close (the scope of the hand-back).** `--input` is
444
+ `ast.literal_eval` only — deliberately: no code execution (§6). So it can carry a scalar,
445
+ container, dict or string, but *not an instance* — a `ProfilingResult`, a `SourceExpr`, any
446
+ domain object. The residual is still printed for those (`--input "(<result>,)"`); it simply
447
+ cannot be filled. The complement is `capture_call_inputs` (§6, "harvest, don't fabricate"),
448
+ which reuses a REAL argument from the covering tests — so an object-parameter function is
449
+ specifiable *iff some test already passes it one*. A cold function whose parameter is a
450
+ domain object is reachable by neither, and that is the honest Zone-3 abstention, not a
451
+ defect. Detective's own core is largely in this class (it passes `ProfilingResult` around),
452
+ which is why `scope.py::scope_from_profiling` is its own top decompose candidate and still
453
+ needs a covering test to harvest from.
454
+
455
+ ---
456
+
457
+ ## 11. Working on this codebase (the discipline)
458
+
459
+ **Dogfood before AND after every function change** — profile with the literal `detective`
460
+ (this *is* the product's intended workflow, not a checkbox):
461
+
462
+ ```
463
+ PYTHONPATH=/Users/rohanvinaik/tools/Detective \
464
+ /Users/rohanvinaik/tools/Wesker/.venv/bin/python -m Detective.cli \
465
+ converge "PATH::FUNC" --project-root ROOT # generates its tests AND tests the pipeline
466
+ ```
467
+
468
+ - **Serena for navigation** (symbol graph, references) — not grep/name-matching; it is a
469
+ dev-time oracle, never a runtime dependency.
470
+ - **Never hand-write** a test for a Detective/Wesker function — run `converge`; if it can't,
471
+ fix the *input generation* (§6) or supply the `--input` residual, don't hand-write.
472
+ - **Bidirectional**: if dogfooding shows the bug is in Wesker, the fix goes in Wesker.
473
+ - **Auto-apply principle**: deterministically-correct → auto; only-mostly-correct → propose
474
+ (show code); **deletion is never auto** (propose + confirm).
475
+ - **Determinism is the product** (the audience is Sussman-lineage): any parallel path must
476
+ be bit-identical to serial, any cache content-addressed. Verify, don't assume.
477
+ - Engine-core / integration fns that can't self-profile are guarded by the unit suite — the
478
+ *only* exemption from the converge rule.
479
+
480
+ Suites: Detective `python -m pytest` (258 green) + Wesker (92 green), run with the Wesker
481
+ venv python and `PYTHONPATH` at the repo root. Push / PyPI publish are **user-only**.
@@ -0,0 +1,29 @@
1
+ """Detective — behavioral-scope diagnosis and warrant-classed test synthesis for
2
+ a single Python function, built on the Wesker mutation engine.
3
+
4
+ Depends only on Wesker + the standard library. No lintgate runtime dependency.
5
+
6
+ Public API:
7
+ diagnose(file, function, project_root=".") -> ScopeMap
8
+ certify(file, function, project_root=".", *, write_dir=None) -> CertifyResult
9
+ converge(file, function, project_root=".", *, write_dir="tests") -> ConvergeResult
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ __version__ = "0.1.0"
15
+
16
+ from .certify import CertifyResult, certify
17
+ from .converge import ConvergeResult, converge
18
+ from .engine import diagnose
19
+ from .scope import ScopeMap
20
+
21
+ __all__ = [
22
+ "diagnose",
23
+ "certify",
24
+ "converge",
25
+ "CertifyResult",
26
+ "ConvergeResult",
27
+ "ScopeMap",
28
+ "__version__",
29
+ ]
@@ -0,0 +1,10 @@
1
+ """``python -m Detective`` entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())