ci: add cognitive-complexity and dependency-direction gates, plus a weekly structural sweep - #360
Merged
thodson-usgs merged 2 commits intoAug 9, 2026
Conversation
This was referenced Aug 8, 2026
thodson-usgs
marked this pull request as ready for review
August 8, 2026 21:05
Move BaseMetadata into a focused leaf module while preserving the documented dataretrieval.utils import, and flatten the top-level CQL OR scanner without changing its parsing behavior.
Gate cognitive complexity and dependency direction in pre-commit and CI, replace duplicated AST boundary tests with import-linter contracts, and add an advisory weekly structural-health report with contributor and architecture documentation.
thodson-usgs
force-pushed
the
chore/architecture-analysis-tools
branch
from
August 9, 2026 02:39
1f2ca33 to
90865cb
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follows the survey in Python code quality tools beyond linting. The repo already ratchets cyclomatic complexity with
xenonand trends it withwily; this evaluates the rest of that list, adopts the three that earn their keep, and — the larger half of the change — retires the hand-rolled architecture tests that the right tool expresses better.What lands
complexipyimport-linterpyscnworkflow_dispatchBoth gates run in well under a second and are wired the way
xenonalready is: identical command in the hook and in CI, so a clean pre-commit run means CI agrees..importlinterreplaces 11 of 23 architecture teststests/architecture_test.pyhand-parses the AST to assert dependency rules. Eleven of those were plain statements about the import graph, which is exactly what a boundary checker is for. They are now seven declarative contracts, and the tests are deleted — not kept alongside, since one rule with two homes is one rule that gets updated in one place.Three import-linter features made a bigger cut possible than I first estimated:
acyclic_siblingsreplaces both hand-written DFS cycle detectors, and states the claim package-wide instead of once per subsystem.protectedexpresses "only NGWMN and Water Data may consume OGC" as an allowlist, so a new service module is refused by default rather than permitted until someone remembers to extend a denylist.**wildcards make the NGWMN facade-only rule cover OGC submodules that don't exist yet.Equivalence was verified, not assumed. For each of the 11 tests: inject the violation it exists to catch, confirm both the test and the replacement contract fail, revert.
The first run came back 2 of 11 not equivalent, and the failure was worth the exercise. The
protectedcontract matchesallowed_importerswithas_packages, so listing the root package there made every module in the distribution an allowed importer — the contract could not fail on anything. A contract that can't fail is worse than no contract, because it reads as coverage. The root's two legitimate OGC imports are narrowignore_importsentries now, and the rerun was 11 of 11 with both sides firing.What deliberately stays a test
The 12 remaining tests are the ones an import graph genuinely cannot express, and the module docstring now says so — including the instruction that a new module-to-module rule belongs in
.importlinter, not here:ogc.engine's compatibility surface is capped by count;ogc.requestsmay borrow header policy but not the executing calls. A graph sees module edges, not which names cross them.__all__presence, single ownership per getter, the facade's exact export union._default_headerscalls proving they pass a destination URL; onlycredentialsnaming the API-key host.shapingmust importogc.schema.lint-importscan forbid an edge, never require one.Net: 23 tests → 12, and the module runs in 0.64s instead of 1.6s.
Report from the first full pyscn run
No correctness issues to resolve — zero dead code even at
--min-severity info, zero cycles, no high-coupling or low-cohesion classes, andlizardagrees withxenonon the three functions over CCN 15. No package source changed in this PR.The two soft numbers, read rather than obeyed:
utils.query/_query_with_retry, identical but for retry policy, andChunkCall.partial_frame/partial_response. Collapsing any of them trades the documented public surface for a metric. This is the main reasonpyscnis advisory and not a gate.exceptionsandcredentialsas multi-responsibility because many packages import them — afferent coupling on a stable leaf, which is what ADR 0003 asks for, scored as if it were the opposite. Its DI check similarly flags module-level singletons and one function it misidentifies as a class (planning._extract_axes).Score analysis: what actually hurts, and what is worth fixing
Asked what drags the 78/100 down and whether it can be honestly improved, I went through each sub-score. Most of the gap is the tool disagreeing with decisions this project made on purpose. Full write-up in #361; summary:
utils.py(Ca=19)exceptions/credentialsfor being widely importedexceptions,codes,rdb; textbook gaming<module>rows where pyscn sums a whole file (planning.py: cognitive 140, cyclomatic 1)The one real fix, included here
ogc.filters._split_top_level_orwas the only genuine hotspot — cognitive 36 in a package where nothing else exceeded 27. Four levels of nesting went to one question ("does a<space>OR<space>separator start here?") written as inline boundary arithmetic. Extracted as_resume_after_or, returning the next clause's start orNone; the scan loop becomes mutually exclusiveelifarms over a single character. Same algorithm, named intermediate.It parses user-supplied filter input, so behavior preservation is checked, not assumed — a differential harness runs the original against the new implementation over 34 adversarial cases (the
ORDER BYtoken trap, doubled-quote escapes, unbalanced parens, unclosed quotes, tabs/newlines), every 1–4 token sequence exhaustively, and 200k randomized expressions. 222,654 expressions, zero mismatches.Package ceiling 36 → 27, so the ratchet tightens 25% in the hook, CI, and CONTRIBUTING.
The pyscn composite did not move — still 78/100, because Complexity was already 95 and rounds the same. Worth stating plainly: the durable win is the tighter gate, not the score.
A second fix, found by wily's history
wilyindexes metrics across git history, and that dimension found something a point-in-time scan cannot. Over the last 120 revisions the Water Data refactor moved 706 lines out ofwaterdata/utils.py(MI 34.5 → 74.3, cyclomatic −108) while the sharedutils.pygrew 234 → 527 lines and 5 → 14 functions (MI 66.6 → 56.1, cyclomatic +27). Everything around it improved; that one module absorbed the difference.Auditing what its 19 dependents actually wanted: 15 of the 21 import sites wanted one name —
BaseMetadata, the second half of every getter's(DataFrame, metadata)contract. A class whose only dependency ishttpx.Response, sitting beside the legacy query machinery, so importing it dragged transport + credentials + error policy in transitively.Extracted to
dataretrieval/response_metadata.py, a dependency-free leaf.dataretrieval.utils.BaseMetadatastill resolves (re-export), since tests and users import that path.Afferent coupling on
utils.py: 20 → 10. Ten modules —ngwmn,ogc.shaping, and eightwaterdatamodules — no longer depend onutilsat all. Max depth stays 14; I simulated that before making the change, since depth is driven by legitimatewaterdata → ogc → transportlayering rather than this edge.The
.importlinterexhaustive stack refused to pass until the new module was placed deliberately — the contract doing its job.The strongest evidence for keeping pyscn advisory
That extraction is unambiguously good design: dependency-free leaf, coupling halved, public path preserved, all tests and contracts green.
pyscn's architecture score went 85 → 81 for it.
The new leaf is now itself an "SRP violation" —
responsibilities = [ngwmn, nwis, ogc, waterdata, wateruse, wqp], i.e. penalized for being depended upon — and every importer gained a "responsibility". A controlled before/after where a real improvement moves the metric backwards. Had pyscn been a merge gate with a threshold, it would have blocked this change. It stays advisory.Checked and rejected
codes/states.pyshows the sharpest MI drop in the whole history (100 → 70.5, cyclomatic +18) and looks alarming. It is a false alarm: the file went from a pure lookup table to a table plus a documented state-normalization API. Logic living where it belongs is not erosion, and "fixing" it would mean moving state handling somewhere worse.Deliberately not done
The rest of the
utils.pysplit — separating the legacy HTTP-query half from the pure stdlib helpers (Ambient,_require_positive_int,to_str) — stays in #361. The legacy-query half is entangled with the NWIS deprecation, so it may be cheaper to wait for that removal than to move it twice.Considered and skipped
tach— benchmarked rather than argued about, since Rust suggested it should win: 222ms vs import-linter's 160ms, while import-linter checked more contracts.grimp's graph builder is already Rust, so at 50 modules CLI startup dominates. With no speed case, a second boundary config to keep in sync isn't worth it. (tach initis also interactive-only — it blocks onEnterand pitches a hosted service — so the only automatable path is hand-writingtach.toml.)lizard— cyclomatic complexity and duplication, both already covered. Ran it as a cross-check; same three functions.cohesion/lcom/module_coupling_metrics— these measure class cohesion. This package is function-oriented by policy, andpyscnalready reports LCOM4 at 1.1 average across 26 classes with zero offenders.Packaging note
pyscnstays in its own[health]extra. It ships a compiled Go binary with no linux/aarch64 wheel — that's a container built on Apple Silicon, and an ARM CI runner — and[metrics]is what the blocking CI job and every contributor installs, so it's worth keeping a compiled third-party binary off that path regardless.One duplication I did not remove: the
36threshold appears in both the complexipy hook args and the CI command, asxenon's thresholds already do.complexipyhas no config-file support, so removing it would mean CI invokingpre-commitinstead — a change to this repo's CI convention, and out of scope here.Verification
lint-imports— 7 contracts kept, and all 7 shown to fail on an injected violationpytest tests/— 751 passed, 12 deselected (762 − 11 deleted)dataretrieval.utils.BaseMetadataverified to still resolve to the same object after the move_split_top_level_orrefactor differential-tested against the original over 222,654 expressions, zero mismatchespre-commit run --all-files— all 14 hooks passpip install -e .[metrics,health]resolves clean on 3.13; every CI command line re-run from that fresh env🤖 Generated with Claude Code