Repository navigation
fix(enums): converge severity literals on the canonical enum and add the three rungs it was missing (#14956, #13597) - #14990
Conversation
…the three rungs it was missing (#14956, #13597) The enum-fork half of #13597 landed in #14957; this is the literal sweep. Measured with the ratchet's own matcher: 169 bare "severity" dict literals across 43 files under the two Python roots. 145 converted to canonical Severity reads; the 24 that remain are values crossing an external boundary and are now an explicit, staleness-checked allowlist rather than a bare count. Three literals were not synonyms of an existing rung, so the canonical enum gained them rather than folding them away: warning (a Prometheus label the monitor publishes), degraded (serialised into the causal-analysis response) and error (the grade the capability audit counts findings at). Folding any of the three would have renamed a scraped label or an API response value. Adding rungs would have added three always-zero buckets to the bug-prediction risk distribution, so Severity.score_ladder() pins the grading subset and the endpoints iterate that instead of the enum. All three auto-tools sanitizers stored the member NAME ("HIGH") in a field carrying the value ("high"), so anything comparing against Severity.HIGH.value matched none of them. Fixed, with the icon/order table they each had a private copy of hosted once in tool_base.
✅ SSOT Configuration Compliance: Passing🎉 No new hardcoded values of either class — Known backlog in |
Auto-regenerated (py3.14) to match the backend and/or SLM backend schema so the required verify-generated-types gate(s) pass. Triggered by auto-fix-generated-types.yml.
Applied Black, isort, and autoflake to match code-quality checks. Triggered by workflow auto-fix-formatting.yml.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…frontend types (#14956) Two CI failures on the first head, both from the sweep. code-quality: the regex conversion rewrote a literal inside an endpoint docstring, where a fenced json block documents the response body. That turned documented JSON into something no reader can copy, and left the added import unused. A docstring is prose, so it is exempt from the ratchet rather than a target for it: the literal is restored, the import dropped, and the file joins the deliberate-literal set alongside the other documentation examples. The corrected count is 144 of 169, not 145 - the docstring was never a legitimate conversion. The ceiling moves 24 -> 25 to match the re-measured population, and the allowlist is renamed to say what it actually holds: two qualifying reasons, an external boundary or documentation prose. Added test_no_enum_read_was_written_inside_a_string, which walks every module mentioning the enum by AST and fails if an enum read appears inside a string constant. That is the exact defect CI caught, so it is now pinned rather than remembered. check-drift: Severity gaining three rungs widened the generated TypeScript union, and the checked-in generated file was stale. Regenerated with the repo generator, not hand-edited. No consumer of the generated union switches on it exhaustively: the only typed consumer is AutomationWorkflowStep.risk_level, read as a two-value predicate and a class binding, and every switch over a severity takes a plain string with a default branch. The frontend carries two hand-written forks of this type that have already drifted, filed separately.
…st (#14956) The file enumerates the traps it is built against; the sweep's docstring regression added a fourth and the list did not say so. A trap list that is missing the most recent trap is the kind of stale comment that teaches the next reader the wrong lesson.
Auto-regenerated (py3.14) to match the backend and/or SLM backend schema so the required verify-generated-types gate(s) pass. Triggered by auto-fix-generated-types.yml.
|
Independent review: MERGE, pending the in-flight CI going green. The reviewer re-derived every number rather than trusting the body — ran the guard's own matcher against both base and HEAD (169 hits/43 files at base, 25 at HEAD, distributed exactly as the PR table claims), confirmed
All six One nit, not blocking and no code change needed. The body says |
Thinking Path
The enum-fork half of #13597 already landed in #14957 —
AlertSeverityandErrorSeverityare aliases of the canonical
Severity, and the ratchet inrepo_tests/enum_union_guard_test.pypins that. Confirmed on the base before starting; nothing there was redone and no new enum
was forked. What was left is the literal sweep.
Measured the population rather than trusting it. #13597 said 119, #14956 said 169. Ran
the ratchet's own matcher —
["']severity["']\s*:\s*["'][A-Za-z_]+["']overgit ls-files '*.py'underautobot-backend/andautobot_shared/— and got 169 hitsacross 43 files, 43 of them outside the canonical vocabulary, matching #14956 exactly. The
number is not asserted only in this description:
SEVERITY_LITERAL_FLOORandSEVERITY_LITERAL_CEILINGbound it from both sides in the guard.The 43 out-of-vocabulary literals, decided one at a time. The rule is that a duplicated
concept converges on the implementation that is the union; a rung is never dropped to make
a count go down. So each value was either a genuine synonym of an existing rung, or a rung
the canonical enum was missing.
HIGHMEDIUMCRITICALwarningPerformanceMonitorpublishesupdate_active_alerts("warning", ...)as a Prometheus label, the shipped alert rules carryseverity: warning, and the capability audit countswarningfindings separately fromerror. Folding it tomediumrenames a scraped label.degradedCausalSeverity.DEGRADEDis serialised into the causal-analysis API response byto_dict(), and an existing test assertsresult["severity"] == "degraded".errorerrorvswarningand counts each; they are two rungs, not one.ExtremeNOAASource. #14956 called it "a value no consumer can match"; it is in fact faithful external wire data, and the carve-out for parsed external values applies. Left alone, with the reason recorded in the guard.Adding rungs had a blast radius, and it is closed. Four bug-prediction call sites build
{level.value: 0 for level in RiskLevel}— a shipped API response key set. Left iteratingthe enum, this PR would have quietly added three always-zero buckets to it.
Severity.score_ladder()now names the grading subset, the four sites iterate that, and a test asserts the key tuple
is byte-for-byte what it was before.
Not folded, deliberately.
CausalSeverityis a fourth severity fork whose three membersare now all in canonical
Severity, so it is one alias from collapsing — but it is(str, Enum)where canonical is a plainEnum, and that is precisely the serialisation rule#13597 scope item 3 parked and #14956 explicitly deferred. Collapsing it before that rule is
stated would change serialisation behaviour under existing holders. Filed as #14988 with the
blast-radius audit already done, rather than half-changed here.
What Changed
Converted 144 of 169 (85%). The 25 that remain are ones where a bare literal is the right
thing to write — the value crosses an external boundary, or the line is documentation prose:
monitoring/alertmanager_webhook_test.pyapi/webhook_authentication_security_test.pytests/test_osint_engine.pyautobot_memory_graph/property_graph.pyautobot_memory_graph/property_graph_mixin.pyapi/knowledge_grounding.py```jsonresponse body inside an endpoint docstringThey are an explicit set in the guard now, not an anonymous residue in a count.
autobot_shared/status_enums.py—SeveritygainsWARNING,DEGRADED,ERROR,each placed at its own height so
to_scorestays strictly ascending across all ten rungs.Added
score_ladder()for the grading subset. The docstring records why each of the threewas not folded into a neighbour.
Severity.<RUNG>.value,.valueat every serialisation boundary."HIGH") in a fieldcarrying the value (
"high"), so anything comparing a finding againstSeverity.HIGH.valuematched none of them. Fixed. Each also had a private copy of the same icon table and the
same worst-first ordering list; those now live once in
tool_base.py, keyed on the enum.severity_label()keeps the rendered report text identical — the stored data changed, thereport a human reads did not.
score_ladder(), pinning the response key set.repo_tests/enum_union_guard_test.py— ceiling 169 → 25, plus the new guards below.Verification
The guard can actually fail. Fourteen mutations, each applied to a committed tree, run, and
reverted with
git checkout --; none pushed, tree confirmed clean afterwards:test_no_new_file_carries_a_bare_severity_literalWARNINGfrom the enumtest_severity_is_exactly_the_vocabulary_unionscore_ladder()returns the whole enumtest_the_risk_distribution_keys_did_not_gain_the_added_rungs"CRITICAL"test_no_sanitizer_grades_a_finding_with_a_member_nametest_bare_severity_literals_do_not_growtest_this_guard_does_not_trip_its_own_matchertest_every_externally_sourced_severity_file_still_has_oneseverity_label()echoes the valuetest_the_rendered_report_text_survived_the_value_changetest_the_report_vocabulary_lives_in_one_place_and_reads_canonical_valuesWARNINGaboveHIGHtest_the_severity_scale_is_strictly_ascending_over_the_whole_vocabularytest_no_enum_read_was_written_inside_a_stringtest_the_prose_scan_reaches_the_modules_that_use_the_enumtest_bare_severity_literals_do_not_growtest_no_new_file_carries_a_bare_severity_literalM5 is the important one. With the matcher broken the population goes to 0, and the guard
fails loudly rather than reporting a clean tree:
That is
SEVERITY_LITERAL_FLOOR, asserted before the ceiling. Both traps named in theissue trail are handled: the fixtures that must contain a banned pattern are assembled from
fragments by
_banned_shape(), andtest_this_guard_does_not_trip_its_own_matcherscansthis guard's own source to prove no spelled-out literal crept back in.
Assertions are on behaviour, not source text. The sanitizer guard reads the severity
field by AST and asserts each constant parses as a
Severity— a member name failsbecause
Severity("HIGH")raises, not because a string comparison says so. Therisk-distribution guard builds the dict the endpoints build and asserts the key tuple. The
enum members are pinned as
(name, value)pairs, so a rename is a member-set change andfails by name, matching the shape #14957 established in this file.
Local runs — these characterise this interpreter only (Python 3.10.12, pytest 9.0.2);
CI is authoritative:
repo_tests/enum_union_guard_test.py— 65 passed (was ~40 before this PR)reasoner, property graph, codebase stats, LLC finding proposal 156 passed; LLC findings
gather/verify, prometheus monitor category, knowledge-grounding decode 39 passed;
bug-prediction 62 passed
black --line-length=120 --checkandisort --profile=black --line-length=120clean onall 44 changed files; pre-commit ran on the commit, no
--no-verifyPopulation, re-measured after the change: 169 → 25, and the 25 are exactly the six
allowlisted files.
Second round — the two CI failures on the first head
code-quality(F401). Not just a stray import. The regex conversion had rewritten aliteral inside an endpoint docstring, where a fenced
jsonblock documents the responsebody — turning documented JSON into
Severity.HIGH.value, which no reader can copy, andleaving the import unused. A docstring is prose, so it is exempt from the ratchet rather than
a target for it: the literal is restored, the import dropped, and the file joins the
deliberate-literal set. The repo's auto-formatter had already deleted the import on its own,
which would have left the mangled docstring in place — the import was the symptom.
Audited the whole sweep for the same defect by AST rather than fixing only the reported line:
exactly one file was affected. The corrected count is therefore 144 of 169, not 145,
and the ceiling is 25, not 24 — the stale ceiling would have masked it, so both bounds moved.
test_no_enum_read_was_written_inside_a_stringnow walks every module mentioning the enum andfails if an enum read appears inside a string constant (M11), floored so an empty walk cannot
read as clean (M12).
check-drift. Adding three rungs widened the generated TypeScript union and thechecked-in generated file was stale. Regenerated with the repo's generator, not hand-edited,
and
--checknow reportsOK — generated TS matches committed file.Does the widened union have an unhandled consumer? No. Traced every consumer:
AutomationWorkflowStep.risk_level, readat
WorkflowBuilderView.vueas a two-value predicate (=== 'high' || === 'critical') and aclass binding — not a
switch, not aRecord<>.Record<RiskLevel, …>orRecord<Severity, …>is keyed on the generated union. The oneexhaustive
Record<Severity, string>inFindingsTable.vueis keyed on a locallydeclared
Severityintypes/codeIntelligence.ts, untouched by codegen.switchover a severity takes a plainstring(often?.toLowerCase()) and has adefault:branch — includingFailureAnalysisDashboard.vue, the one placedegradedandwarninggenuinely arrive, which handles both.Narrowing the TS alias was considered and rejected. The backend
score_ladder()guard wasneeded because a shipped response key set would have changed. The TS
RiskLevelis an aliasthe generator emits precisely because Python declares
RiskLevel = Severity; narrowing itwould make the generated types disagree with the canonical enum, which is the drift the
frontend-codegen-driftgate exists to prevent. The generated file stays faithful.That trace did surface real pre-existing drift, filed rather than folded in: #14993 — the
frontend holds three definitions of this type, one of them upper-cased (
'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'intypes/models.ts), which is the same name-where-a-value-belongsdefect this PR fixed on the Python side.
Model Used
Claude Opus 4.5
Closes #14956
Refs #13597rather than closing it: its scope item 3 — stating the plain-Enumvs(str, Enum)rule in thestatus_enumsdocstring, and reconcilingAgentLifecycleStatusand
ConnectionStatus, both of which have committed wire formats — is untouched here.#14956 itself carved that out as "not delivered here". Partial delivery does not close.
Discovered and filed rather than dropped: #14993 (three frontend definitions of this type,
one upper-cased) and #14988 (
CausalSeverityas a fourth fork, plus219 severity literals in two shapes the ratchet's matcher does not see, 20 of them outside
the canonical vocabulary).