Skip to content

Commit 127be28

Browse files
Merge pull request #255 from brownjuly2003-code/test/sql-builder-foreign-tenant-mutants
test(mutation): green the mutation gate, make it measurable locally, and take sql_builder off its threshold line
2 parents da50ccb + 49fe3f9 commit 127be28

8 files changed

Lines changed: 2251 additions & 14 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,66 @@ All notable changes to AgentFlow are documented in this file.
5454
failing at import. The stale-shim hazard is now written into the test's own
5555
design rules, since the next rename will ripple the same way.
5656

57+
* **The gate is measurable on this machine now, not only on Sundays.**
58+
`python scripts/mutation_local.py --module <target>` runs one module of the
59+
same gate locally in about two minutes. It exists because `mutmut run` calls
60+
`sys.exit(1)` at import time on native Windows, so between weekly runs nobody
61+
here could see a score at all — which is part of why nine consecutive red
62+
Sundays went unnoticed. The driver never invokes the `mutmut` CLI: it reads
63+
its targets from `scripts/mutation_report.MODULE_TARGETS`, builds the
64+
workspace with `prepare_workspace`, generates mutants with mutmut's own
65+
`mutate_file_contents`, and runs each one as a plain `pytest` subprocess
66+
selected through `MUTANT_UNDER_TEST`. The gate's definition of a target is
67+
still declared in exactly one place.
68+
* **It reproduces CI rather than approximating it.** Measured against run
69+
34266462154 on `serving/semantic_layer/query/sql_builder.py`: 141 mutants,
70+
the same population, down to the surviving mutant names. At `10e20a5` the
71+
module scores 90.8% (128 killed, 13 survived) — the 88.7% CI last reported
72+
plus the three mutants `2cda8da` and `10e20a5` killed since. Only pytest exit
73+
0 (survived) and 1 (killed) count as verdicts; anything else is reported as a
74+
harness failure and fails the run, where `mutation_report.py` counts exit 3
75+
as a kill. That is the one deliberate divergence, and `CONTRIBUTING.md` says
76+
so rather than claiming exact parity.
77+
* **A score you can trust to be about your own tree.** Three failure modes are
78+
closed by construction: the workspace is stamped with the root, the module,
79+
its source, the materialized package tree, the target's tests and
80+
`pyproject.toml`, and is rebuilt whenever any of those move, so a second run
81+
never reports the first one's sources; a mutant that comes back without a
82+
verdict is retried once serially before it is called a harness failure, so
83+
the number does not drift with machine load; and a `--workspace` that is a
84+
checkout — this repository, anything inside it, or any directory holding a
85+
`.git` — is refused instead of emptied. The mutated module never leaves the
86+
temp workspace: the working tree is clean after a run.
87+
88+
* **`sql_builder.py` is off the threshold line, and its residue is honest.**
89+
It cleared 90% by a single mutant (90.8%, 128 killed of 141), which is not a
90+
margin worth keeping: the next covered line added to the module would have
91+
put the gate back in the red for reasons unrelated to the change. Nine of the
92+
thirteen survivors could never have been killed. Eight mutated a
93+
`typing.cast` type argument — a cast returns its second argument untouched
94+
and never evaluates the first — so both casts are plain annotations now and
95+
the mutants stop existing; the ninth turned `rows = []` into `rows = None` in
96+
a branch whose next statement is `bool(rows)`, and carries a
97+
`# pragma: no mutate` with the reason above it. The remaining four were the
98+
`dialect="duckdb"` argument, and two of them are now dead: DuckDB list
99+
indexing is 1-based where sqlglot's default dialect is not, so
100+
`list_value(1, 2)[1]` read without the dialect comes back out of the scoper
101+
as `[2]` — the tenant scoper would have changed which element the query asked
102+
for while it added a WHERE clause. The module measures 98.4% (124 killed of
103+
126) with `scripts/mutation_local.py` on py3.13.
104+
* **The two mutants still alive are named in the test file, not suppressed.**
105+
`_scope_sql__mutmut_41` and `_43` drop the dialect from the parse of the
106+
relation `_qualify_table` generated itself, and that string has one fixed
107+
shape which — parsed with the dialect or without it — renders identically
108+
under the `sql(dialect="duckdb")` `_scope_sql` applies on the way out, so no
109+
input reaches them with a difference to observe. Not the same as neutral: the
110+
*default-dialect render* of that shape rewrites `EXCLUDE` to `EXCEPT`, which
111+
is why the mutants on the render itself stay killable and dead.
112+
They are not equivalent — a `_qualify_table` that ever emitted
113+
DuckDB-specific syntax would make them killable — so they get a written
114+
record of what was tried and came out identical rather than a pragma that
115+
would outlive its reason.
116+
57117
### Terraform — an exact core pin took the provider update channel down with it
58118

