smoke-optimiser 0.3.2__tar.gz → 0.3.4__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.
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/.beads/issues.jsonl +8 -1
- smoke_optimiser-0.3.4/.githooks/pre-commit +18 -0
- smoke_optimiser-0.3.4/.githooks/pre-merge-commit +18 -0
- smoke_optimiser-0.3.4/.githooks/pre-push +44 -0
- smoke_optimiser-0.3.4/.github/dependabot.yml +29 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/.github/workflows/ci.yml +4 -4
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/.github/workflows/release-please.yml +11 -11
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/.gitignore +1 -0
- smoke_optimiser-0.3.4/.release-please-manifest.json +3 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/AGENTS.md +0 -56
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/CHANGELOG.md +34 -0
- smoke_optimiser-0.3.4/Justfile +236 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/PKG-INFO +19 -3
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/README.md +17 -1
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/pyproject.toml +9 -25
- smoke_optimiser-0.3.4/ruff.toml +17 -0
- smoke_optimiser-0.3.4/ruff_shared.toml +100 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/scripts/branch_summary.py +0 -2
- smoke_optimiser-0.3.4/shared_scripts/analyse_ai_readiness.py +469 -0
- smoke_optimiser-0.3.4/smoke_optimiser/__init__.py +1 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/cli.py +15 -2
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/config.py +19 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/downwind/changes.py +13 -4
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/downwind/command.py +72 -21
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/downwind/rules.py +34 -4
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/environment.py +1 -1
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/plugin.py +108 -21
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/reports/downwind_suite.py +20 -12
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/reports/smoke_suite.py +2 -1
- smoke_optimiser-0.3.4/ty.toml +40 -0
- smoke_optimiser-0.3.2/.beads/hooks/post-checkout +0 -33
- smoke_optimiser-0.3.2/.beads/hooks/post-merge +0 -33
- smoke_optimiser-0.3.2/.beads/hooks/pre-commit +0 -42
- smoke_optimiser-0.3.2/.beads/hooks/pre-push +0 -33
- smoke_optimiser-0.3.2/.beads/hooks/prepare-commit-msg +0 -33
- smoke_optimiser-0.3.2/.githooks/pre-commit +0 -9
- smoke_optimiser-0.3.2/.release-please-manifest.json +0 -3
- smoke_optimiser-0.3.2/Justfile +0 -97
- smoke_optimiser-0.3.2/smoke_optimiser/__init__.py +0 -1
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/.agents/skills/beads/SKILL.md +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/.agents/skills/beads/agents/openai.yaml +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/.beads/.gitignore +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/.beads/README.md +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/.beads/config.yaml +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/.beads/interactions.jsonl +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/.beads/metadata.json +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/.codex/config.toml +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/.codex/hooks.json +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/LICENSE +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/release-please-config.json +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/__main__.py +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/downwind/__init__.py +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/downwind/blind_spots.py +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/downwind/environment_files.py +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/downwind/maps.py +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/optimiser/__init__.py +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/optimiser/filters.py +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/optimiser/greedy.py +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/optimiser/models.py +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/paths.py +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/profiler/__init__.py +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/profiler/coverage_db.py +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/profiler/import_tracer.py +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/profiler/models.py +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/profiler/persistence.py +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/profiler/read_tracer.py +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/profiler/runner.py +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/profiler/scope.py +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/py.typed +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/reports/__init__.py +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/reports/repro.py +0 -0
- {smoke_optimiser-0.3.2 → smoke_optimiser-0.3.4}/smoke_optimiser/reports/summary.py +0 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
{"_type":"issue","id":"so-n6b.61","title":"A changed test module is selected by its profiled node ids, not by what pytest now collects from it","description":"Downwind selects a changed test module's tests by the node ids the PROFILE\nrecorded for it. It should select the tests pytest now collects from it. The\ntwo differ whenever the change adds, removes or renames tests, which is the\nusual reason to edit a test module.\n\n`rules._tests_downwind_of` answers a changed test module with\n`maps.tests_in_module(path) | maps.tests_executing(path)`, both drawn from the\nprofile. `.downwind.json` carries only those exact node ids, and\n`plugin._filter_to_downwind_suite` keeps collected items whose nodeid is in\nthat set. So:\n\n- A TEST ADDED to an existing, profiled test module is never selected. It is\n collected and then deselected, which is a silent under-selection: the one\n test the developer just wrote does not run, and nothing says so.\n- A test RENAMED in place is not selected under its new name, and its old\n name is reported missing.\n- A test module DELETED by the diff has every test it held selected. None of\n them can be collected, so each is reported as \"downwind test not found in\n collection (removed or renamed since profiling)\". Under a project's\n `filterwarnings = [\"error\"]` with xdist, that warning is raised inside\n `pytest_collection_modifyitems` on the workers, and the run ends in an xdist\n INTERNALERROR (AssertionError in xdist/dsession.py, then KeyError from\n `_active_nodes.remove`) with no tests run. Seen in the field on 0.3.2 after\n `git rm api/tests/test_dev_login.py`. The workaround was\n `smoke-optimiser smoke --profile-only`.\n\nDeletion is not staleness. The diff already records the file as deleted, so\nthe selection should contain nothing for it. It is the change itself, and no\nwarning is due. A path that git reports both as DELETED and as ADDED (so-n6b.22's\n`git rm --cached` shape) still exists in the worktree and is not a deletion\nfor this purpose.\n\nWHERE: smoke_optimiser/downwind/rules.py (`_tests_downwind_of`, `downwind_of`,\nwhose docstring states that no rule reads ChangeKind),\nsmoke_optimiser/reports/downwind_suite.py (the schema holds node ids only),\nand smoke_optimiser/plugin.py (`_filter_to_downwind_suite`).\n\nDESIGN: a changed test module is selected as a FILE. `DownwindSelection`\ncarries `test_modules` (spelled as the path part of a node id, i.e.\nrootdir-relative) beside `node_ids`. No node id defined in a selected module\nis listed, because the module covers it. `.downwind.json` is version 3, with a\n`test_modules` list, and the plugin keeps any item whose node id's file part is\nlisted. Only a listed node id can be reported missing. A selected module that\ncollects nothing is the change itself. When the only selection is modules that\nno longer exist, the command exits 0 without starting pytest, since pytest\nwould collect nothing and exit 5. Deletion needs no rule of its own, and no\nrule reads ChangeKind.\n\nALTERNATIVES CONSIDERED, do not re-propose: keep selecting profiled node ids\nand subtract those defined in deleted modules. That fixes the reported crash\nand leaves a test added to an existing module silently unselected.\n","acceptance_criteria":"Deleting a profiled test module selects nothing for it, and the run emits no missing-test warning. A test added to, or renamed in, a profiled test module is selected. An e2e test in a project running filterwarnings=error reproduces the reported deletion and passes.","status":"closed","priority":1,"issue_type":"bug","assignee":"lawther","owner":"mike.lawther@gmail.com","created_at":"2026-09-30T00:16:17Z","created_by":"lawther","updated_at":"2026-09-30T00:24:04Z","started_at":"2026-09-30T00:18:10Z","closed_at":"2026-09-30T00:24:04Z","close_reason":"Changed test modules are selected as files (DownwindSelection.test_modules, .downwind.json v3), so a deleted module collects nothing and a new or renamed test runs. The reported crash no longer reproduces: the e2e fixture now runs filterwarnings=error. xdist and the missing-test signal for genuinely stale ids are so-n6b.62.","labels":["downwind-mvp","model:opus"],"dependencies":[{"issue_id":"so-n6b.61","depends_on_id":"so-n6b","type":"parent-child","created_at":"2026-09-30T00:16:17Z","created_by":"lawther","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
|
1
2
|
{"_type":"issue","id":"so-n6b.54","title":"[bug] A Python file no test can reach is a blind spot that re-profiling can never clear","description":"A Python file that the profiling run cannot observe at all is an UNKNOWN_PATH blind spot\nthat no amount of re-profiling can clear, so every subsequent run pays for the full suite\nand the fallback's promise to \"rebuild the map it fell back from\" is false for it.\n\nThe maps know a Python file if coverage measured it, the import graph mentions it, the\ntracer recorded it as an unattributed module, or a recorded node id is defined in it. A\nfile that pytest does not collect, nothing imports, and coverage's roots do not cover\nsatisfies none of those. It is not stale data: it is measurably inert, and the profile has\nno way to say so.\n\nObserved in ../enphase_curtailer with an untracked, empty `tests/foo.py`. It matches\nneither `test_*.py` nor `*_test.py`, so pytest collects nothing from it; nothing imports\nit; `[tool.coverage.run] source = app` excludes it. The instrumented full-suite fallback\nruns, rewrites the profile, and the very next run reports the same blind spot.\n\nThe read map already has the denominator that would answer this -- `present_files` -- but\nit is deliberately restricted to tracked NON-Python files, and the Python rules have no\nequivalent. With one, a Python file that was present while the suite ran and still appears\nin no map is inert by measurement rather than by assumption, exactly as an unread data file\nis, and can select nothing instead of everything.\n\nTwo things the denominator would have to cover that it does not today:\n\n* Python files, which `_present_files` filters out.\n* Untracked-but-not-ignored files, which `tracked_files` never lists. The changed set\n includes untracked files, so a tracked-only denominator leaves precisely the reported\n case looping forever.\n\nThe bound is out-of-process use: a `.py` file invoked by a test through `subprocess` is\ninvisible to coverage and to the import tracer alike, and would go from a full suite to\nselecting nothing. That is the trade this issue has to settle, and it is why the change is\nnot simply widening `_present_files`.\n\nWhatever the selection rule ends up being, the reporting must stop claiming self-repair for\na blind spot re-profiling cannot clear.\n","status":"closed","priority":1,"issue_type":"bug","owner":"mike.lawther@gmail.com","created_at":"2026-09-09T23:07:50Z","created_by":"lawther","updated_at":"2026-09-09T23:18:20Z","closed_at":"2026-09-09T23:18:20Z","close_reason":"Fixed by giving the Python rules the denominator the read map already had.\n\npresent_files is now every non-ignored file in the working tree when the run started -- Python\nincluded, untracked included -- rather than the tracked non-Python files. Both halves of the\nwidening were needed: the Python half so a module can be measured as unloaded, the untracked half\nbecause the changed set reports untracked files, which is precisely the reported case.\n\nOn that denominator, a changed Python path the maps deny knowing splits in two. Present means every\nrecorder watched it and none saw it take part, so it is inert by measurement and answers with the\ntests that LISTED its directory -- the one relation a file nothing imports can still appear in, and\nwhat keeps a tree-walking architecture test attached to it. Absent means it arrived after the run\nand still refuses with UNKNOWN_PATH. Expiry asks the same question, so an inert file in the tree no\nlonger expires the profile either; without that half the loop would simply have moved from the\nchanged-file rule to the expiry rule.\n\nVerified against the reporting project: with the denominator a re-profile would now write, an\nuntracked tests/foo.py selects nothing instead of the full suite. The under-selection this admits --\na script a test invokes through subprocess, invisible to every in-process recorder -- is so-n6b.55.","dependencies":[{"issue_id":"so-n6b.54","depends_on_id":"so-n6b","type":"parent-child","created_at":"2026-09-10T09:07:50Z","created_by":"lawther","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
|
2
3
|
{"_type":"issue","id":"so-n6b.50","title":"A coverage root puts files in scope that coverage.py's own package-walk would never measure","description":"scope._in_scope treats every tracked .py file under a coverage root as a file a regenerated profile would know about. coverage.py does not agree: its own file discovery (coverage.inorout.find_python_files, run against every one of its source directories, not only a bare --cov of the whole repository) only descends into a subdirectory that holds an __init__.py, unless the project has set include_namespace_packages. A directory of plain scripts with no __init__.py is invisible to coverage whatever the root says, so counting it as in scope pins the profile to permanently expired: the files are in scope, absent from the maps, and no amount of regenerating can add them.\n\nOBSERVED IN enphase_curtailer, 2026-09-09. A profile recorded under coverage_roots [\".\"] reported four blind spots that regenerating could not clear -- every file under .claude/hooks, which has no __init__.py.\n\nFIX: ProfileScope carries include_namespace_packages, read by resolve_scope from the actual Coverage object's own setting inside the profiled pytest process (not assumed). _in_scope mirrors coverage's package-walk rule for every coverage root, not only a whole-repository one: a file counts only if every directory strictly between it and its root holds an __init__.py, unless include_namespace_packages is set. Package-ness is derived from the same tracked-file list files_in_scope is already given (any __init__.py in that list marks its directory a package) rather than a fresh filesystem walk, so both functions stay pure and the check is correct for files added after the last profiling run -- which a literal replay of coverage's own measured-file set could not be.\n\nPROFILE_SCHEMA_VERSION bumped 4 -\u003e 5 for the new field; an old profile is rejected and reprofiled rather than migrated, per this project's existing schema-version convention.","acceptance_criteria":"A tracked .py file that coverage.py would never walk (no __init__.py in an intermediate directory, and include_namespace_packages not set) is not in scope, so it cannot expire the profile. This holds for any coverage root, not only a whole-repository one. A project that has set include_namespace_packages is unaffected. The rule is derived from the tracked-file list already at hand rather than a fresh filesystem walk or a replayed snapshot, so it stays correct for files added after the last profiling run.","status":"closed","priority":1,"issue_type":"bug","owner":"mike.lawther@gmail.com","created_at":"2026-09-09T04:46:24Z","created_by":"lawther","updated_at":"2026-09-10T00:06:08Z","closed_at":"2026-09-10T00:06:08Z","close_reason":"Implemented per user decisions: (1) generalised to every coverage root, matching coverage.py's real behaviour rather than special-casing '.'; (2) rather than storing/replaying profile.measured_files (which would be stale for files created after the last profiling run), added ProfileScope.include_namespace_packages and derived package-ness live from __init__.py presence in the same tracked-file list files_in_scope already receives, keeping the module's pure-function design; (3) include_namespace_packages is read from the actual Coverage object inside the profiled pytest process rather than assumed False.\n\nChanged: smoke_optimiser/profiler/scope.py (_package_directories, _reachable_by_coverage_walk, _in_scope, ProfileScope, resolve_scope), smoke_optimiser/profiler/runner.py (pytest_unconfigure hook reads cov_controller.cov.config.include_namespace_packages; _merge_scopes), smoke_optimiser/profiler/models.py (ProfileScopeModel field, PROFILE_SCHEMA_VERSION 4-\u003e5). Tests added/updated in tests/test_scope.py and tests/test_runner.py; fixture dicts in tests/conftest.py, test_cli.py, test_downwind_command.py, test_profiler_models.py, test_runner.py updated for the new required field. Full suite (381 tests), ruff, and ty all pass.","dependencies":[{"issue_id":"so-n6b.50","depends_on_id":"so-n6b","type":"parent-child","created_at":"2026-09-09T14:46:23Z","created_by":"lawther","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
|
3
4
|
{"_type":"issue","id":"so-n6b.29","title":"A change reaching tests only through conftest.py selects nothing when coverage never measured the conftest","description":"so-017's design says a conftest that imports a changed helper module 'is covered by so-aeo's graph, since conftest.py is itself an importer'. That holds for the CLOSURE but not for the answer. The closure reaches the conftest and stops: no test module imports a conftest -- pytest loads it -- so there is nowhere further to walk. Turning that terminal file into node ids then needs one of the two relations in smoke_optimiser/downwind/rules.py:_tests_downwind_of, and under a profile that did not measure the test tree BOTH are empty:\n\n* tests_in_module(conftest.py) is empty by definition. No test is defined in a conftest.\n* tests_executing(conftest.py) is empty unless coverage measured it. Measured 2026-09-07 across the fleet: casey_ai/api and enphase_curtailer profile with --cov=. and DO measure it; fomo (--cov=fomo) and smoke-optimiser (--cov=smoke_optimiser) do NOT.\n\nSo on fomo or smoke-optimiser, a changed declarations file whose only route to the suite is a conftest import selects ZERO tests, and downwind_of returns a confident DownwindSelection saying so. Silent under-selection, which is the one failure the epic exists to prevent.\n\nREPRODUCTION SHAPE: src/settings.py holds module-level constants, tests/conftest.py imports it to build a fixture, no test module imports it and no test executes it (its code runs at import). Change src/settings.py: tests_executing(src/settings.py) is empty because it ran under no test context, the closure is {tests/conftest.py}, and both relations return nothing for that.\n\nThe same hole exists for any file the closure terminates at that pytest loaded rather than imported -- a pytest11 plugin belonging to the project, a module loaded through machinery the tracer cannot attribute -- but conftest.py is the case every project has.\n\nWHY NOT JUST WIDEN --cov: measuring tests/ puts every branch of the test code into total_branches, the denominator smoke mode optimises against, from the one shared profile. That repairs downwind by corrupting smoke.\n\nNote the interaction with so-jr5.2's rules: this is NOT the changed-conftest rule. That one fires when the conftest ITSELF changed and refuses. Here the conftest is untouched and merely sits on the path, so no rule fires at all.","design":"Options worth weighing before implementing, none of them settled:\n\n* Treat a closure that terminates at a file with no tests and no coverage as a blind spot, naming that file. Safe, loud, and cheap, but a project profiling with --cov=pkg would refuse for any conftest-mediated change, which may be common enough to hurt.\n* Fall back to conftest directory scope, as so-017 does for a changed conftest: every test at or below the terminal conftest's directory. Over-selects in the same shape so-017 already accepts, and reuses its machinery.\n* Record conftest loads in the import tracer as edges from the test modules pytest applied them to, making the graph answer directly. Most precise and most work, and it puts the fix in the profiler rather than the rules.\n\nWhichever is chosen, so-017's design note claiming the graph already covers this needs correcting in the same change.","acceptance_criteria":"A changed file whose only route to the suite is a conftest import does not return a selection that omits the tests using that conftest's fixtures, under a profile whose coverage target excluded the test tree. Covered by a unit test over a hand-built profile with measured_files holding the source only, since that is the configuration where it bites.","status":"closed","priority":1,"issue_type":"bug","assignee":"lawther","owner":"mike.lawther@gmail.com","created_at":"2026-09-07T13:11:51Z","created_by":"lawther","updated_at":"2026-09-07T22:36:22Z","started_at":"2026-09-07T22:32:39Z","closed_at":"2026-09-07T22:36:22Z","close_reason":"Implemented as the dead-end rule in downwind/rules.py. _tests_downwind_of now walks the closure asking, per reached file, whether it yielded tests by either relation; a file that yielded none AND is unattributed is terminal, since nothing imports it so there is nowhere further to walk. A terminal conftest.py falls back to pytest's directory scope via the new DownwindMaps.tests_at_or_below(); anything else terminal becomes a new TERMINAL_DEAD_END blind spot naming the file.\n\nChose option 2 for conftests plus option 1 for everything else, over the tracer change: pytest's real conftest applicability IS directory scope except for pytest_plugins, which so-017 already puts out of scope, so option 3 would have bought only that case at the cost of writing non-import edges into ImportGraph. The fallback fires only where BOTH relations answer empty, so a --cov=. project keeps the precise fixture-mediated answer coverage gives it.\n\nscope._under_root became public under_root so the rules and scope membership share one 'at or below' predicate. so-017 was rewritten to be the changed-conftest caller of tests_at_or_below rather than to build its own path matching, and now depends on this. Filed so-n6b.32 for the untested risk that a barren tests/__init__.py becomes a dead end.\n\nso-017's design note claiming the graph already covered this had been corrected in an earlier session, so no further edit was needed there.","labels":["downwind-mvp","model:opus"],"dependencies":[{"issue_id":"so-n6b.29","depends_on_id":"so-n6b","type":"parent-child","created_at":"2026-09-07T23:11:50Z","created_by":"lawther","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
|
@@ -18,6 +19,9 @@
|
|
|
18
19
|
{"_type":"issue","id":"so-pc5","title":"Coverage recorded during fixture setup aborts the entire profiling run","description":"REGRESSION introduced by so-mr0. A single test whose fixture executes measured source and then errors kills the whole profiling run.\n\nREPRODUCED 2026-09-06 on a 2-test throwaway project:\n\n @pytest.fixture\n def broken():\n classify(-1) # exercises measured source during setup\n raise RuntimeError('boom')\n\n def test_setup_error(broken): ...\n\n $ smoke-optimiser --src app\n 1 passed, 1 error in 0.02s\n ❌ Error: 1 coverage context matches no test that pytest collected in this run:\n test_edge.py::test_setup_error\n\nWHY. coverage records a context per test PHASE, so the fixture's coverage lands\nunder 'test_edge.py::test_setup_error|setup'. But PYTEST_HOOK_CODE in\nrunner.py only records a report when report.when == 'call':\n\n if report.when == 'call':\n item.config._smoke_outcomes[item.nodeid] = {...}\n\nA test that errors during setup never reaches its call phase, so it is absent\nfrom test_durations. coverage_db._verify_contexts then finds a context matching\nno collected test and raises, by design -- that fail-loudly check is correct and\nshould stay. The bug is that the test IS real and WAS collected; the hook simply\ndid not record it.\n\nTWO THINGS ARE WRONG.\n1. One errored fixture takes down a whole profiling run of any size.\n2. The error message misdiagnoses it, telling the user the coverage database is\n stale and to 'remove it and profile again', which will fail identically.\n\nNOT A PROBLEM: skipped tests, and setup errors in fixtures that touch no\nmeasured source, both produce no context row at all (verified), so they do not\ntrigger this.\n\nLIKELY FIX. Record setup/teardown phase reports in the hook so such tests appear\nwith passed=False. Failing tests are already hard-excluded by the optimiser\n(PRD 5.5), so an errored test would be excluded by the existing path rather than\nbeing a fatal ingest error. Needs a decision on duration (setup time only? zero?)\nand on whether a teardown failure after a passing call should flip passed.\n\nWHERE: smoke_optimiser/profiler/runner.py PYTEST_HOOK_CODE; the check is\nsmoke_optimiser/profiler/coverage_db.py::_verify_contexts.\n\nACCEPTANCE: a suite containing a fixture that errors after touching measured\nsource profiles successfully, and tests/test_e2e.py covers it -- the current e2e\nproject has only cleanly-passing tests and would not catch this.","status":"closed","priority":1,"issue_type":"bug","assignee":"lawther","owner":"mike.lawther@gmail.com","created_at":"2026-09-06T12:36:29Z","created_by":"lawther","updated_at":"2026-09-06T12:44:28Z","started_at":"2026-09-06T12:44:27Z","closed_at":"2026-09-06T12:44:28Z","close_reason":"Fixed by recording failed setup/teardown-phase reports in the pytest hook, so setup-erroring tests get an outcome entry instead of leaving an orphaned coverage context. Added regression coverage in tests/test_e2e.py.","labels":["model:opus"],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
|
19
20
|
{"_type":"issue","id":"so-jr5","title":"Select tests from the map: turn a git diff and a profile into a test set","description":"smoke-optimiser's existing smoke mode selects a STATIC subset -- highest coverage\nper second, the same tests whatever you changed. That is a coverage bet: it cannot\npromise the coverage it skips, so it cannot gate a commit.\n\nThis is the second, honest selection mode. Given the files changed in the working\ntree, run every test the profile says could be affected, and run the full suite\nwherever the map cannot answer. The promise is categorical and literally statable,\nwhich is the whole point: every test that has ever executed a line you touched,\nplus every test that transitively imports a file you touched.\n\nWHY IT IS POSSIBLE. The profiling data already IS a test-impact map.\nProfilingOutcome.files_covered gives file -\u003e tests directly, and ImportGraph.edges\ngives the dependency relation that answers for a file which only ever runs at\nimport time -- a constants module, a Pydantic model, a package re-export -- which\ncoverage records under no test context at all.\n\nMEASURED on enphase_curtailer: 2600 tests, 190 files in the map, MEDIAN 37 TESTS\nPER FILE. Touch one source file and you run ~37 of 2600. The full suite is 55s\nwarm on a 10-core Mac and 7m32s on GitHub CI's 4 cores.\n\nGRANULARITY: FILE-LEVEL. Deliberately not line-level. Line numbers shift the moment\nyou insert a line above, so a line-level map silently mismatches after almost any\nedit. File-level is immune to line shifts, reformatting and refactors within a\nfile. so-aif is the deferred line-level follow-up.\n\nTHE MAP MUST NEVER GUESS. Every input is either answered from recorded fact or\ndeclared unanswerable, and unanswerable means the full suite with a stated reason.\nThere is no best-effort middle: the moment it selects a subset it cannot justify it\nis test-fast again and cannot gate a commit.\n\nSELF-HEALING. A full-suite fallback runs under profiling instrumentation and\nrewrites the profile, so the run that pays for the fallback is also the run that\nrepairs the map. Instrumentation costs +11% (measured on enphase: 61s -\u003e 68s), and\nit means staleness costs one full run rather than accumulating until someone\nremembers to regenerate by hand. Nothing regenerates in the background, and no\nregeneration the developer did not ask for ever blocks a commit.\n\nDOES NOT COVER, and must not pretend to: npm/vitest suites, tsc typechecks, codegen\ndrift checks, Rust. In casey_ai the heavy precommit item is a Docker integration\nsuite; only the Python half is in scope here.\n\nThis issue is the shared spec. Its children carry the work.\n","design":"THE INPUT is the union of staged, unstaged and untracked changes, because pytest\nreads the worktree rather than the index: selecting on the index alone would test\nsomething other than what is about to run. Renames are detected (-M) and resolved\nto the OLD path, the only path the profile knows. A deleted file is looked up in\nthe map exactly like any other change -- the tests that executed it are the ones\nmost likely to break -- so nothing may gate on whether a path still exists on disk.\n\nPATHS ARE REPO-RELATIVE throughout, from `git rev-parse --show-toplevel`, so the\nprofile and git agree by construction rather than by coincidence of the directory\nsmoke-optimiser was invoked from.\n\nSELECTION for a changed file is the union of:\n - tests whose files_covered contains it (it executed under that test), and\n - tests whose module transitively imports it, via the inverted ImportGraph.\n\nUNANSWERABLE -- run the full suite, and say which input caused it:\n - a changed path with no entry in the map (new code, which is exactly when tests\n matter most);\n - a changed path that is not Python at all (YAML, templates, assets, lockfiles,\n workflows). The map is a Python-execution map, so absence carries no\n information about a data file: it means \"we never looked\", not \"nothing reads\n this\". so-n6b.4 makes these answerable for real;\n - a changed file listed in ImportGraph.unattributed_modules, which means the\n graph is blind to who depends on it;\n - a profile whose map has never seen a file that exists now (see EXPIRY);\n - an import graph carrying failures we could not localise (see TRUST).\n\nA CHANGED conftest.py selects every test at or below its directory, mirroring\npytest's own scoping rule, so a root conftest.py selects the whole suite and a\nleaf one does not. A changed test file selects the tests in that file; a test file\nthat other tests import from is covered by the import graph like any other module.\n\nEXPIRY is divergence, not age. A profile is expired once a file exists that its map\nhas never seen, because from that moment the map can answer confidently and wrongly:\nthe tests added since capture are absent from every answer it gives. Divergence is\na cliff rather than a slope, and the self-healing fallback is what makes the cliff\naffordable -- hitting it costs one instrumented full run, not a full run on every\ncommit until someone regenerates by hand.\n\nTRUST. The import tracer swallows and counts edge-resolution failures, because\nraising inside __import__ would kill the suite being profiled. Every such failure\nis our defect, not the project's -- it is our name arithmetic failing on legal\nPython -- so the user must not be made to pay a full suite for it. The failure loses\nthe IMPORTED name while the IMPORTER is still in hand, so the doubt localises to\nthe importer: selection always additionally runs every test whose closure reaches a\nmodule with a failed edge. Failures where even the importer could not be recovered\nare counted separately and do force the full suite, since there is nothing to\nlocalise them to.\n\nOUTPUT is its own file with its own schema and its own pytest flag. The categorical\npromise has no time cap, no coverage target and no efficiency ranking, so it shares\nnothing with SmokeSuiteFile but the list of node ids.\n\nALTERNATIVES CONSIDERED, and rejected -- do not re-propose without new evidence:\n\n - Staged-only input (--cached). Rejected: pytest runs against the worktree, so an\n unstaged edit changes what the selected tests execute.\n - Treating a rename's new path as unknown, or forgoing -M so it appears as an\n add plus a delete. Both make every pure move cost a full suite.\n - A user-configurable ignore list for non-Python paths, so a README edit does not\n cost a full run. Rejected: \"this path is irrelevant\" is a claim that should be\n MEASURED, not asserted -- an asserted list is silent under-selection the moment\n it is wrong. so-n6b.3 measures it instead.\n - Wall-clock expiry with a configured threshold. Rejected: it is a heuristic that\n reads a day of heavy refactoring as fresh and an untouched fortnight as stale,\n while divergence measures the thing that actually breaks the map.\n - Refusing any profile with non-zero resolution_errors. Rejected: it punishes the\n user durably for our bug, since the failures are deterministic and persist\n until we fix the tracer.\n - Trusting a degraded graph with only a warning. Rejected: that is the silent\n under-selection this whole line of work exists to prevent.\n - Printing node ids for the caller to pass to pytest, with no file. Rejected: it\n puts a full suite's worth of argv on the command line at 2600 tests, and the\n fallback must run through our entry point for the self-healing rewrite to\n happen at all.\n - Regenerating the profile in a detached background process. Rejected: a full\n suite firing at an unpredictable moment is worse than the instrumented\n fallback, which pays the cost exactly when the developer was already paying it.\n","acceptance_criteria":"Changing a source file runs exactly the tests that executed it plus the tests whose modules transitively import it, and no others. Changing a file absent from the map, a non-Python file, an unattributed module, or a file in a repository whose map has never seen some existing file, runs the full suite and names the input that caused it. Changing tests/sub/conftest.py runs every test under tests/sub/ and nothing outside it. A deleted file still selects the tests that executed it. A full-suite fallback rewrites the profile, so an immediately repeated run selects a subset instead of falling back again.","notes":"WHAT THE PROFILE PROVIDES, and what each part is for:\n\n - ProfilingOutcome.files_covered: every measured file a test executed, branchless\n files included, so a straight-line constants module is visible where\n branches_covered cannot see it.\n - ProfilingData.measured_files: everything coverage measured, executed or not, so\n \"coverage never saw this file\" is distinguishable from \"no test ran it\".\n - ImportGraph.edges: (importer, imported) as project-relative paths. Invert and\n take the transitive closure for every test module depending on a changed file.\n - ImportGraph.unattributed_modules: project files loaded that nothing was seen to\n import -- test modules, conftest.py, and any pytest11 plugin belonging to the\n project, since pytest loads entry-point plugins before any hook can watch. A\n change to one means the graph CANNOT ANSWER.\n - ImportGraph.resolution_errors / error_samples / failed_importers: edge recording\n that failed and was swallowed, because raising inside __import__ would kill the\n run being profiled.\n\nMeasured on smoke-optimiser itself: 91 edges, 17 unattributed of ~35 project files.\n\nWHAT AN IMPORT CLOSURE SELECTS, as a percentage of the suite, per source file, from\na --collect-only prototype (2026-09-06):\n\n repo tests files p25 median p75 max mean\n smoke-optimiser 81 19 18.5% 27.2% 48.1% 87.7% 33.3%\n casey_ai/api 1389 143 20.5% 47.7% 52.1% 96.5% 39.4%\n enphase_curtailer 2600 271 7.3% 12.8% 21.9% 93.2% 17.5%\n\nThe decisive case is casey_ai: db/models/common.py reaches 96.5% of the suite\n(genuinely global -- running everything IS right), while db/models/interval.py and\ndb/models/task.py reach 3 tests each. Same directory, same kind of declaration file.\nThe graph tells them apart; a file-glob heuristic cannot.\n\nCAVEAT: measured with --collect-only, so it misses imports made lazily inside test\nbodies. Real closures may be marginally wider.\nMVP, labelled touched-files-mvp: so-jr5.1, so-jr5.2, so-7hr, so-uom, so-jr5.4,\nso-n6b.1, plus so-2os and so-qex. That is the whole path from a git diff to a pytest\nrun, with expiry included so the answer is trustworthy rather than merely fast.\n\nIt ships with three conservative stand-ins, each of which over-selects or errors\nrather than under-selecting, and each of which is replaced by a named issue:\n\n - any changed conftest.py is unanswerable -\u003e so-017\n - any non-zero resolution_errors distrusts the graph -\u003e so-n6b.2\n - the fallback runs plain pytest, regenerate by hand -\u003e so-jr5.3\n - the command refuses to run outside the repository root -\u003e so-746\n\nSequenced this way so the feature exists end to end early and gets exercised against\nreal edits, rather than being assembled correct-on-paper and met all at once.","status":"closed","priority":1,"issue_type":"epic","owner":"mike.lawther@gmail.com","created_at":"2026-09-06T06:44:01Z","created_by":"lawther","updated_at":"2026-09-07T03:09:42Z","closed_at":"2026-09-07T03:09:42Z","close_reason":"Superseded by so-n6b, which now carries this spec verbatim as its description, design and notes. Its children moved up to so-n6b: grouping by hierarchy was not carrying information, and the axis that matters is the touched-files-mvp label.","labels":["model:opus"],"dependencies":[{"issue_id":"so-jr5","depends_on_id":"so-aeo","type":"blocks","created_at":"2026-09-06T23:09:33Z","created_by":"lawther","metadata":"{}"},{"issue_id":"so-jr5","depends_on_id":"so-d8v","type":"blocks","created_at":"2026-09-06T22:37:26Z","created_by":"lawther","metadata":"{}"},{"issue_id":"so-jr5","depends_on_id":"so-mr0","type":"blocks","created_at":"2026-09-06T16:44:19Z","created_by":"lawther","metadata":"{}"},{"issue_id":"so-jr5","depends_on_id":"so-n6b","type":"parent-child","created_at":"2026-09-07T12:04:45Z","created_by":"lawther","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0}
|
|
20
21
|
{"_type":"issue","id":"so-mr0","title":"Replace the 1.2 GB JSON coverage ingest with a direct read of coverage.py's SQLite database","description":"smoke-optimiser cannot profile a medium suite today. Measured against\nenphase_curtailer (2600 tests, 230 files) on 2026-09-06:\n\n coverage json --show-contexts 1.2 GB\n the same data as .coverage 29 MB (40x smaller)\n the same report WITHOUT contexts 1.9 MB in 1.3s\n\nparser.py:193 does a single json.load() of that 1.2 GB file, which needs many\nGB of resident Python objects. The contexts are 99.8% of the file.\n\nWHAT TO BUILD. Read coverage.py's SQLite database directly instead. One query\njoining arc -\u003e context -\u003e file extracted the full test-\u003earc map in 0.43s and\nyielded exactly 2600 tests, matching the collected count:\n\n select context.context, file.path, arc.fromno, arc.tono\n from arc join context on arc.context_id = context.id\n join file on arc.file_id = file.id\n where context.context != ''\n\nThe public CoverageData API is NOT viable: set_query_contexts()/arcs() over 40\ncontexts took 6.51s, extrapolating to 13.4 minutes for this suite. Raw SQL is\n~1900x faster. That is why this reads an internal schema.\n\nTWO CORRECTNESS BUGS THIS FIXES.\n\n1. parser.py:139 infers branch coverage: a test covers u-\u003ev if it executed line\n u and line v. That over-attributes -- both endpoints can execute without that\n arc ever being taken. The arc table holds real arcs, so the inference and its\n docstring caveat both go away.\n2. TestResolver (parser.py:37-77) matches coverage contexts to node ids by\n endswith/startswith and by bare function name. Contexts are already exact node\n ids with a phase suffix ('tests/test_x.py::test_y|run'), so rsplit('|', 1)[0]\n is exact. The fuzzy matcher can mis-attribute coverage between same-named\n tests in different files; delete it.\n\nSCHEMA COUPLING. The DB format is not a public API. Read the coverage_schema\ntable (version 7 as of coverage 7.13) and compare against a verified set. On a\nmismatch, FAIL LOUDLY -- silently mis-reading would silently under-select tests,\nwhich is the worst failure this tool can have. The error must name the schema\nversion found, the versions supported, the installed coverage.py version, and\nsay plainly that smoke-optimiser itself needs updating to support the new schema,\nwith the file to edit. Do not degrade quietly.\n\nTHE DENOMINATOR. The arc table records only arcs that were EXECUTED, so a\nnever-taken branch has no row. enphase has 3198 branches, 406 uncovered; counting\nfrom SQLite alone reports 100% instead of 88%. --target-cov needs the true total,\nso keep a SECOND context-free `coverage json` purely for totals -- 1.9 MB and 1.3s,\nbecause the blow-up was entirely contexts. total_branches keeps its current\nmeaning (executed + missing).\n\nEverything moves to the SQLite ingest, not just the new selection feature, so the\ntool has one ingest path rather than two. Retire parse_coverage_json, TestResolver,\nthe CoverageReport/CoverageFile models, the [json] show_contexts rcfile setting,\nand the contexts export step in runner.py:159-176.\n\nAlso handle a suite profiled WITHOUT branch mode: that data lands in line_bits\n(a bitmap blob), not arc. Either support it or refuse with a clear message.","notes":"IMPLEMENTED with two departures from the original description, both agreed with the user.\n\n1. NO SECOND coverage json. The description called for a context-free 'coverage json'\npurely for the denominator. Not needed: the denominator is computed in-process from\ncoverage's own per-file analysis (FileReporter.arcs() filtered to branch lines), in the\nsame pass that builds the arc translation map. Measured 0.03s on this repo. Verified\nbyte-identical to 'coverage json' on both sets (denominator 234/234, executed 153/153),\nand the 222-branch total matches pytest-cov's own report exactly. One ingest, no\nsubprocess, no JSON, no absolute-vs-relative path reconciliation between two sources.\n\n2. RAW ARCS NEED TRANSLATING -- not mentioned in the original description, and the\nlandmine in it. The arc table records the interpreter's actual jump targets; coverage\nreports branches in an AST-derived vocabulary. A jump into the middle of a multi-line\nstatement is reported against the statement's first line, so raw 3-\u003e6 is reported as\n3-\u003e5. Intersecting raw arcs against reported branches without translation silently\ndropped ~4% of this repo's executed branches -- exactly the silent under-selection the\nissue exists to prevent. Fixed by running each file's distinct raw arcs through\nFileReporter.translate_arcs ONCE, building a raw-\u003ebranch lookup, then a dict hit per\narc. This is what sidesteps the 13.4-minute per-context path.\n\n3. UNATTRIBUTABLE BRANCHES. Module-level code runs at import under the empty context,\nso no test can ever claim it (4 branches on this repo). The old inference-based parser\nfalsely attributed these to whichever test touched the lines. They are now kept in\ntotal_branches (so it keeps agreeing with 'coverage report') and surfaced as a new\nProfilingData.unattributable_branches field, with the summary reporting the attainable\nceiling: 'Ceiling: 98.2% max attainable'.\n\nFAIL-LOUDLY paths, all tested: unsupported coverage_schema version (names version found,\nsupported set, installed coverage.py, and the file to edit), line-only data (has_arcs=0),\na context matching no collected test, a source file no longer readable, a corrupt or\nnon-coverage database, an empty coverage_schema table.\n\nFOR so-jr5: branch ids are relative to project_root (POSIX), so they match git diff paths\ndirectly. Note the file-\u003etest map only covers files that contain branches.","status":"closed","priority":1,"issue_type":"task","assignee":"lawther","owner":"mike.lawther@gmail.com","created_at":"2026-09-06T06:43:07Z","created_by":"lawther","updated_at":"2026-09-06T07:10:15Z","started_at":"2026-09-06T07:00:07Z","closed_at":"2026-09-06T07:10:15Z","close_reason":"Implemented: SQLite ingest with arc translation, in-process denominator, fail-loudly schema and context checks. Verified byte-identical to coverage json; 76 tests pass; ruff/ty clean.","labels":["model:opus"],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
|
22
|
+
{"_type":"issue","id":"so-gai","title":"Release the so-n6b.62 fix so consumers stop hitting the xdist INTERNALERROR","description":"The fix for so-n6b.62 (a missing selected test is reported in pytest's terminal summary rather than through warnings.warn) is on main but in no release: the latest tag is v0.3.2, cut before the fix. Consumers install from PyPI, so they still run the old plugin. casey_ai pins smoke-optimiser\u003e=0.3.2, and committing a change that deletes any profiled test module there fails precommit with an xdist INTERNALERROR, because its filterwarnings = [\"error\"] turns the old warning into an exception. The failing message reads \"downwind test not found in collection (removed or renamed since profiling)\".\n\nCut a release that contains the fix (release-please owns versioning, see so-vm4), then raise the floor in each consuming repo's pyproject so the older plugin cannot be resolved. Publishing is the maintainer's action; this issue tracks that it happens and that consumers move.\n\nUntil then a consumer that deletes profiled tests must move its .smoke_profiling_data.json aside first so downwind rebuilds it.\n","acceptance_criteria":"A tagged release includes the terminal-summary reporting of missing selected tests. casey_ai's smoke-optimiser floor is raised to that release, and deleting a profiled test module there commits without moving the profile aside.","status":"open","priority":2,"issue_type":"task","owner":"mike.lawther@gmail.com","created_at":"2026-09-30T03:58:53Z","created_by":"lawther","updated_at":"2026-09-30T03:58:53Z","labels":["model:sonnet"],"dependencies":[{"issue_id":"so-gai","depends_on_id":"so-n6b.62","type":"relates-to","created_at":"2026-09-30T03:58:54Z","created_by":"lawther","metadata":"{}"}],"comments":[{"id":"97c0a3a3-561d-56a2-a6b5-09ca2bc18f1f","issue_id":"so-gai","author":"lawther","text":"Fix commit 1c1f9ae (2026-09-30); latest tag v0.3.2 (2026-09-23) predates it. Found when casey_ai's precommit failed after deleting api/tests/test_check_bead_model_hook.py.","created_at":"2026-09-30T03:58:54Z"}],"dependency_count":0,"dependent_count":0,"comment_count":1}
|
|
23
|
+
{"_type":"issue","id":"so-n6b.62","title":"A missing selected test is signalled with warnings.warn, which warnings-as-errors turns into an xdist crash","description":"`plugin._filter_to_downwind_suite` and `plugin._filter_to_smoke_suite` report a\nselected test that is missing from collection with `warnings.warn`. A project\nrunning warnings as errors (`filterwarnings = [\"error\"]`, which is common)\nturns that warning into an exception raised inside\n`pytest_collection_modifyitems`. Under xdist, each worker raises it during\ncollection, and the controller fails with an INTERNALERROR (AssertionError in\nxdist/dsession.py, then KeyError from `_active_nodes.remove`) instead of\nreporting anything the user can act on. The same signal produces a warning in\none project and a crash in another, depending on configuration smoke-optimiser\ndoes not own.\n\nA missing selected test means the selection and the tree disagree. For\ndownwind, the claim is categorical, so a missing test means the selection\ncannot be trusted as a whole. For smoke, it is one fewer test in a coverage\nbet, and so-9kb covers the case where every id misses because the rootdir\nmoved.\n\nDECISION, for both --downwind and --smoke: a missing selected test is a\nsection in pytest's terminal summary that names every missing id and the\ncommand that fixes the selection. The selected tests that were collected still\nrun. No warning filter can escalate a terminal line. Under xdist only the\nworkers collect, so the controller records what is missing from the ids each\nworker reports in `pytest_xdist_node_collection_finished`, and the workers\nrecord nothing themselves.\n\nALTERNATIVES CONSIDERED, do not re-propose: `pytest.exit` with a distinct\nstatus, which stops the tests the selection got right; dropping the filter and\nrunning the full suite, which is slow and lets the stale selection pass\nwithout comment.\n\nWHERE: smoke_optimiser/plugin.py (`_filter_to_smoke_suite`,\n`_filter_to_downwind_suite`), and tests/test_plugin.py, which pins the\ncurrent warning text.\n","acceptance_criteria":"For both --downwind and --smoke, a selected test missing from collection produces the same outcome with and without filterwarnings=error, and with and without xdist, and that outcome names the cause and the fix. tests/test_plugin.py pins it under filterwarnings=error.","status":"closed","priority":2,"issue_type":"task","assignee":"lawther","owner":"mike.lawther@gmail.com","created_at":"2026-09-30T00:16:19Z","created_by":"lawther","updated_at":"2026-09-30T00:26:49Z","started_at":"2026-09-30T00:24:45Z","closed_at":"2026-09-30T00:26:49Z","close_reason":"Missing selected tests are reported in a terminal-summary section, for --smoke and --downwind, and the rest of the selection still runs. The controller records them from pytest_xdist_node_collection_finished. tests/test_plugin.py pins it under plain, warnings-as-errors, xdist, and xdist with warnings-as-errors; the last of those reproduced the field INTERNALERROR against the previous plugin. pytest-xdist is now a dev dependency so those cases can run.","labels":["model:opus"],"dependencies":[{"issue_id":"so-n6b.62","depends_on_id":"so-n6b","type":"parent-child","created_at":"2026-09-30T00:16:19Z","created_by":"lawther","metadata":"{}"},{"issue_id":"so-n6b.62","depends_on_id":"so-n6b.61","type":"related","created_at":"2026-09-30T00:16:19Z","created_by":"lawther","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
|
24
|
+
{"_type":"issue","id":"so-avn","title":"Test git fixtures and downwind's own git calls leak GIT_DIR/GIT_WORK_TREE into subprocess calls","description":"Test fixtures that build throwaway git repos shell out to git without clearing GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE or GIT_PREFIX from the inherited environment. Affected: tests/test_downwind_changes.py, tests/test_downwind_e2e.py, tests/test_downwind_command.py, tests/test_e2e.py, tests/test_paths.py. The production code under test (smoke_optimiser/downwind/changes.py's _run_git) had the same gap.\n\nWhen the suite runs as a git hook (core.hooksPath=.githooks, .githooks/pre-commit calls just precommit on every commit), git sets those variables for the hook's child processes. Every test that creates an isolated temp repo and runs git init/add/commit inside it is then redirected to the real repository instead, because the env vars override cwd.\n\nA shared git_subprocess_env() helper now lives in tests/conftest.py and every test's git-shelling helper passes it as env=. smoke_optimiser/downwind/changes.py's _run_git does the same for the production code path, since downwind itself would have the identical exposure if ever invoked from inside a git hook.\n","status":"in_progress","priority":2,"issue_type":"task","assignee":"Test","owner":"test@example.com","created_at":"2026-09-24T22:44:51Z","created_by":"Test","updated_at":"2026-09-24T23:19:05Z","started_at":"2026-09-24T22:44:55Z","lease_expires_at":"2026-09-24T22:49:55Z","heartbeat_at":"2026-09-24T22:44:55Z","labels":["model:sonnet"],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
|
21
25
|
{"_type":"issue","id":"so-n6b.59","title":"(BUG) run_profiling hand-rebuilds ProfilingData, so a new field with a default is silently dropped","description":"`run_profiling` does not return the ProfilingData that `build_profiling_data`\nbuilt. It names eight of its fields one at a time into a fresh one, so it can\nreplace the meta:\n\n return ProfilingRun(\n data=ProfilingData(\n meta=final_meta,\n tests=data.tests,\n total_branches=data.total_branches,\n measured_files=data.measured_files,\n import_graph=data.import_graph,\n scope=data.scope,\n anchor=data.anchor,\n unattributable_branches=data.unattributable_branches,\n reads=data.reads,\n present_files=data.present_files,\n ),\n ...\n\nAdd a field to ProfilingData and this keeps compiling, keeps passing, and\nquietly returns a profile without it -- so long as the field has a default,\nwhich `reads`, `present_files` and `unattributable_branches` all do. Only a\nfield with no default fails loudly, and that is luck rather than design.\n\nTHIS IS THE SECOND HALF OF so-n6b.39, WHICH ITS FIX WOULD NOT CATCH. That\nissue is about `save_profile` hand-mapping ProfilingData onto\nProfilingDataFile, and proposes a round-trip test as its first part. A\nround-trip test STARTS from a ProfilingData, so it cannot detect a field this\nfunction never put into one. The two functions share a failure shape and need\nseparate fixes.\n\nThe cause is that `build_profiling_data` fabricates a ProfilingMeta it has no\nbusiness knowing -- `commit=None`, `timestamp=now()`, `command=sys.argv` --\nwhich the caller then throws away and rebuilds with the real values. It\nreturns a half-true ProfilingData so that the caller has to launder it.\n\nWHAT WOULD FIX IT: have `build_profiling_data` return what it actually knows\n(the coverage ingest, plus the coverage version it read) and let\n`run_profiling` assemble ProfilingData once, in one place, from parts that are\neach true when they are made. Then there is one construction site rather than\ntwo, and a new field has one place to be forgotten instead of three.\n\nWHERE: smoke_optimiser/profiler/runner.py run_profiling(), and\nsmoke_optimiser/profiler/coverage_db.py build_profiling_data().\n\nObserved while adding `anchor` in so-746: it had to be threaded through\n`build_profiling_data`, out of `data.anchor`, and into `save_profile`\nseparately -- three hand edits for one field.\n","acceptance_criteria":"ProfilingData is constructed in exactly one place in the profiling path, and build_profiling_data no longer fabricates metadata its caller replaces.","status":"open","priority":2,"issue_type":"bug","owner":"mike.lawther@gmail.com","created_at":"2026-09-23T02:03:04Z","created_by":"lawther","updated_at":"2026-09-23T02:03:04Z","labels":["model:sonnet"],"dependencies":[{"issue_id":"so-n6b.59","depends_on_id":"so-746","type":"discovered-from","created_at":"2026-09-23T02:03:10Z","created_by":"lawther","metadata":"{}"},{"issue_id":"so-n6b.59","depends_on_id":"so-n6b","type":"parent-child","created_at":"2026-09-23T02:03:04Z","created_by":"lawther","metadata":"{}"},{"issue_id":"so-n6b.59","depends_on_id":"so-n6b.39","type":"related","created_at":"2026-09-23T02:03:09Z","created_by":"lawther","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
|
22
26
|
{"_type":"issue","id":"so-n6b.58","title":"Pin that a change in a sibling directory of a monorepo selects nothing","description":"This is the property that makes downwind usable in a monorepo at all, and\nnothing pins it.\n\nso-746 anchored the profile on the repository root, so `changed_files` now\nreports EVERY change in the repository -- including files in sibling\ndirectories that have nothing to do with the project being tested. For\ncasey_ai that is app/ (a Flutter app), assets/, docs/, scripts/ and\nemulator-data/. If a change to any of those refused to the full suite, every\ncommit touching the front end would run 1337 Python tests plus testcontainers,\nand downwind would be worse than useless there.\n\nIt does not, and the reason is worth a test rather than a reader's trust. A\nsibling file is present in `present_files` (which is the whole working tree),\nso it is not read as newly added; it is outside every scope root, so it is not\nan expiry; and the read map answers \"nothing the profile saw ever read this\ndirectory\", which is measured emptiness rather than ignorance. The three\ncombine to select nothing.\n\nVerified by hand against the monorepo fixture, for a sibling .dart file, a\nsibling .py file and a file at the repository root:\n\n sibling dart file exit=0 blind_spots=0 nodes=0 changed=['app/main.dart']\n sibling python file exit=0 blind_spots=0 nodes=0 changed=['app/helper.py']\n root readme exit=0 blind_spots=0 nodes=0 changed=['README.md']\n\nThe sibling .py file is the interesting one: it is Python, outside the coverage\nroot and outside the test roots, so it exercises the scope predicate rather\nthan the \"not a .py file\" shortcut.\n\nWHERE: tests/test_downwind_e2e.py, alongside the profiled_monorepo fixture\nso-746 added. The fixture needs a sibling directory holding both a non-Python\nfile and a .py file outside every scope root.\n\nWorth having as a test and not only as an argument: each of the three\nmechanisms above is owned by a different module, and any one of them changing\nits mind would turn every sibling commit into a full suite -- slowly, and\nwithout anything failing.\n","acceptance_criteria":"An end-to-end test edits a file in a sibling directory of the monorepo fixture -- both a non-Python file and a .py file outside every scope root -- and asserts no blind spots, no node ids and no full suite.","status":"open","priority":2,"issue_type":"task","owner":"mike.lawther@gmail.com","created_at":"2026-09-23T02:02:47Z","created_by":"lawther","updated_at":"2026-09-23T02:02:47Z","labels":["downwind-mvp","model:sonnet"],"dependencies":[{"issue_id":"so-n6b.58","depends_on_id":"so-746","type":"discovered-from","created_at":"2026-09-23T02:03:11Z","created_by":"lawther","metadata":"{}"},{"issue_id":"so-n6b.58","depends_on_id":"so-n6b","type":"parent-child","created_at":"2026-09-23T02:02:47Z","created_by":"lawther","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
|
23
27
|
{"_type":"issue","id":"so-u3z","title":"(BUG) The unbounded-test-scope warning goes quiet for a project in a subdirectory","description":"`ProfileScope.collects_from_whole_repository` asks `WHOLE_REPOSITORY in\ntest_roots`, and `_warn_about_an_unbounded_test_scope` fires off it. That was\nexact while the invocation directory was always the repository root: a project\nconfiguring no `testpaths` left pytest pointed at the root, so the resolved test\nroot really was `.`.\n\nIt no longer is. pytest with no `testpaths` sets `config.args` to the invocation\ndirectory, which now relativises to the project's offset -- `api`, not `.`. So a\nsubdirectory project that configures nothing gets `test_roots = {\"api\"}`, the\nproperty answers False, and the warning never fires.\n\nThe project is in exactly the state the warning exists to describe: every `.py`\nfile beneath it is in scope, only files matching the test-file patterns can be\ntracked, a new fixture or factory module goes unnoticed when the profile goes\nstale, and any test-named file anywhere beneath it forces the full suite. None\nof that is said.\n\nThe question the property is really asking is \"was pytest pointed at a test\ndirectory, or at the whole project?\", and the answer is now \"the test root\nequals the project offset\", of which `. == .` is the flat-layout case. The\nprofile records that offset as of so-746, so both halves are already in hand.\n\nVerified by running pytest with no testpaths from a subdirectory: `ARGS:\n['/private/tmp/scopechk/api'] ROOTDIR: /private/tmp/scopechk/api`.\n","acceptance_criteria":"A project in a subdirectory of its repository that configures no testpaths gets the same unbounded-scope warning a project at the root does, and a project that configures testpaths still gets none.","status":"open","priority":2,"issue_type":"bug","owner":"mike.lawther@gmail.com","created_at":"2026-09-23T01:50:59Z","created_by":"lawther","updated_at":"2026-09-23T01:50:59Z","labels":["model:sonnet"],"dependencies":[{"issue_id":"so-u3z","depends_on_id":"so-746","type":"discovered-from","created_at":"2026-09-23T01:51:04Z","created_by":"lawther","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
|
@@ -64,8 +68,10 @@
|
|
|
64
68
|
{"_type":"issue","id":"so-8ve","title":"Multi-iteration duration averaging blends setup-phase duration from a differently-outcomed iteration","description":"Introduced by the so-pc5 fix (runner.py PYTEST_HOOK_CODE now also records failed setup/teardown reports).\n\nWHERE: smoke_optimiser/profiler/runner.py, the iterations loop around lines 169-178:\n for nodeid, data in raw_outcomes.items():\n all_durations[nodeid].append(data['duration'])\n final_outcomes[nodeid] = data['passed'] # last iteration wins\n final_markers[nodeid] = frozenset(data['markers'])\navg_durations is then sum(all_durations[nodeid]) / len(...) across ALL iterations, unconditionally.\n\nPROBLEM. With config.iterations \u003e 1 and a flaky test whose fixture sometimes errors in setup:\n iteration 1: fixture errors in setup after doing 2.0s of work -\u003e recorded passed=False, duration=2.0 (the setup phase duration)\n iteration 2: fixture succeeds, call passes in 0.01s -\u003e recorded passed=True, duration=0.01\nfinal_outcomes lands on passed=True (last iteration), but avg_durations averages 2.0 and 0.01 together (~1.0s) -- a passing test now reports a duration wildly inflated by an unrelated failing iteration's setup-phase timing.\n\nBefore so-pc5, failed iterations were absent from raw_outcomes entirely (the hook only recorded 'call'), so this contamination could not happen -- durations from failing iterations simply were not collected. so-pc5 fixed a real crash but reopened this averaging correctness gap as a side effect.\n\nNEEDS A DESIGN DECISION: should durations only be averaged across iterations whose outcome matches final_outcomes (i.e. only average the 'passed' iterations' durations when the test ultimately counts as passed)? Or bucket durations by outcome and report the one matching final_outcomes? Or something else? This wasn't decided as part of so-pc5 since it's a distinct pre-existing averaging mechanism the fix newly interacts with.","status":"open","priority":2,"issue_type":"bug","owner":"mike.lawther@gmail.com","created_at":"2026-09-06T12:48:17Z","created_by":"lawther","updated_at":"2026-09-06T12:48:17Z","labels":["model:opus"],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
|
65
69
|
{"_type":"issue","id":"so-asl","title":"Profiling ignores the project's own coverage config, inflating total_branches","description":"runner.py writes its own minimal rcfile and passes it as --cov-config, which\nREPLACES the project's coverage configuration rather than extending it:\n\n COVERAGERC_CONTENT = '''\n [run]\n branch = True\n '''\n\nSo the project's own settings are silently discarded during profiling. Most\nconsequential is [report] exclude_lines. This repository's own pyproject.toml\nhas:\n\n exclude_lines = ['pragma: no cover', 'if TYPE_CHECKING:',\n 'def __repr__', 'raise NotImplementedError']\n\nNone of these apply while profiling. Branches the project has explicitly\ndeclared un-coverable are therefore counted in total_branches, inflating the\ndenominator and depressing every reported coverage percentage. [run] omit and\n[run] source are dropped for the same reason.\n\nPRE-EXISTING, NOT A so-mr0 REGRESSION -- the old JSON path passed the same\nrcfile to 'coverage json', so behaviour is unchanged. It is more VISIBLE now:\nso-mr0 computes the denominator in-process from FileReporter using this rcfile,\nso the exclusions are absent from both numerator and denominator, and\ncoverage_db is where the effect now lands.\n\nINTERACTION WITH --target-cov: an inflated denominator makes a given\n--target-cov harder to reach than the user intends, and can make a threshold\nthey set from 'coverage report' output unreachable.\n\nDESIGN DECISION. Merging user config is not free: an omit or source setting that\nconflicts with --src, or a dynamic_context already set to something other than\n'test', would break profiling. Options are to layer our [run] settings over the\ndiscovered config, or to read only [report] exclusions, or to keep replacing but\nwarn when the project has settings we are discarding.\n\nWHERE: smoke_optimiser/profiler/runner.py COVERAGERC_CONTENT; consumed by\nsmoke_optimiser/profiler/coverage_db.py::read_coverage_db via config_file.\n\nACCEPTANCE: a branch marked 'pragma: no cover' does not appear in\ntotal_branches, and profiling still works for a project with no coverage\nconfiguration at all.","notes":"The dropped [run] source now costs more than measurement fidelity. The profile records\nthe scope it was captured under (smoke_optimiser/profiler/scope.py), and that scope is\nread off the --cov flags pytest-cov resolved -- so a project that declares its coverage\nsource only in [tool.coverage.run] gets neither the measurement it asked for nor a\nrecorded scope matching its layout. Whatever replaces COVERAGERC_CONTENT should therefore\nkeep the two in step: the scope must stay a description of what was actually measured,\nwhich is the property that lets a profile say it has gone stale.","status":"open","priority":2,"issue_type":"task","owner":"mike.lawther@gmail.com","created_at":"2026-09-06T12:36:58Z","created_by":"lawther","updated_at":"2026-09-07T12:17:37Z","labels":["model:opus"],"dependencies":[{"issue_id":"so-asl","depends_on_id":"so-owf","type":"blocks","created_at":"2026-09-06T23:15:15Z","created_by":"lawther","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
|
66
70
|
{"_type":"issue","id":"so-2os","title":"pytest's exit code is ignored, so a collection error silently yields a partial smoke suite","description":"runner.py runs the suite with check=False and never inspects the return code:\n\n subprocess.run(pytest_cmd, cwd=project_root, check=False, env=env)\n\nExit code 1 (tests failed) is expected and correctly handled -- failing tests are\nhard-excluded. But every other non-zero code is swallowed:\n\n 2 interrupted (e.g. Ctrl-C, or --maxfail reached)\n 3 internal error\n 4 usage error (e.g. a bad --pytest-args value)\n 5 no tests collected\n\nA COLLECTION ERROR IN ONE FILE is the dangerous case. pytest still runs and\nstill writes coverage for everything it did collect, so profiling proceeds\nhappily and emits a smoke suite that is simply missing every test in the broken\nfile. The user gets a green-looking result that under-selects, with no\nindication anything was skipped. That is precisely the silent under-selection\nso-mr0 was written to eliminate -- so-mr0 closed the ingest hole but this one is\nupstream of it and still open.\n\nExit 5 (no tests collected) is currently caught downstream by chance: no\ncoverage database means CoverageIngestError. The message will talk about a\nmissing database rather than about no tests being collected.\n\nDECISION NEEDED on --iterations \u003e 1: if iteration 3 of 5 is interrupted, is the\naccumulated coverage still usable, or is the whole run void? Coverage\naccumulates across iterations into one database, so a partial iteration\ncontributes real data but skews nothing except durations.\n\nWHERE: smoke_optimiser/profiler/runner.py:157\n\nACCEPTANCE: a project with one uncollectable test file fails loudly rather than\nproducing a smoke suite; exit code 1 still proceeds as it does today.","status":"closed","priority":2,"issue_type":"bug","owner":"mike.lawther@gmail.com","created_at":"2026-09-06T12:36:44Z","created_by":"lawther","updated_at":"2026-09-07T07:28:50Z","closed_at":"2026-09-07T07:28:50Z","close_reason":"Two signals now guard the profiling run, because the exit code alone cannot carry it.\n\nThe inline profiling hook grows a pytest_collectreport hook that records every node id pytest failed to collect, merged across xdist workers through the outcomes file (OutcomesFileModel gains a required collection_errors list). Any collection error is fatal however many iterations have already run: the same files fail to collect every time, so accumulated coverage never makes up for tests that were never collected. This is what closes the hole the issue was aimed at -- --continue-on-collection-errors makes a collection error exit 1, indistinguishable from ordinary test failures, and a project can set it in its own addopts where no flag smoke-optimiser sees ever mentions it.\n\nThe exit code is then checked against pytest.ExitCode: OK and TESTS_FAILED proceed, everything else (plus codes pytest does not define, e.g. a signal) is fatal, each with its own message -- NO_TESTS_COLLECTED now says so instead of falling through to a missing-database CoverageIngestError.\n\nDecision on --iterations \u003e 1: an abandoned iteration stops the loop but keeps what completed. Coverage accumulates into one database, so the earlier iterations' work is intact; the abandoned iteration's outcomes and import graph are dropped whole, since tests it never reached would be averaged over fewer samples than the rest. If the first iteration is the one that fails there is nothing to keep, and the run fails.\n\nTests: three e2e runs over a project with one uncollectable test file (default exit 2, --continue-on-collection-errors exit 1, and a plain failing test that must still profile), plus unit coverage of each fatal exit code, the mid-run abandonment, and the hook recording collection errors.","labels":["downwind-mvp","model:opus"],"dependencies":[{"issue_id":"so-2os","depends_on_id":"so-n6b","type":"parent-child","created_at":"2026-09-07T12:04:42Z","created_by":"lawther","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
|
71
|
+
{"_type":"issue","id":"so-bxj","title":"Downwind headline reports '0 of N tests' when a changed test module carries the selection","description":"When a commit changes a source module AND the test module that tests it, the downwind headline under-states the selection. Seen on casey_ai/api: a change to participant_view.py plus tests/test_guest_router.py printed\n\n 🎯 0 of 2124 tests, plus every test in 1 changed test modules, downwind of 2 changed files.\n\nand pytest then ran all 48 test_guest_router tests. The same change without the test-file edit prints 48 of 2124.\n\nThe selection is right; the count is not. rules.downwind_of drops every node id whose file part is a changed test module (that module is selected whole, because the change made its profiled ids stale), and command._report_selection counts only len(selection.node_ids). So the number a developer reads is the tests reached OUTSIDE the changed test modules, presented as though it were the whole selection. In the common edit-code-and-its-test commit that number is 0, which reads as 'nothing is downwind', the one thing the line should never imply when tests are about to run.\n\nThe changed module's test count is not known exactly before pytest collects it: the profile's ids for it are the stale part. Where the true number comes from (the profile's count for that module as an estimate, a count the --downwind plugin reports after collection, or both) is a design decision for whoever picks this up.\n\nNot MVP: no test is mis-selected, and it fails visibly (pytest runs and reports its own count).","acceptance_criteria":"- The headline for a change touching a source module and its own test module never reports 0 tests when that test module has tests to run.\n- The headline states the changed test modules' contribution as a number, not only as a module count.\n- A test covers the participant_view.py + test_guest_router.py shape: a changed test module that also holds every test downwind of the changed source module.","status":"in_progress","priority":3,"issue_type":"bug","assignee":"lawther","owner":"mike.lawther@gmail.com","created_at":"2026-10-08T03:48:24Z","created_by":"lawther","updated_at":"2026-10-08T03:49:04Z","started_at":"2026-10-08T03:49:04Z","lease_expires_at":"2026-10-08T03:54:04Z","heartbeat_at":"2026-10-08T03:49:04Z","labels":["model:opus"],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
|
72
|
+
{"_type":"issue","id":"so-n6b.63","title":"A changed test module emptied of every test makes downwind exit 5","description":"When the only thing downwind of a change is a changed test module that still\nexists but no longer defines any test (every test deleted, file kept),\nrun_downwind in smoke_optimiser/downwind/command.py finds the module present\nand starts pytest. pytest collects nothing and exits 5 (NO_TESTS_COLLECTED),\nwhich fails the commit the command is gating. A deleted module is already\nhandled, because present_modules excludes it. An emptied one is not, since\nexistence is the only test. It fails loudly and only in an unusual edit.\n\nWHERE: run_downwind, and _run_pytest, whose contract is to pass pytest's exit\ncode through untouched.\n","acceptance_criteria":"A change whose only selection is a test module holding no tests exits 0 and says why, without reinterpreting any other pytest exit code.","status":"open","priority":3,"issue_type":"bug","owner":"mike.lawther@gmail.com","created_at":"2026-09-30T01:30:20Z","created_by":"lawther","updated_at":"2026-09-30T01:30:20Z","labels":["model:sonnet"],"dependencies":[{"issue_id":"so-n6b.63","depends_on_id":"so-n6b","type":"parent-child","created_at":"2026-09-30T01:30:20Z","created_by":"lawther","metadata":"{}"},{"issue_id":"so-n6b.63","depends_on_id":"so-n6b.61","type":"discovered-from","created_at":"2026-09-30T01:30:20Z","created_by":"lawther","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
|
67
73
|
{"_type":"issue","id":"so-n6b.60","title":"Environment-file patterns match at any depth, so a sibling project forces the full suite","description":"`is_environment_file` matches each pattern against the repository-relative\npath AND against the bare filename:\n\n return any(fnmatch(path, pattern) or fnmatch(name, pattern) for pattern in patterns)\n\nThe bare-name arm is deliberate and documented -- \"uv.lock\" is meant to catch\none at any depth. Before so-746 the repository and the project were the same\ndirectory, so \"any depth\" meant \"anywhere in this project\". Now they are not,\nand it means anywhere in the REPOSITORY.\n\nIn a monorepo that pulls in every sibling. A change to `app/pyproject.toml`, a\nsibling service's `Dockerfile`, or `web/requirements-dev.txt` matches\nDEFAULT_ENVIRONMENT_FILES and forces the full suite of a project that has no\nrelationship to any of them -- 1337 tests and testcontainers, in casey_ai's\ncase, for a commit that never touched Python.\n\nThis over-selects, which is the safe direction, and environment_files.py says\noutright that a wrong entry costs time rather than correctness. So this is a\ncost question, not a correctness one -- but the cost is the whole feature:\ndownwind's value is the commits it does NOT run everything for, and a\nshared-monorepo repository can easily see more sibling commits than project\nones.\n\nTHE JUDGEMENT, and why this needs one rather than a patch: the sibling's\nenvironment file may genuinely matter. A docker-compose.yml at the repository\nroot is plausibly the one testcontainers uses, and a root pyproject.toml may\nbe a workspace that really does pin this project's dependencies. Scoping the\nbare-name arm to at-or-below the project offset would fix the false positives\nand introduce false negatives in exactly those cases.\n\nOne shape worth weighing: keep the full-path arm repository-wide, since a\npattern someone wrote WITH a path said where it meant; scope only the\nbare-name arm to the project offset, since that arm is the one that means \"at\nany depth\" and no longer knows what depth it is measuring from. A project that\nwants the root compose file back names it as `../docker-compose.yml` or the\nrepository-relative path, which is a thing it can say.\n\nWHERE: smoke_optimiser/downwind/environment_files.py, and the\n`environment_files` argument threaded through downwind/rules.py.\nThe profile records the project offset as of so-746, so the input is in hand.\n","acceptance_criteria":"A change to an environment file belonging to a sibling project of a monorepo does not by itself force this project's full suite, and a project can still name a repository-level environment file it genuinely depends on.","status":"open","priority":3,"issue_type":"bug","owner":"mike.lawther@gmail.com","created_at":"2026-09-23T02:03:47Z","created_by":"lawther","updated_at":"2026-09-23T02:03:47Z","labels":["model:opus"],"dependencies":[{"issue_id":"so-n6b.60","depends_on_id":"so-746","type":"discovered-from","created_at":"2026-09-23T02:03:51Z","created_by":"lawther","metadata":"{}"},{"issue_id":"so-n6b.60","depends_on_id":"so-n6b","type":"parent-child","created_at":"2026-09-23T02:03:47Z","created_by":"lawther","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
|
68
|
-
{"_type":"issue","id":"so-9kb","title":"The smoke suite records no anchor, so one selected elsewhere blames renamed tests","description":"`.smoke_suite.json` carries version, generated_at, generator_version,\nrepro_command, machine, config, summary, smoke_tests and\ncoverage_equivalents -- and nothing that identifies the profile or the\ndirectory it was selected in. `.downwind.json` carries a ProfileIdentityModel\n(the profile's commit and timestamp); the smoke suite has no equivalent.\n\nNode ids are relative to pytest's ROOTDIR, which so-746 made explicit for the\nprofile and left implicit here. A `.smoke_suite.json` selected in api/ and\nused by a `pytest --smoke` run whose rootdir is the repository root names\ntests that do not exist under that rootdir, so every id misses.\n\nIt is not silent -- `_filter_to_smoke_suite`
|
|
74
|
+
{"_type":"issue","id":"so-9kb","title":"The smoke suite records no anchor, so one selected elsewhere blames renamed tests","description":"`.smoke_suite.json` carries version, generated_at, generator_version,\nrepro_command, machine, config, summary, smoke_tests and\ncoverage_equivalents -- and nothing that identifies the profile or the\ndirectory it was selected in. `.downwind.json` carries a ProfileIdentityModel\n(the profile's commit and timestamp); the smoke suite has no equivalent.\n\nNode ids are relative to pytest's ROOTDIR, which so-746 made explicit for the\nprofile and left implicit here. A `.smoke_suite.json` selected in api/ and\nused by a `pytest --smoke` run whose rootdir is the repository root names\ntests that do not exist under that rootdir, so every id misses.\n\nIt is not silent -- `_filter_to_smoke_suite` records every missing id and the\nplugin's terminal summary lists all N of them as \"smoke tests not found in\ncollection\" with the remedy \"Run `smoke-optimiser smoke` to select the suite\nagain\", and an empty selection makes pytest exit 5 -- but it names the wrong\ncause. The reader is told their tests went missing and is sent to reselect,\nwhen running pytest from the directory the suite was selected in is all it\ntakes.\n\nso-746 made this newly reachable rather than newly broken: before it, a\nproject could only ever be profiled from the repository root, so there was one\nrootdir a suite could have been selected in. Now that a project can live in a\nsubdirectory, \"the same rootdir\" is a real assumption with a way to be wrong.\n\nTHE DESIGN QUESTION, which is why this is not simply a field to add: what\nidentity means for a smoke suite is not obviously the same as for a profile. A\nprofile is regenerable and a mismatch is repaired by rebuilding; a smoke suite\nis a deliberate selection someone may have committed, and refusing to run it\nbecause the anchor moved may be too strict where a clear warning would do.\nWhich of \"error\", \"warn accurately\" or \"record and say nothing\" is right is a\njudgement about how the smoke suite is meant to be used.\n\nWHERE: smoke_optimiser/reports/smoke_suite.py (SmokeSuiteFile,\nwrite_smoke_suite), smoke_optimiser/plugin.py (_filter_to_smoke_suite and\n_record_missing, whose summary text names the wrong cause), and smoke_optimiser/cli.py, which knows\nthe anchor at write time.\n\n","acceptance_criteria":"A .smoke_suite.json records where it was selected, and a --smoke run whose rootdir differs says so in those terms rather than reporting every test as removed or renamed.","status":"open","priority":3,"issue_type":"bug","owner":"mike.lawther@gmail.com","created_at":"2026-09-23T02:03:28Z","created_by":"lawther","updated_at":"2026-09-30T02:08:57Z","labels":["model:opus"],"dependencies":[{"issue_id":"so-9kb","depends_on_id":"so-746","type":"discovered-from","created_at":"2026-09-23T02:03:53Z","created_by":"lawther","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
|
69
75
|
{"_type":"issue","id":"so-c76.4","title":"README: prefer config over CLI for stable pytest args, and warm the profile in new worktrees","description":"Two small README gaps that each cost an adopter a cycle.\n\nCONFIG OVER CLI FOR STABLE ARGUMENTS. The one project running downwind in anger passes -n auto --dist=worksteal through --pytest-args on the command line. downwind_pytest_args already exists as a config key and is the better home for anything stable: it is what lets a shared, synced justfile recipe stay byte-identical across several repos while each tunes itself. The table documents both without saying which to reach for. A line -- config for what does not change, CLI for one-offs -- is all it needs, and it is what makes a fleet-wide shared recipe possible at all.\n\nWORKTREES PAY A FULL SUITE EACH. The profile is gitignored and per-checkout, so every new git worktree pays one instrumented full suite on its first commit. For a project whose suite needs Docker and testcontainers that is a surprising first commit in a fresh worktree. A line recommending `smoke-optimiser smoke --profile-only` as a worktree-setup step covers it.\n\nSharing one profile across worktrees of the same repo is arguably sound -- they are the same path space -- but that is a design question, not a doc fix, and is deliberately not proposed here.","acceptance_criteria":"The README says which of --pytest-args and downwind_pytest_args to reach for and why, and tells worktree users to pre-warm the profile.","status":"open","priority":3,"issue_type":"task","owner":"mike.lawther@gmail.com","created_at":"2026-09-22T22:15:47Z","created_by":"lawther","updated_at":"2026-09-22T22:15:47Z","labels":["model:sonnet"],"dependencies":[{"issue_id":"so-c76.4","depends_on_id":"so-c76","type":"parent-child","created_at":"2026-09-22T22:15:47Z","created_by":"lawther","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
|
70
76
|
{"_type":"issue","id":"so-oh2","title":"include_namespace_packages is read via pytest-cov's private _cov plugin attributes, with a silent fallback","description":"so-n6b.50's fix reads the profiled run's real include_namespace_packages setting from smoke_optimiser/profiler/runner.py's pytest_unconfigure hook via:\n\n config.pluginmanager.get_plugin('_cov').cov_controller.cov.config.include_namespace_packages\n\n'_cov' and cov_controller are pytest-cov's internal plugin registration name and attribute, not part of its public API (confirmed by reading pytest_cov/plugin.py -- CovPlugin registers itself under '_cov' and exposes cov_controller.cov as the underlying coverage.Coverage object). A pytest-cov internal refactor could rename or remove either without a deprecation, and the current code has no test pinned to a pytest-cov version to catch that.\n\nThe failure mode is silent and safe-by-default: getattr(..., None) chains mean any breakage just yields include_namespace_packages=False, the conservative choice (package-walk restriction stays on) -- so this would not misclassify a project that has actually turned the setting on; it would just silently stop reading it. Still worth either finding a public pytest-cov API for this, or adding a version-compat test/comment that fails loudly if pytest-cov's internal shape changes.","status":"open","priority":3,"issue_type":"task","owner":"mike.lawther@gmail.com","created_at":"2026-09-10T00:08:49Z","created_by":"lawther","updated_at":"2026-09-10T00:08:49Z","labels":["model:sonnet"],"dependencies":[{"issue_id":"so-oh2","depends_on_id":"so-n6b","type":"parent-child","created_at":"2026-09-10T10:08:54Z","created_by":"lawther","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
|
71
77
|
{"_type":"issue","id":"so-n6b.48","title":"The 'regenerate it' hint ignores a profile path given on the command line","description":"REGENERATE_COMMAND (smoke_optimiser/downwind/command.py:67) is a literal:\n\n REGENERATE_COMMAND = \"smoke-optimiser smoke --profile-only\"\n\nIt is printed in the two places downwind says the profile is the problem (command.py:202\nand :307), and it ignores the configuration of the run that printed it. A user who ran\n'downwind --profile-path build/profile.json' is told to run a command that writes to\n.smoke_profiling_data.json instead, so following the advice exactly leaves downwind still\nunable to find a profile, with no hint as to why. The path resolved from\n[tool.smoke_optimiser] in pyproject.toml is safe, since the suggested command reads the\nsame table -- only a path given on downwind's own command line goes wrong.\n\nThis is the pattern build_repro_command (smoke_optimiser/reports/repro.py) already exists\nto get right: it renders --profile-path from the resolved config rather than assuming the\ndefault. The suggestion here should be built the same way, from the DownwindConfig the\ncommand already holds.\n\nAlso worth settling while here: --profile-only is no longer what distinguishes a run that\nrecords a profile, since so-n6b.25 made every smoke run write one. The flag is still the\ncheapest way to record a profile without also building a smoke suite, so it is probably\nstill the right thing to suggest -- but that is now a choice rather than the only option,\nand it should be a deliberate one.\n","acceptance_criteria":"The suggested command names the profile path the run was actually configured with, so running it verbatim produces a profile the same downwind invocation then finds.","status":"open","priority":3,"issue_type":"bug","owner":"mike.lawther@gmail.com","created_at":"2026-09-08T22:33:54Z","created_by":"lawther","updated_at":"2026-09-08T22:33:54Z","labels":["model:sonnet"],"dependencies":[{"issue_id":"so-n6b.48","depends_on_id":"so-n6b","type":"parent-child","created_at":"2026-09-09T08:33:53Z","created_by":"lawther","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
|
@@ -103,5 +109,6 @@
|
|
|
103
109
|
{"_type":"issue","id":"so-u3i","title":"Parallel pytest workers (xdist) would race on the single shared outcomes JSON file","description":"Noticed while reviewing PYTEST_HOOK_CODE for so-pc5. The plugin's pytest_unconfigure writes ALL outcomes for the run to one file (SMOKE_OUTCOMES_JSON, default .smoke_outcomes.json):\n\n def pytest_unconfigure(config):\n if hasattr(config, '_smoke_outcomes'):\n outcomes_file = os.environ.get('SMOKE_OUTCOMES_JSON', '.smoke_outcomes.json')\n try:\n os.unlink(outcomes_file)\n except OSError:\n pass\n with open(outcomes_file, 'w') as f:\n json.dump(config._smoke_outcomes, f)\n\nThe existing comment even flags this: '# Unique name per worker if needed, but here we just need one'. That assumption breaks if a user passes xdist flags (e.g. -n auto) through config.pytest_args (smoke_optimiser/config.py, ResolvedConfig.pytest_args, spliced into pytest_cmd in runner.py). Each xdist worker process runs its own pytest_unconfigure and would unlink+overwrite the same file, racing with sibling workers -- the runner would then read back only whichever worker wrote last, silently losing most tests' durations/outcomes/markers rather than erroring.\n\nWHERE: smoke_optimiser/profiler/runner.py PYTEST_HOOK_CODE (pytest_unconfigure) and the outcomes_json read loop after subprocess.run.\n\nNEEDS A DESIGN DECISION: support xdist properly (per-worker files keyed by PYTEST_XDIST_WORKER, merged after the run), or explicitly detect and reject xdist flags in check_prerequisites with a clear error, since silent data loss is worse than refusing to run.","design":"DECIDED 2026-09-06 (user): SUPPORT xdist, do not reject it.\n\nRATIONALE. Half this tool is the coverage map, half is the time-based suite\nconstruction. Wanting only the map is a completely normal use (so-jr5's\nchange-based selection consumes the map and never reads a duration). Parallel\nprofiling is fine for that half, so it must not warn loudly at profile time.\nContended durations only matter to the half that uses time, so that is the\nhalf that objects.\n\nCAPTURE. Per-worker outcomes files keyed by PYTEST_XDIST_WORKER, merged by the\nrunner after the run (union across files). Detection must be RUNTIME, not\narg-string sniffing: xdist can be switched on by the target project's own\npytest.ini addopts, never appearing in --pytest-args, which is the exact\nsilent-data-loss case this issue is about. The plugin reads PYTEST_XDIST_WORKER\nand PYTEST_XDIST_WORKER_COUNT from its own process.\n\nRECORD. The worker count lands in the profile (ProfilingMeta), so the fact\ntravels with the artefact rather than living in stderr.\n\nCONSUME. The time-based optimiser ERRORS on a profile recorded with \u003e1 worker,\nwith an explicit override flag to proceed. A coverage-per-second ranking built\non contended timings is confidently wrong and looks fine, so a warning is not\nenough. The map-only consumers stay silent.","status":"closed","priority":3,"issue_type":"task","assignee":"lawther","owner":"mike.lawther@gmail.com","created_at":"2026-09-06T12:48:39Z","created_by":"lawther","updated_at":"2026-09-06T13:45:18Z","started_at":"2026-09-06T13:38:02Z","closed_at":"2026-09-06T13:45:18Z","close_reason":"xdist supported: per-worker outcomes files merged by the runner, worker count recorded in the profile, time-based optimiser errors on a parallel profile with an --allow-parallel-durations override. Verified end to end with real pytest-xdist: 93/93 tests captured across 2 workers.","labels":["model:opus"],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
|
104
110
|
{"_type":"issue","id":"so-tzk","title":"check_prerequisites looks for pytest on PATH but the runner invokes it as a module","description":"check_prerequisites gates on pytest being an executable on PATH:\n\n if shutil.which('pytest') is None:\n raise RuntimeError('pytest not found in PATH')\n\nbut every actual invocation runs it as a module of the current interpreter:\n\n pytest_cmd = [sys.executable, '-m', 'pytest', ...]\n\nThe two can disagree in both directions.\n\nFALSE NEGATIVE (the common one): pytest is importable by sys.executable but not\non PATH. This is normal when smoke-optimiser is invoked via 'uv run', via\n'python -m', from a virtualenv whose bin directory is not on PATH, or from a\ntox/nox session. Profiling is refused even though it would work.\n\nFALSE POSITIVE: a pytest on PATH belonging to a DIFFERENT environment satisfies\nthe check while sys.executable has no pytest, so the check passes and the\nsubprocess then fails with 'No module named pytest'.\n\nFIX: test what is actually used -- importlib.util.find_spec('pytest') against\nthe running interpreter -- and say which interpreter was checked when it fails.\n\nWHERE: smoke_optimiser/profiler/runner.py:58\n\nNOTE: tests/test_runner.py::test_check_prerequisites_success patches\nshutil.which, so the patch target moves with the fix.\n\nACCEPTANCE: profiling proceeds in an environment where pytest is importable but\nabsent from PATH.","status":"open","priority":3,"issue_type":"bug","owner":"mike.lawther@gmail.com","created_at":"2026-09-06T12:37:27Z","created_by":"lawther","updated_at":"2026-09-06T12:37:27Z","labels":["model:sonnet"],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
|
105
111
|
{"_type":"issue","id":"so-aif","title":"Line-level downwind selection, with git-aware remapping of shifted line numbers","description":"so-n6b deliberately ships FILE-level downwind selection: a changed file\nselects every test that has ever executed any line in it. This is the follow-up\nthat narrows it to the lines actually touched.\n\nWHY IT WAS NOT DONE FIRST. Line numbers shift. Insert one line at the top of a\nfile and every line number below it moves, so a line-level map silently\nmismatches after almost any edit -- and silent mismatch means silent\nunder-selection, the one failure mode this feature must never have. File-level\nis immune to line shifts, reformatting, and refactors within a file.\n\nWHAT THIS NEEDS. Git-aware remapping: track how lines moved between the commit\nthe profile was taken at (ProfilingMeta.commit is already recorded) and HEAD, so\na profiled line number still resolves to the right line today. `git diff -U0`\nbetween those two commits gives the hunks to translate through.\n\nDO NOT START THIS UNTIL file-level has been measured in real use. On enphase the\nfile-level map has a median of 37 tests per file out of 2600 -- if that is\nalready fast enough, the extra precision buys nothing and costs a whole\nremapping layer that can be subtly wrong.","status":"open","priority":3,"issue_type":"feature","owner":"mike.lawther@gmail.com","created_at":"2026-09-06T06:44:20Z","created_by":"lawther","updated_at":"2026-09-07T04:20:40Z","labels":["model:opus"],"dependencies":[{"issue_id":"so-aif","depends_on_id":"so-jr5","type":"discovered-from","created_at":"2026-09-06T16:44:26Z","created_by":"lawther","metadata":"{}"},{"issue_id":"so-aif","depends_on_id":"so-n6b","type":"parent-child","created_at":"2026-09-07T12:04:45Z","created_by":"lawther","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
|
112
|
+
{"_type":"issue","id":"so-0ju","title":"Downwind pytest header counts only node ids, not the changed test modules' tests","description":"plugin.pytest_report_header prints 'running downwind suite from .downwind.json (0 tests, plus every test in 1 changed test modules, downwind of 2 changed files)' for a change to a source module and the test module holding all its downwind tests. The 0 is len(downwind.node_ids), which excludes every id in a changed test module because that module runs whole.\n\nThe CLI headline counts those tests from the profile ('counted as last profiled'). The plugin cannot: .downwind.json carries no per-module count, so giving the header a real number means adding one to DownwindSuiteFile (a schema version bump) or rewording the header to make no count claim. pytest's own 'N selected' line directly below is exact, which is why this is P4.","acceptance_criteria":"- The pytest header never states '0 tests' for a selection whose changed test modules have tests to run.","status":"open","priority":4,"issue_type":"bug","owner":"mike.lawther@gmail.com","created_at":"2026-10-08T03:52:15Z","created_by":"lawther","updated_at":"2026-10-08T03:52:15Z","labels":["model:opus"],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
|
106
113
|
{"_type":"issue","id":"so-n6b.53","title":"downwind --src accepts a value that a --cov in --pytest-args silently overrides","description":"so-jr5.3 added --src to the downwind command, so the message refusing an undiscovered coverage source can offer a command the user can paste back. The smoke command rejects --src alongside a --cov in --pytest-args as a conflict it cannot honour (cli.py, _validate_option_combinations). downwind has no such check.\n\nPassing both to downwind is therefore accepted and one of them quietly does nothing: check_prerequisites returns early when supplies_cov_source(config.pytest_args) is true, so the --src the user just typed never reaches the command line. Harmless to the profile, which is measured under the --cov they gave, but it means the tool accepts a contradiction and acts on half of it -- and the two commands disagree about whether the same pair of options is an error.\n\nWhere: smoke_optimiser/cli.py. _validate_option_combinations is shaped around the smoke command arguments and would need the relevant half extracted, or downwind given its own check.\n\nNote the two commands read different args keys: smoke checks its own --pytest-args, downwind profiling run uses the PROFILING pytest_args rather than downwind_pytest_args, so the check must look at the same string check_prerequisites does or it will disagree with it.","acceptance_criteria":"Passing --src to downwind alongside a --cov in the arguments the fallback profiles under is rejected with the same reasoning smoke uses, rather than silently ignoring one of them.","status":"open","priority":4,"issue_type":"chore","owner":"mike.lawther@gmail.com","created_at":"2026-09-09T04:57:49Z","created_by":"lawther","updated_at":"2026-09-09T04:57:49Z","labels":["model:sonnet"],"dependencies":[{"issue_id":"so-n6b.53","depends_on_id":"so-n6b","type":"parent-child","created_at":"2026-09-09T14:57:48Z","created_by":"lawther","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
|
107
114
|
{"_type":"issue","id":"so-n6b.44","title":"Three near-identical helpers read back the per-worker files the profiling hook writes","description":"runner.py now has three functions doing the same four steps: glob the per-worker siblings of a base path, read each, unlink each, and merge the results.\n\n _read_iteration_outcomes -\u003e OutcomesFileModel, merged by dict update\n _read_iteration_graph -\u003e read_graph, merged by merge_graphs\n _read_iteration_read_map -\u003e read_read_map, merged by merge_read_maps\n\nThe glob pattern (stem + '*' + suffix) and the unlink loop are copied verbatim between them, and each of the three merges resolves what one worker could not attribute against what the others could. HookArtefacts already groups the three paths, so the shape is half recognised.\n\nThe pattern is what makes xdist correct -- a shared file would be overwritten by whichever worker finished last, silently losing most of the suite -- so it is worth having in one place with that reasoning stated once, rather than three times with the risk of a fourth reader-writer pair getting it subtly wrong.\n\nLow priority: the duplication is stable and each copy is correct today. File so the next addition consolidates rather than copying again.","status":"open","priority":4,"issue_type":"chore","owner":"mike.lawther@gmail.com","created_at":"2026-09-08T06:11:04Z","created_by":"lawther","updated_at":"2026-09-08T06:11:04Z","labels":["model:sonnet","profiler"],"dependencies":[{"issue_id":"so-n6b.44","depends_on_id":"so-n6b","type":"parent-child","created_at":"2026-09-08T16:11:04Z","created_by":"lawther","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Git pre-commit hook — runs the full justfile precommit suite before each commit.
|
|
3
|
+
#
|
|
4
|
+
# This file is version-controlled in .githooks/. To activate it on a fresh clone:
|
|
5
|
+
#
|
|
6
|
+
# git config core.hooksPath .githooks
|
|
7
|
+
#
|
|
8
|
+
# Or run: just setup-git-hooks
|
|
9
|
+
#
|
|
10
|
+
# `set -e` is what makes a failing check refuse the commit: without it only the last
|
|
11
|
+
# command's status counts, and a check ahead of it can fail unnoticed.
|
|
12
|
+
set -e
|
|
13
|
+
|
|
14
|
+
# check-hooks-drift runs here, not inside `just precommit`, because CI calls
|
|
15
|
+
# `just precommit` directly with no ../agent_rules sibling checkout to check
|
|
16
|
+
# against -- see agent_rules-8ym, agent_rules-bwr.
|
|
17
|
+
just check-hooks-drift
|
|
18
|
+
just precommit
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Git pre-merge-commit hook — refuses any merge that is about to create a merge commit.
|
|
3
|
+
#
|
|
4
|
+
# Fires only when `git merge` cannot fast-forward, which `merge.ff = only` (see
|
|
5
|
+
# `just setup-linear-history`) already stops before it gets this far. This hook is the
|
|
6
|
+
# belt-and-braces backstop for when that local preference is missing or overridden --
|
|
7
|
+
# a clone whose global config was never set to match, or `git merge --no-ff` used
|
|
8
|
+
# deliberately. Rebase onto the target instead of merging it in.
|
|
9
|
+
#
|
|
10
|
+
# This file is version-controlled in .githooks/. To activate it on a fresh clone:
|
|
11
|
+
#
|
|
12
|
+
# git config core.hooksPath .githooks
|
|
13
|
+
#
|
|
14
|
+
# Or run: just setup-git-hooks
|
|
15
|
+
echo "error: this repo requires linear history -- merge commits are rejected." >&2
|
|
16
|
+
echo "Rebase your branch onto the target instead of merging it in:" >&2
|
|
17
|
+
echo " git fetch && git rebase origin/main" >&2
|
|
18
|
+
exit 1
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Git pre-push hook — refuses to push any merge commit.
|
|
3
|
+
#
|
|
4
|
+
# `pre-merge-commit` stops a merge commit being created locally, but that is not the
|
|
5
|
+
# only way one reaches a push: `--no-verify` bypasses it, GUI clients often skip hooks
|
|
6
|
+
# entirely, and a merge commit made on a branch that never had these hooks installed
|
|
7
|
+
# can still be pulled in and pushed on from here. This is the last checkpoint before
|
|
8
|
+
# local history becomes shared, so it re-checks from scratch rather than trusting that
|
|
9
|
+
# the earlier hook already caught it.
|
|
10
|
+
#
|
|
11
|
+
# Only scans the commits actually being pushed (local ref vs. its remote-tracking
|
|
12
|
+
# state), not the whole repo history, so it stays fast regardless of repo age.
|
|
13
|
+
#
|
|
14
|
+
# This file is version-controlled in .githooks/. To activate it on a fresh clone:
|
|
15
|
+
#
|
|
16
|
+
# git config core.hooksPath .githooks
|
|
17
|
+
#
|
|
18
|
+
# Or run: just setup-git-hooks
|
|
19
|
+
remote_name="$1"
|
|
20
|
+
zero_sha="0000000000000000000000000000000000000000"
|
|
21
|
+
|
|
22
|
+
while read -r local_ref local_sha remote_ref remote_sha; do
|
|
23
|
+
# A deleted ref pushes no commits.
|
|
24
|
+
if [ "$local_sha" = "$zero_sha" ]; then
|
|
25
|
+
continue
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
if [ "$remote_sha" = "$zero_sha" ]; then
|
|
29
|
+
# New branch: nothing to diff against on this remote yet, so check every
|
|
30
|
+
# commit not already known on some other branch of it.
|
|
31
|
+
merges=$(git rev-list --min-parents=2 "$local_sha" --not --remotes="$remote_name")
|
|
32
|
+
else
|
|
33
|
+
merges=$(git rev-list --min-parents=2 "$remote_sha..$local_sha")
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
if [ -n "$merges" ]; then
|
|
37
|
+
echo "error: this repo requires linear history -- refusing to push merge commit(s) on $local_ref:" >&2
|
|
38
|
+
echo "$merges" | sed 's/^/ /' >&2
|
|
39
|
+
echo "Rebase instead of merging, then push again." >&2
|
|
40
|
+
exit 1
|
|
41
|
+
fi
|
|
42
|
+
done
|
|
43
|
+
|
|
44
|
+
exit 0
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
updates:
|
|
3
|
+
# Security updates only: open-pull-requests-limit 0 switches off routine version-update pull
|
|
4
|
+
# requests, and the group collects this ecosystem's security fixes into one pull request.
|
|
5
|
+
# The repository's "Grouped security updates" setting must stay off, because when on it
|
|
6
|
+
# overrides this group.
|
|
7
|
+
- package-ecosystem: "uv"
|
|
8
|
+
directory: "/"
|
|
9
|
+
schedule:
|
|
10
|
+
interval: "weekly"
|
|
11
|
+
open-pull-requests-limit: 0
|
|
12
|
+
commit-message:
|
|
13
|
+
prefix: "fix(deps)"
|
|
14
|
+
groups:
|
|
15
|
+
security:
|
|
16
|
+
applies-to: security-updates
|
|
17
|
+
patterns: ["*"]
|
|
18
|
+
# Security updates only, in the same way as the entry above.
|
|
19
|
+
- package-ecosystem: "github-actions"
|
|
20
|
+
directory: "/"
|
|
21
|
+
schedule:
|
|
22
|
+
interval: "weekly"
|
|
23
|
+
open-pull-requests-limit: 0
|
|
24
|
+
commit-message:
|
|
25
|
+
prefix: "ci(deps)"
|
|
26
|
+
groups:
|
|
27
|
+
security:
|
|
28
|
+
applies-to: security-updates
|
|
29
|
+
patterns: ["*"]
|
|
@@ -12,17 +12,17 @@ jobs:
|
|
|
12
12
|
env:
|
|
13
13
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
14
14
|
steps:
|
|
15
|
-
- uses: actions/checkout@v7
|
|
15
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
16
16
|
|
|
17
|
-
- uses: actions/setup-python@v7
|
|
17
|
+
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
|
18
18
|
with:
|
|
19
19
|
python-version: "3.13"
|
|
20
20
|
|
|
21
21
|
- name: Install uv
|
|
22
|
-
uses: astral-sh/setup-uv@
|
|
22
|
+
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
|
23
23
|
|
|
24
24
|
- name: Install just
|
|
25
|
-
uses: taiki-e/install-action@v2
|
|
25
|
+
uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1
|
|
26
26
|
with:
|
|
27
27
|
tool: just
|
|
28
28
|
|
|
@@ -17,7 +17,7 @@ jobs:
|
|
|
17
17
|
pr: ${{ steps.release.outputs.pr }}
|
|
18
18
|
tag_name: ${{ steps.release.outputs.tag_name }}
|
|
19
19
|
steps:
|
|
20
|
-
- uses: googleapis/release-please-action@v5
|
|
20
|
+
- uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5
|
|
21
21
|
id: release
|
|
22
22
|
with:
|
|
23
23
|
token: ${{ secrets.GITHUB_TOKEN }}
|
|
@@ -31,13 +31,13 @@ jobs:
|
|
|
31
31
|
permissions:
|
|
32
32
|
contents: write
|
|
33
33
|
steps:
|
|
34
|
-
- uses: actions/checkout@v7
|
|
34
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
35
35
|
with:
|
|
36
36
|
ref: release-please--branches--main
|
|
37
37
|
token: ${{ secrets.GITHUB_TOKEN }}
|
|
38
38
|
|
|
39
39
|
- name: Install uv
|
|
40
|
-
uses: astral-sh/setup-uv@
|
|
40
|
+
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
|
41
41
|
|
|
42
42
|
- name: Update lock file
|
|
43
43
|
run: uv lock
|
|
@@ -58,16 +58,16 @@ jobs:
|
|
|
58
58
|
permissions:
|
|
59
59
|
id-token: write # Mandatory for Trusted Publishing
|
|
60
60
|
steps:
|
|
61
|
-
- uses: actions/checkout@v7
|
|
61
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
62
62
|
|
|
63
63
|
- name: Install uv
|
|
64
|
-
uses: astral-sh/setup-uv@
|
|
64
|
+
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
|
65
65
|
|
|
66
66
|
- name: Build package
|
|
67
67
|
run: uv build
|
|
68
68
|
|
|
69
69
|
- name: Publish to PyPI
|
|
70
|
-
uses: pypa/gh-action-pypi-publish@release/v1
|
|
70
|
+
uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # release/v1
|
|
71
71
|
with:
|
|
72
72
|
skip-existing: true
|
|
73
73
|
|
|
@@ -78,17 +78,17 @@ jobs:
|
|
|
78
78
|
env:
|
|
79
79
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
80
80
|
steps:
|
|
81
|
-
- uses: actions/checkout@v7
|
|
81
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
82
82
|
|
|
83
|
-
- uses: actions/setup-python@v7
|
|
83
|
+
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
|
|
84
84
|
with:
|
|
85
85
|
python-version: "3.13"
|
|
86
86
|
|
|
87
87
|
- name: Install uv
|
|
88
|
-
uses: astral-sh/setup-uv@
|
|
88
|
+
uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3
|
|
89
89
|
|
|
90
90
|
- name: Install just
|
|
91
|
-
uses: taiki-e/install-action@v2
|
|
91
|
+
uses: taiki-e/install-action@68a389c788b2cd04016ce8c80ee23b80d2ab3d68 # v2.88.1
|
|
92
92
|
with:
|
|
93
93
|
tool: just
|
|
94
94
|
|
|
@@ -105,7 +105,7 @@ jobs:
|
|
|
105
105
|
permissions:
|
|
106
106
|
contents: write
|
|
107
107
|
steps:
|
|
108
|
-
- uses: actions/checkout@v7
|
|
108
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
109
109
|
|
|
110
110
|
- name: Publish release
|
|
111
111
|
env:
|
|
@@ -47,62 +47,6 @@ cp -rf source dest # NOT: cp -r source dest
|
|
|
47
47
|
- `apt-get` - use `-y` flag
|
|
48
48
|
- `brew` - use `HOMEBREW_NO_AUTO_UPDATE=1` env var
|
|
49
49
|
|
|
50
|
-
<!-- BEGIN BEADS INTEGRATION v:1 profile:minimal hash:970c3bf2 -->
|
|
51
|
-
## Beads Issue Tracker
|
|
52
|
-
|
|
53
|
-
This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands.
|
|
54
|
-
|
|
55
|
-
### Quick Reference
|
|
56
|
-
|
|
57
|
-
```bash
|
|
58
|
-
bd ready # Find available work
|
|
59
|
-
bd show <id> # View issue details
|
|
60
|
-
bd update <id> --claim # Claim work
|
|
61
|
-
bd close <id> # Complete work
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
### Rules
|
|
65
|
-
|
|
66
|
-
- Use `bd` for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists
|
|
67
|
-
- Run `bd prime` for detailed command reference and session close protocol
|
|
68
|
-
- Use `bd remember` for persistent knowledge — do NOT use MEMORY.md files
|
|
69
|
-
|
|
70
|
-
**Architecture in one line:** issues live in a local Dolt DB; sync uses `refs/dolt/data` on your git remote; `.beads/issues.jsonl` is a passive export. See https://github.com/gastownhall/beads/blob/main/docs/SYNC_CONCEPTS.md for details and anti-patterns.
|
|
71
|
-
|
|
72
|
-
## Agent Context Profiles
|
|
73
|
-
|
|
74
|
-
The managed Beads block is task-tracking guidance, not permission to override repository, user, or orchestrator instructions.
|
|
75
|
-
|
|
76
|
-
- **Conservative (default)**: Use `bd` for task tracking. Do not run git commits, git pushes, or Dolt remote sync unless explicitly asked. At handoff, report changed files, validation, and suggested next commands.
|
|
77
|
-
- **Minimal**: Keep tool instruction files as pointers to `bd prime`; use the same conservative git policy unless active instructions say otherwise.
|
|
78
|
-
- **Team-maintainer**: Only when the repository explicitly opts in, agents may close beads, run quality gates, commit, and push as part of session close. A current "do not commit" or "do not push" instruction still wins.
|
|
79
|
-
|
|
80
|
-
## Session Completion
|
|
81
|
-
|
|
82
|
-
This protocol applies when ending a Beads implementation workflow. It is subordinate to explicit user, repository, and orchestrator instructions.
|
|
83
|
-
|
|
84
|
-
1. **File issues for remaining work** - Create beads for anything that needs follow-up
|
|
85
|
-
2. **Run quality gates** (if code changed) - Tests, linters, builds
|
|
86
|
-
3. **Update issue status** - Close finished work, update in-progress items
|
|
87
|
-
4. **Handle git/sync by active profile**:
|
|
88
|
-
```bash
|
|
89
|
-
# Conservative/minimal/default: report status and proposed commands; wait for approval.
|
|
90
|
-
git status
|
|
91
|
-
|
|
92
|
-
# Team-maintainer opt-in only, unless current instructions forbid it:
|
|
93
|
-
git pull --rebase
|
|
94
|
-
bd dolt push
|
|
95
|
-
git push
|
|
96
|
-
git status
|
|
97
|
-
```
|
|
98
|
-
5. **Hand off** - Summarize changes, validation, issue status, and any blocked sync/commit/push step
|
|
99
|
-
|
|
100
|
-
**Critical rules:**
|
|
101
|
-
- Explicit user or orchestrator instructions override this Beads block.
|
|
102
|
-
- Do not commit or push without clear authority from the active profile or the current user request.
|
|
103
|
-
- If a required sync or push is blocked, stop and report the exact command and error.
|
|
104
|
-
<!-- END BEADS INTEGRATION -->
|
|
105
|
-
|
|
106
50
|
<!-- BEGIN BEADS CODEX SETUP: generated by bd setup codex -->
|
|
107
51
|
## Beads Issue Tracker
|
|
108
52
|
|
|
@@ -16,6 +16,40 @@
|
|
|
16
16
|
recording the coverage targets and test paths the profiling run measured. A version 1
|
|
17
17
|
profile is rejected on load -- re-run the profiling phase to regenerate it.
|
|
18
18
|
|
|
19
|
+
## [0.3.4](https://github.com/lawther/smoke-optimiser/compare/v0.3.3...v0.3.4) (2026-11-04)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
### Features
|
|
23
|
+
|
|
24
|
+
* **downwind:** force the full suite with --full-suite or SMOKE_OPTIMISER_FULL_SUITE ([41476e4](https://github.com/lawther/smoke-optimiser/commit/41476e413f59e295762f9b1089cc844af9fe2b60))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Bug Fixes
|
|
28
|
+
|
|
29
|
+
* **deps:** bump the security group across 1 directory with 2 updates ([2754b31](https://github.com/lawther/smoke-optimiser/commit/2754b31dbd7edba857658d57cb769a8b260ec3b6))
|
|
30
|
+
* **downwind:** count changed test modules' tests in the selection headline ([d264ff4](https://github.com/lawther/smoke-optimiser/commit/d264ff440790b5e1f34814a65870ba6f8bd1889b))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
### Documentation
|
|
34
|
+
|
|
35
|
+
* **downwind:** say what --full-suite is for in its help text ([4915edb](https://github.com/lawther/smoke-optimiser/commit/4915edb8f23934a64fb011dd60e97d65f0a50037))
|
|
36
|
+
|
|
37
|
+
## [0.3.3](https://github.com/lawther/smoke-optimiser/compare/v0.3.2...v0.3.3) (2026-09-30)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
### Bug Fixes
|
|
41
|
+
|
|
42
|
+
* **downwind:** select a changed test module whole, not by its profiled node ids ([913534b](https://github.com/lawther/smoke-optimiser/commit/913534b510063db18cf0a683b9025bca4eaf2403))
|
|
43
|
+
* **downwind:** strip GIT_DIR and friends before every throwaway-repo git call ([590646d](https://github.com/lawther/smoke-optimiser/commit/590646def8e2211662627730f198d8a9e4a2e7dd))
|
|
44
|
+
* **justfile:** type-check analyse_ai_readiness.py in its own environment ([cc7981a](https://github.com/lawther/smoke-optimiser/commit/cc7981a400914135633c7928ddab0007291312a6))
|
|
45
|
+
* **plugin:** report missing selected tests in the terminal summary, not as warnings ([8fe1a46](https://github.com/lawther/smoke-optimiser/commit/8fe1a4607434094c30561bece5ef64601389d058))
|
|
46
|
+
* **reports:** stamp selection files with the real generator version, not a hardcoded 0.1.0 ([2b3890e](https://github.com/lawther/smoke-optimiser/commit/2b3890e93e7597e7daa2476c922d19ec87c5cdd2))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
### Documentation
|
|
50
|
+
|
|
51
|
+
* correct which deletions tracked_files reports, and where downwind runs from ([c096a27](https://github.com/lawther/smoke-optimiser/commit/c096a271a854fba9597ceb53145cb7f37f36cb44))
|
|
52
|
+
|
|
19
53
|
## [0.3.2](https://github.com/lawther/smoke-optimiser/compare/v0.3.1...v0.3.2) (2026-09-23)
|
|
20
54
|
|
|
21
55
|
|