Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ adheres to [Semantic Versioning](https://semver.org/).
- **The doc-claim gate's own vacuity guard could be held open by a number that was never a claim.** `TEST_CLAIM` matches any `N tests` phrase in a scanned file, so an incidental count — "12 tests skipped locally that CI runs", a true and dated measurement — was read as an advertisement of the suite size. That line is not yet on `main`: it arrives with #231, whose `Test (Python 3.12)` leg fails today on exactly `CONTRIBUTING.md:36: advertises 12 tests` while its `Lint` leg passes, because the static gate skips the test-count family when no `--test-count` is given. This change is therefore the build-first half of that pair — it lands the mechanism, and #231 declares the marker on its own line. Two defects followed, and the second is the dangerous one: the false positive *counted as a match*, so `check_counts`' vacuity guard (the thing that turns a reworded or deleted claim into a build failure rather than an unnoticed loss of coverage) stayed silent. Probed on the pre-fix code: a tree whose only `N tests` text was that incidental line returned a mismatch, not the vacuity message — every real `6268 tests` claim could have been deleted and the guard would not have fired. A line whose number counts something else now declares `[not-a-count-claim: <label>]` and is skipped **for that family only**, so the same line still answers to every other one; the prose keeps its number, its date and its breakdown, because rewording a true measurement to keep a gate quiet hides the measurement instead of fixing the gate. Declared exemptions form a registry that is printed on every successful run and pinned by name in a test, so an exemption is added deliberately or not at all, and the marker fails closed — a misspelled or wrong-family label exempts nothing. Also folded away a second, hand-rolled copy of the vacuity guard for the with-integrations tool claim, which had no test of its own; it now runs through `check_counts` like every other family and has one. Scoped mutation run: **0 surviving mutants on the changed code** (`collect_failures` went 45 → 0 once the composition was driven against a deliberately stale repository rather than only the green real tree); 25 survivors remain in the untouched `canonical_*` helpers, filed as #235.
- **The test-count family was checked in exactly one place on earth.** `collect_failures` skips `TEST_CLAIM` entirely when no `--test-count` is passed, and only the Python 3.12 matrix leg of the test job passes one — so the repository-level test that runs everywhere never exercised the most drift-prone claim in the project, and five of the six CI jobs were blind to it. `test_every_advertised_test_count_states_the_same_number` asserts instead that every advertised count *agrees with the others*, which needs no live `pytest --collect-only` and therefore runs in the local suite and in all six jobs; a half-updated count now fails before it reaches CI.
- **`scripts/` was unreachable by the mutation runner**, so no gate, hook or helper under it had ever been mutation-tested. Three things blocked it: `[tool.mutmut] source_paths` was `mcp_server`-only (a source outside it is silently never mutated, and the run then reports zero survivors because it mutated nothing); `also_copy` omitted the documentation surface, so a module that resolves repository files relative to its own location raised `FileNotFoundError` under every mutant; and the test loaded the gate under a bare module name while mutmut keys its trampolines on the dotted path, which made every mutant look unreached. `scripts/mutation_check.sh` now unions the sources' roots into `source_paths`, `also_copy` carries the files the gate reads, and the test loads it as `scripts.check_doc_claims`.
- **`scripts/mutation_check.sh` false-reported 50 survivors for `ast_extractor_registry.py`'s `_make_extractor`/`build_extra_extractors`** (#269) — mutmut's per-mutant test attribution is recorded once, from a coverage trace of the FIRST test whose call reaches the mutated line; `ast_parser._EXTRACTORS = {..., **build_extra_extractors()}` builds its dispatch table once, eagerly, at import time, so every test after the first exercises the already-built, cached closures without ever re-invoking the mutated functions — mutmut narrowed the per-mutant rerun to one (often irrelevant) test and reported "survived" for all 50 mutants even though the full 3-file test selection genuinely kills every one. `scripts/mutation_recheck_survivors.py` closes the gap generically, for any source, not by special-casing this one file: every mutant mutmut reports "survived" is re-run against the FULL declared test selection before the verdict is trusted, and a mutant the full selection kills is reported as **RECOVERED** — a distinct, visible category, never silently folded into "killed" (a false-survivor report otherwise either blocks a correct commit or trains reviewers to wave off real survivors as "probably a tooling artifact"). `scripts/mutation_check.sh` also gained multi-file test-selection support (`<test_paths>` now accepts a space-separated list), needed because `_make_extractor`/`build_extra_extractors` require `ast_parser.py`'s own repeatedly-called functions in the same run for mutmut's OWN forced-fail bootstrap check to succeed at all (mutating `ast_extractor_registry.py` alone starves that check of anything to re-invoke). Re-running the full reproduction surfaced ONE genuine (non-false) survivor mutmut's current version generates that the issue's original reproduction did not: `_make_extractor__mutmut_10` replaces the `source` argument to the calls-extractor with `None`, invisible to every existing per-language test because `extract_calls_generic`'s hardcoded node-type list (`"call"`/`"call_expression"`) never matches any of the 7 languages' own call-expression node type (Java's is `method_invocation`, for instance — a separate, pre-existing, out-of-scope language-coverage gap); a new test (`test_make_extractor_threads_the_real_source_into_calls_extraction`) monkeypatches `extract_calls_generic` to assert `_make_extractor`'s own composition contract directly, closing it. Verified: 0 genuine survivors remain in `ast_extractor_registry.py`; `json_native.py`'s existing scoped run is unchanged (same 5 documented-equivalent survivors); `scripts/mutation_recheck_survivors.py` itself carries **0 surviving mutants** on its own committed scope (32 new tests).
- **An unreadable wiki `README.md` was overwritten instead of preserved.** `wiki_store._try_reindex` promises "never clobber a hand-written README", but when the marker check could not read the file (permissions, non-UTF-8 bytes) the failure was swallowed and `should_write` stayed `True` — the README was replaced with generated content. Surfaced by the #197 S110 sweep; an unreadable README is now left untouched and the read failure is reported via `silent_failure` (`wiki_store.readme_read`). Regression test: `tests_py/infrastructure/test_s110_sweep_infrastructure.py::TestWikiStoreReadmeGuard::test_unreadable_readme_is_not_clobbered`.
- **The wiki pipeline was silently dead on the SQLite backend** (#206) — the default for plugin installs, `.mcpb`/Cowork, and every sandboxed launch (`PRIVACY.md` lines 26–38). `PsycopgCompatConnection` exposed no `cursor()`, so all six stages (`extract → resolve → emerge → synthesize → curate → compile`) and `wiki_migrate` raised `AttributeError`; each error was captured as a string and `backfill_memories` returned a **success-shaped payload with zero pages and no log line** — the FlashRank silent-failure mode a third time. A fresh SQLite install reported a successful backfill and produced no wiki. The **entire `wiki` schema is now ported to SQLite** (`infrastructure/sqlite_schema_wiki.py`: the eight `wiki.*` tables flattened to `wiki_*`, since SQLite has no schema namespaces, plus every index except the PostgreSQL-only HNSW/GIN families), and the compat layer grew the translations the shared SQL actually needs: `cursor()` (context-managing, dict-returning, accepting psycopg's `row_factory=`), `%(name)s` → `:name`, `wiki.<t>` → `wiki_<t>`, `array_length(c,1)` → `json_array_length(c)`, `= ANY(?)` and `&&` → `json_each` membership/intersection tests, `::int[]` casts (the trailing `[]` included), `IS DISTINCT FROM` → `IS NOT`, `UPDATE t alias` → `UPDATE t AS alias`, and `RETURNING id, (xmax = 0) AS inserted` → `RETURNING id` (kept natively on SQLite ≥ 3.35, because `lastrowid` does not identify the row an upsert *updated*). `INTEGER[]`/`JSONB` columns are declared `JSON` and round-trip through a registered converter/adapter pair, so `entity_ids` returns a `list[int]` as psycopg gives — without it the column returned `"[1,2]"` and `for eid in entity_ids` would have iterated **character-wise**, yielding garbage ids and never raising. Two of the defects made stages *report success while doing nothing*: untranslated `%(name)s` extracted **zero claims**, and `wiki_resolve`'s `WHERE entity_ids = '{}'` (PostgreSQL's empty-array literal, which never matches SQLite's `'[]'`) made resolution a **permanent no-op** — that predicate is now the backend-agnostic `COALESCE(array_length(entity_ids, 1), 0) = 0`, and `wiki_emerge`'s bare `COUNT(*)` is aliased, since psycopg names that column `count` and SQLite names it `COUNT(*)`. Both `wiki_pipeline._safe_call` and `backfill_memories`' pipeline `except` clause now **log** the failure instead of only recording it. Verified end-to-end on SQLite (4 memories → 3 claims → 3 concepts → 3 drafts → 3 published pages) against a paired PostgreSQL control on the same commit; existing SQLite databases gain the tables on next open via the `CREATE TABLE IF NOT EXISTS` init path, no shim.
- **The stage-aware context assembler dropped memories it had selected, instead of condensing them** (#196). Its own contract reads "may truncate individual chunks but never reduces the count of selected items"; the code did the opposite — Phase 2 skipped any memory that did not fit the remaining budget and Phase 3 broke out of its loop, so the longest memories (the ones retrieval had just ranked highest) vanished from both the rendered text and `selected_memories`, while Phase 1's 60 % share was never computed at all. Measured pre-fix with two ~760-token adjacent memories at a 120-token budget: zero phase-2 memories and empty adjacent text. The domain-aware condensers in `core/context_assembly/condensers.py` were written for exactly this reduction and had no caller — they are now the packing rule's reduction step (`core/context_assembly/stage_phases.py`), giving every item a share of the budget (the Swift ContextDecomposer rule, now a single definition in `budget.proportional_share`) and condensing the over-share ones. One output per input, never a drop. Behaviour is unchanged when `token_budget is None`, which is what every current caller passes. Also fixed: `condense_assistant_message` could return an empty string — a single code block larger than the whole budget kept no blocks and joined an empty list, deleting the memory outright — and now falls back to `truncate_to_budget` like every sibling condenser.
Expand Down
8 changes: 4 additions & 4 deletions assets/badge-tests.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
43 changes: 36 additions & 7 deletions scripts/mutation_check.sh
Original file line number Diff line number Diff line change
@@ -1,13 +1,31 @@
#!/usr/bin/env bash
#
# Scoped mutation-testing run (mutmut 3.x) — rules/coding-standards.md §12.
# Mutates ONLY the given source file(s) against the given test path, then
# Mutates ONLY the given source file(s) against the given test path(s), then
# restores pyproject.toml's [tool.mutmut] block untouched. Standard: 0
# surviving non-equivalent mutants on the changed code.
#
# usage: scripts/mutation_check.sh <test_path> <source.py> [source.py ...]
# usage: scripts/mutation_check.sh <test_paths> <source.py> [source.py ...]
# e.g. scripts/mutation_check.sh tests_py/shared/test_json_native.py \
# mcp_server/shared/json_native.py
# <test_paths> may be a single file or a space-separated list quoted as
# one argument, e.g. "tests_py/a/test_a.py tests_py/b/test_b.py" — a
# module mutated by more than one test file needs its full selection
# here (see the eager-import re-verification note below; issue #269).
#
# Any mutant mutmut itself reports "survived" is re-verified against the
# FULL test selection above before being trusted (scripts/
# mutation_recheck_survivors.py): mutmut's per-mutant test attribution is
# recorded once, from the first test whose coverage trace reaches the
# mutated line. A module that builds a dispatch table eagerly at import
# time (memoizing the built closures) is invisible to that attribution
# for every later test, so mutmut narrows the per-mutant rerun to just
# the first (often irrelevant) test and reports "survived" even when the
# full suite kills the mutant — issue #269's root cause, reproduced and
# fixed there. A mutant the full selection actually kills is reported as
# RECOVERED, never silently folded into "killed": the false-survivor
# cause must stay visible to the reader (issue #269 acceptance criterion
# 2), not just absorbed.
#
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
Expand All @@ -19,6 +37,7 @@ if [ $# -lt 2 ]; then
fi

TESTS="$1"; shift
read -r -a TEST_ARR <<< "$TESTS"
PY="$ROOT/pyproject.toml"
BAK="$(mktemp)"
cp "$PY" "$BAK"
Expand All @@ -30,17 +49,20 @@ trap cleanup EXIT
# copies those into mutants/, and a source outside them is silently never
# mutated — the run then reports 0 survivors because it mutated nothing. Rooted
# at the committed value so a run inside mcp_server/ keeps its existing scope.
python3 - "$PY" "$TESTS" "$@" <<'PYEOF'
python3 - "$PY" "${TEST_ARR[@]}" -- "$@" <<'PYEOF'
import re, sys
path, tests, *sources = sys.argv[1], sys.argv[2], *sys.argv[3:]
args = sys.argv[2:]
sep = args.index("--")
tests, sources = args[:sep], args[sep + 1:]
path = sys.argv[1]
fmt = lambda xs: "[" + ", ".join(f'"{x}"' for x in xs) + "]"
src = open(path).read()
roots = sorted({s.split("/", 1)[0] for s in sources})
declared = re.search(r'^source_paths = \[(.*)\]$', src, flags=re.M)
existing = re.findall(r'"([^"]+)"', declared.group(1)) if declared else []
src, n1 = re.subn(r'^only_mutate = .*$', "only_mutate = " + fmt(sources), src, count=1, flags=re.M)
src, n2 = re.subn(r'^pytest_add_cli_args_test_selection = .*$',
"pytest_add_cli_args_test_selection = " + fmt([tests]), src, count=1, flags=re.M)
"pytest_add_cli_args_test_selection = " + fmt(tests), src, count=1, flags=re.M)
src, n3 = re.subn(r'^source_paths = .*$',
"source_paths = " + fmt(sorted(set(existing) | set(roots))), src, count=1, flags=re.M)
assert n1 and n2 and n3, "pyproject [tool.mutmut] must define only_mutate, source_paths and pytest_add_cli_args_test_selection"
Expand All @@ -49,5 +71,12 @@ PYEOF

echo ">>> mutating: $* | tests: $TESTS"
uv run mutmut run
echo ">>> survivors (must be empty, or documented equivalents):"
uv run mutmut results | grep -iE 'survived' || echo " none — 0 surviving mutants 🎉"
echo ">>> mutmut-reported survivors (must be empty, or documented equivalents):"
RESULTS="$(uv run mutmut results)"
if echo "$RESULTS" | grep -qiE 'survived'; then
echo "$RESULTS" | grep -iE 'survived'
echo ">>> re-verifying against the FULL test selection before trusting the verdict (issue #269):"
echo "$RESULTS" | uv run python3 "$ROOT/scripts/mutation_recheck_survivors.py" "$ROOT/mutants" "${TEST_ARR[@]}"
else
echo " none — 0 surviving mutants 🎉"
fi
Loading