59119
* **`required_version = "= 1.15.4"` broke Dependabot's terraform ecosystem the

‎CONTRIBUTING.md‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,43 @@ replaceable runtime artifacts, not reviewed evidence or production acceptance.
148148
Promote a reviewed snapshot only under a new date-stamped identity with
149149
provenance.
150150

151+
`python scripts/mutation_local.py --module <target>` measures one module of
152+
that same gate on this machine, so a mutation score is available before you
153+
push instead of only after the weekly workflow. `--list-modules` prints the
154+
targets; the score, the mutant population and the surviving mutant names match
155+
the CI run for the same commit — up to pytest's internal-error exits, which
156+
`scripts/mutation_report.py` counts as kills (exit 3) while this driver refuses
157+
to score them at all — because the driver reads its targets from
158+
`scripts/mutation_report.MODULE_TARGETS`, builds its workspace with
159+
`prepare_workspace`, and generates mutants with mutmut's own engine
160+
(`mutmut.mutation.file_mutation`). It needs `mutmut` installed (it is in the
161+
`dev` extra) but never invokes the `mutmut` CLI: mutants are executed as plain
162+
`pytest` subprocesses selected through `MUTANT_UNDER_TEST`, which is what makes
163+
this work on native Windows, where `mutmut run` exits at import time. Expect
164+
minutes, not seconds — one pytest process per mutant, `--jobs` in parallel.
165+
The driver exits 1 when the module is below its threshold or when any mutant
166+
got no verdict (only pytest exit 0 = survived and 1 = killed are verdicts;
167+
anything else is a harness failure, never a kill). A mutant that comes back
168+
without a verdict — usually a timeout from running `--jobs` of them at once —
169+
is retried once serially with a longer timeout before it is reported that way,
170+
so the score does not move with the machine's load. Its workspace and JSON
171+
report live under the OS temp directory, outside the repository; the workspace
172+
is reused across runs only when it is stamped with the same root, module,
173+
module source, materialized top-level package tree, target tests and
174+
`pyproject.toml` (which `prepare_workspace` always renders into the workspace as
175+
a real file, carrying pytest addopts, filterwarnings and `[tool.mutmut]`), and
176+
is rebuilt otherwise, so a second run never reports the first one's copy of
177+
those sources. The stamp does not cover the trees `prepare_workspace` normally
178+
symlinks — `src/`, `sdk/`, `config/`, `scripts/` and the rest of `tests/`
179+
beyond the target's own test files — which it copies instead where the OS
180+
refuses symlinks; on such a machine, pass a fresh `--workspace` after editing
181+
them. A `--workspace` is emptied on rebuild,
182+
so one that is a checkout — this repository, anything inside the tree the
183+
sources come from, or any directory holding a `.git` — is refused instead, and
184+
only a directory carrying the driver's own marker is ever cleared. `--root`
185+
points it at another checkout; `--only` re-runs named mutants, written either
186+
bare or exactly as the report prints them (`<module>.<mutant>`).
187+
151188
`python scripts/evaluate_trivy_policy.py` writes ignored Trivy policy
152189
summaries under `.artifacts/trivy/`. Relative `--report`, `--waivers`, and
153190
`--output` paths resolve from the project root, not the caller CWD, and every

0 commit comments

Comments
 (0)