chore: local informational check for dormant BDD scenarios#1622
chore: local informational check for dormant BDD scenarios#1622pmezzich wants to merge 2 commits into
Conversation
make check-dormant (scripts/check_dormant_scenarios.py) tells you which scenarios touched by your change never run, before you claim them as coverage. Per the prebid#1603 discussion: local and informational, not a CI gate — exit 0 unless --strict. Mechanism: map the branch's BDD-relevant diff (step modules, feature files, test modules) to the test modules that bind them, then run just those WITHOUT a DATABASE_URL. Wired scenarios skip at the integration_db gate (they would run in CI); dormant ones xfail at the harness/step layer (they run nowhere). No Docker needed, seconds not minutes. Output groups dormant scenarios by reason with transport params collapsed, and lists documented spec-gap xfails separately for awareness. Touched conftest/harness/generic-steps files affect every scenario, so the default run notes them and points at --all rather than silently sweeping the world. Verified live: the UC-026 step module reports 49 dormant scenarios ('No harness wired for None'); the UC-003 module reports 54 still dormant post-prebid#1417 ('non-extension scenarios'); a clean branch reports nothing to check. 9 unit tests pin the classification and path mapping.
git add silently skipped scripts/check_dormant_scenarios.py (.gitignore line 55 ignores check_*.py), so the previous commit shipped the Makefile target and tests without the script itself — CI failed on the missing file. Tracked the same way as scripts/check_scoped_coverage.py.
KonstantinMirin
left a comment
There was a problem hiding this comment.
Review — PR #1622
Overview — This is a genuinely useful tool and the core design is right: dropping DATABASE_URL to split "would run in CI" (skips at the db gate) from "never runs anywhere" (xfails at the harness/step layer) is a clean trick, and the mapping tests run against the real repo tree with zero mocks. What needs fixing before merge clusters around one theme: a tool whose whole job is catching quiet false-greens currently has two quiet false-greens of its own — a crashed pytest run prints the all-clear, and the classifier's reason vocabulary is a hand-copied snapshot of the conftest that is already drifted on arrival.
Should fix
1. A crashed run reads as the all-clear. run_without_db() returns only out.stdout and discards the return code and stderr; classify() only reads XFAIL lines. So when a touched module fails to import — exactly the state a developer mid-edit is in when they'd reach for this tool — pytest exits 2 with zero XFAIL lines and the script prints no dormant scenarios in the touched area — everything you changed either runs or is a documented gap and exits 0. --strict is defeated the same way. Reproducible: add import nonexistent_module_xyz to any tests/bdd/test_uc*.py and run --paths on it.
Fix: return the CompletedProcess (or (returncode, stdout, stderr)), and in main() treat pytest return codes outside (0, 1) as "run did not complete — results unreliable": print stderr + tail of stdout and exit non-zero regardless of --strict. Pin it with a unit test.
2. The classifier vocabulary is a hand-maintained copy of the conftest's xfail reasons — and it's already wrong in both directions. The dormant/documented split lives or dies on string-matching the reasons tests/bdd/conftest.py emits, but _DORMANT_MARKERS is a disconnected copy:
- Missing: the conftest's auto-xfail hook converts two exception classes into wiring-gap xfails —
StepDefinitionNotFoundError→"Step definition not found: ..."(covered) andNotImplementedError→"Not implemented: ..."(conftest.py:100-101, not covered). Harness stubs raiseNotImplementedErrortoday (tests/harness/dispatchers.py:338,347), so the day one fires, those scenarios land in the "DOCUMENTED spec-production gaps (fine)" bucket — the exact false reassurance #1603 exists to eliminate. - Phantom:
"step definition is not found"matches nothing anywhere in the tree. - Third copy:
scripts/enumerate_bdd_issues.py:86-98already encodes the same dormant-vs-documented taxonomy with yet another phrasing.
Nothing pins the coupling either: SAMPLE_OUTPUT in the unit tests is hand-written to match the script's own markers, so rewording a pytest.xfail(...) reason in conftest blinds the tool while all 9 tests stay green.
Fix: extract the reason strings into one shared module (e.g. tests/bdd/xfail_taxonomy.py); conftest builds its pytest.xfail(...) reasons from those constants and classify() imports the same constants for matching — an import, not a comment. Add "not implemented" to the markers with a Not implemented: ... line in SAMPLE_OUTPUT asserted into the dormant bucket, and drop the phantom entry.
3. Same helper re-implemented instead of extracted (DRY within this diff). Two shapes:
- Path normalization
path.strip().replace("\\", "/")is copy-pasted at three sites in the new script (changed_paths,is_bdd_relevant,map_paths_to_modules). The next normalization fix (git's quoting of non-ASCII paths, rename arrows) lands at one site and misses two. Extract_norm(path)and normalize once at ingestion. - nodeid → scenario-name collapse
nodeid.split("::")[-1].split("[", 1)[0]duplicatesscripts/enumerate_bdd_issues.py:53character-for-character modulo themaxsplit. Putscenario_name(nodeid)in the same shared module as the taxonomy constants and import it from both scripts.
4. The tool's strongest diagnostics are untested. map_paths_to_modules() has four note-producing branches; the tests cover only the conftest one. Untested: a .feature file no module binds ("every scenario in it is dormant" — the loudest signal the tool emits), a ucNNN step module with no matching test module, and a domain step module without a ucNNN name. Inverting the if binders: condition survives the whole suite. These are pure functions of path + repo tree — add TestMapPaths cases with e.g. tests/bdd/features/BR-UC-999-nonexistent.feature and a uc999_*.py path asserting modules == set() and the specific note text.
Nice to have
git status --porcelainrename entries:line[3:]onR old.py -> new.pyyields a pseudo-path that silently drops an uncommittedgit mvof a feature/step file — a moment scenarios go dormant.line[3:].split(" -> ")[-1].changed_pathsdiffs"..HEAD"whenmerge-basereturns empty (shallow clone) — bail with a message instead of an empty diff._XFAIL_LINE's(\S+?)drops nodeids whose param ids contain spaces; anchor on the-reason separator instead.--pathsgiven only unmapped/non-BDD paths exits with no output at all — print the "nothing mapped" parity message the git-diff branch already has.test_transport_params_collapse_to_one_scenario: counting duplicates inside asetcan never exceed 1 — assert the bucket's exact contents instead (that actually fails if collapsing breaks).- Docstring names a person; convention everywhere else is issue number only —
#1603carries the provenance. - Sibling scripts carry
#!/usr/bin/env python3; this one doesn't.
Notes / prior-review follow-ups
First review round — no prior threads to reconcile. Worth crediting: no allowlist or ratchet moves anywhere in the diff; the informational-by-default/--strict-opt-in shape matches the #1603 direction; and unit tests are the right vehicle here (dev tooling, not protocol behavior — no BDD scenario demanded), with the mock-free real-tree mapping tests being the right kind of coupling.
|
|
||
| # Reasons that mean "this scenario is dormant because nothing wires it", as | ||
| # opposed to documented spec-production gaps that are xfailed on purpose. | ||
| _DORMANT_MARKERS = ( |
There was a problem hiding this comment.
This tuple is a hand-copied snapshot of the reasons tests/bdd/conftest.py emits, and it's already drifted both ways: the conftest's second auto-xfail wiring-gap reason "Not implemented: ..." (conftest.py:100-101, raised today by tests/harness/dispatchers.py:338,347 stubs) is missing — those scenarios would be bucketed as "DOCUMENTED spec-production gaps (fine)", the exact false reassurance #1603 targets — while "step definition is not found" matches nothing in the tree. Extract the reason strings into a shared module (e.g. tests/bdd/xfail_taxonomy.py) that conftest builds its pytest.xfail(...) reasons from and this classifier imports, add "not implemented", drop the phantom, and add a Not implemented: line to SAMPLE_OUTPUT asserted into the dormant bucket.
| "--no-header", | ||
| ] | ||
| out = subprocess.run(cmd, cwd=REPO_ROOT, capture_output=True, text=True, env=env, check=False) | ||
| return out.stdout |
There was a problem hiding this comment.
Returning only stdout swallows collection/import errors: pytest exits 2 with zero XFAIL lines, classify() sees nothing, and main() prints the all-clear and exits 0 — even under --strict. Reproduced: append import nonexistent_module_xyz to a touched module and run --paths on it → "no dormant scenarios". Return the CompletedProcess, treat return codes outside (0, 1) as "run did not complete — results unreliable" (print stderr, exit non-zero regardless of --strict), and pin it with a unit test.
| working = [line[3:] for line in _git("status", "--porcelain").splitlines() if len(line) > 3] | ||
| seen: list[str] = [] | ||
| for p in committed + working: | ||
| p = p.strip().replace("\\", "/") |
There was a problem hiding this comment.
strip().replace("\\", "/") normalization is repeated at three sites in this file (here, is_bdd_relevant, map_paths_to_modules). Extract _norm(path) and normalize once at ingestion (here and for --paths in main()), so the next normalization fix can't land at one site and miss the others.
|
|
||
|
|
||
| def is_bdd_relevant(path: str) -> bool: | ||
| p = path.replace("\\", "/") |
There was a problem hiding this comment.
Second copy of the path normalization — route through the shared _norm() (or assume pre-normalized input once changed_paths/--paths normalize at ingestion).
| all_test_modules = sorted((REPO_ROOT / BDD_DIR).glob("test_*.py")) | ||
|
|
||
| for raw in paths: | ||
| p = raw.replace("\\", "/") |
There was a problem hiding this comment.
Third copy of the path normalization — same _norm() extraction.
| continue | ||
| nodeid, reason = m.group(1), (m.group(2) or "").strip() | ||
| # Collapse parametrization (outline rows nest brackets, so split not regex) | ||
| scenario = nodeid.split("::")[-1].split("[", 1)[0] |
There was a problem hiding this comment.
This nodeid→scenario collapse duplicates scripts/enumerate_bdd_issues.py:53 (nid.split("::")[-1].split("[")[0]). Extract scenario_name(nodeid) into the same shared module as the xfail-reason constants and import it from both scripts.
| """Committed changes vs merge-base plus uncommitted working-tree changes.""" | ||
| merge_base = _git("merge-base", "HEAD", base_ref) | ||
| committed = _git("diff", "--name-only", f"{merge_base}..HEAD").splitlines() | ||
| working = [line[3:] for line in _git("status", "--porcelain").splitlines() if len(line) > 3] |
There was a problem hiding this comment.
line[3:] on a porcelain rename entry (R old.py -> new.py) yields the pseudo-path "old.py -> new.py", so an uncommitted git mv of a feature/step file — exactly a moment scenarios go dormant — is silently dropped. line[3:].split(" -> ")[-1].
| The failure mode this surfaces (#1603): a branch edits step modules, feature | ||
| files, or the BDD conftest, CI stays green, and nothing anywhere says that the | ||
| scenarios involved are auto-xfailed — dormant steps look like coverage. This | ||
| is the local, informational companion to ``run_all_tests.sh`` Konstantin |
There was a problem hiding this comment.
Convention everywhere else in scripts//src//tests/ is issue-number-only provenance — drop the personal name, #1603 carries it.
|
|
||
|
|
||
| class TestMapPaths: | ||
| def test_domain_step_module_maps_to_uc_test_module(self): |
There was a problem hiding this comment.
map_paths_to_modules() has four note-producing branches but only the conftest one is tested here. The untested ones include the tool's loudest signal — a .feature file no module binds ("every scenario in it is dormant") — plus the ucNNN-with-no-test-module and non-ucNNN domain-module notes; inverting the if binders: condition survives this suite. Add cases with e.g. tests/bdd/features/BR-UC-999-nonexistent.feature and a uc999_*.py path asserting modules == set() and the specific note text.
| dormant, _ = cds.classify(SAMPLE_OUTPUT) | ||
| assert dormant["No harness wired for None"].issuperset({"test_create_package_via_mcp"}) | ||
| # two transports, one scenario | ||
| count = sum(1 for s in dormant["No harness wired for None"] if s == "test_create_package_via_mcp") |
There was a problem hiding this comment.
classify() returns sets, so counting how many members equal a name can never exceed 1 — this asserts Python's set, not the collapsing logic. Assert the bucket's exact contents instead, e.g. assert dormant["No harness wired for None"] == {"test_create_package_via_mcp", "test_outline"} — that fails if a bracketed variant leaks in.
Summary
Implements the #1603 outcome: a local, informational check that tells you which BDD scenarios touched by your change never actually run, per the maintainer guidance in that thread (run it alongside
run_all_tests.shorruff, keep it out of CI so it can't become noise).Mechanism. The script maps the branch's BDD-relevant diff (step modules, feature files, test modules) to the test modules that bind them, then runs just those modules with
DATABASE_URLremoved from the environment. That split does all the work with no Docker and no Postgres, in seconds:integration_dbgate ("requires PostgreSQL DATABASE_URL") — they would run in CI;Output groups dormant scenarios by reason with transport params collapsed, and lists documented spec-gap xfails separately (those are intentional, shown for awareness only). Touched
conftest/tests/harness/generic-steps files affect every scenario, so the default run prints a note pointing at--allinstead of silently sweeping the world.Informational by design: exit code is always 0 unless
--strictis passed. Judgment stays with the developer; it just stops operating blind.Execution proof
The UC-026 number is real: all of that feature is currently dormant (no
T-UC-026branch in_detect_uc,MediaBuyDualEnvhas no consumers) while conftest comments still describe it as graduated — exactly the failure mode this check surfaces.Test plan
tests/unit/test_check_dormant_scenarios.pyruff check/ruff formatclean--strictflips to 1 on findings