diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 26a08418e8..4b74a19ae4 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -279,8 +279,15 @@ jobs: run: uv python install 3.12 - name: Install dependencies run: uv sync --locked --all-groups --all-extras + - name: Fetch pull request base for the new-duplicate check + if: github.event_name == 'pull_request' || github.event_name == 'merge_group' + run: git fetch --no-tags --depth=1 origin "$BASE_SHA" + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha }} - name: Run static checks run: make check-ci + env: + DUPES_BASE: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || 'origin/main' }} - name: Run native checks run: make rust-check - name: Run unit and integration tests diff --git a/Makefile b/Makefile index 1753e39f05..f1bc9ed265 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ SHELL := /bin/bash -.PHONY: verify verify-quick verify-pg coverage cli-preview rust-check check-native-layering check-native-stub check-build-features check-python-boundary check-release-gate check-docs-skill \ +.PHONY: verify verify-quick verify-pg coverage cli-preview rust-check check-native-layering check-dupes-new check-native-stub check-build-features check-python-boundary check-release-gate check-docs-skill \ check-e2e-shards test-e2e-duckdb test-e2e-duckdb-build-core \ test-e2e-duckdb-build-incremental \ test-e2e-duckdb-cli-data test-e2e-duckdb-cli test-e2e-duckdb-inspection \ @@ -562,6 +562,22 @@ dupes: uv run fensu dupes +# Warn mode: report new duplicated code since DUPES_BASE without failing the build. +DUPES_BASE ?= origin/main + +check-dupes-new: + @if ! git rev-parse --verify --quiet "$(DUPES_BASE)^{commit}" >/dev/null; then \ + echo "warning: skipped new-duplicate check: base $(DUPES_BASE) is not available"; \ + exit 0; \ + fi; \ + uv run fensu dupes --since "$(DUPES_BASE)" --fail-on-new; rc=$$?; \ + if [ $$rc -eq 1 ]; then \ + echo "warning: new duplicated code above (warn mode: this check does not fail yet)"; \ + elif [ $$rc -ne 0 ]; then \ + exit $$rc; \ + fi + + check: uv run ruff format . uv run ruff check --fix . @@ -671,7 +687,7 @@ verify-quick: exit $$status -check-ci: check-e2e-shards check-native-layering check-native-stub check-build-features check-python-boundary check-release-gate check-docs-skill rust-lint +check-ci: check-dupes-new check-e2e-shards check-native-layering check-native-stub check-build-features check-python-boundary check-release-gate check-docs-skill rust-lint uv run ruff format --check . uv run ruff check . uv run ty check src tests diff --git a/fensu.toml b/fensu.toml index 80fe3fe0d1..1c38e596d6 100644 --- a/fensu.toml +++ b/fensu.toml @@ -1545,6 +1545,37 @@ warn = ["XSB076", "XSB077"] [targets.rust.rule_options.XSB076] schema_paths = ["crates/sqlbuild-orchestration/src/store"] +allowed = [ + "sqb_admin_initialization.initialized_at=Audit history read through audit exports: when the first administrator was created.", + "sqb_effective_roles.effective_since=Audit history read through audit exports: when a role's resolved grants took effect.", + "sqb_locations.created_by=Audit history read through audit exports: who created the location.", + "sqb_password_credentials.changed_at=Audit history read through audit exports: when a password last changed.", + "sqb_platform_sink_deliveries.delivered_at=Audit history read through audit exports: when an event reached the platform sink.", + "sqb_role_assignments.granted_at=Audit history read through audit exports: when a role was granted.", + "sqb_role_assignments.granted_by=Audit history read through audit exports: who granted a role.", + "sqb_role_assignments.revoked_by=Audit history read through audit exports: who revoked a role.", + "sqb_security_audit_deliveries.delivered_at=Audit history read through audit exports: when an event reached a security audit sink.", + "sqb_warehouse_microbatches.execution_run_id=Reader comes with the UI epic (D132), which filters on it: the run that executed the batch.", + "sqb_warehouse_microbatches.record_type=Reader comes with the UI epic (D132), which filters on it: the batch state.", + "sqb_warehouse_migrations.decision=Reader comes with the UI epic (D132), which filters on it.", + "sqb_warehouse_migrations.destination_model=Reader comes with the UI epic (D132), which filters on it.", + "sqb_warehouse_old_name_views.migration_event_id=Reader comes with the UI epic (D132), which filters on it.", +] + +[targets.rust.rule_options.XSB077] +construction_required = [ + "Action=A permission is live only when a route requires it; role tables and classifiers do not count.", +] +exceptions = [ + "Action::ViewEvidence=Kept under D129; reader comes with the UI epic.", + "InaccessibleReason=Wire enum that serde builds from Python scope records.", + "RoleDecision::Rejected=Serde builds it from an administrator's decide request.", + "ScopeDiagnosticCode=Wire enum that serde builds from Python scope diagnostics.", + "TickOutcome::Covered=The scheduler writes it as SQL text and serde reads it back.", + "TickOutcome::Requested=The scheduler writes it as SQL text and serde reads it back.", + "TickOutcome::Skipped=The scheduler writes it as SQL text and serde reads it back.", + "UsageKind=Wire enum that serde builds from Python usage records.", +] [[targets.rust.rule_exceptions]] rule = "FPRSR307" diff --git a/pyproject.toml b/pyproject.toml index 74496f9d4c..3c2b1f0016 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,7 +81,7 @@ dev = [ "pytest-cov>=7.0.0", "pytest-xdist>=3.8.0", "ruff>=0.12.0", - "fensu>=0.28.3", + "fensu>=0.29.0", "testcontainers>=4.8.1", "ty>=0.0.1a19", ] diff --git a/scripts/fensu_policy/_helpers/unused_state.py b/scripts/fensu_policy/_helpers/unused_state.py index f4fb97c5a6..0571284126 100644 --- a/scripts/fensu_policy/_helpers/unused_state.py +++ b/scripts/fensu_policy/_helpers/unused_state.py @@ -13,8 +13,10 @@ BRACKET_CLOSE, BRACKET_OPEN, LIST_SEPARATOR, + NATIVE_PATH_SEPARATOR, PARENTHESIS_CLOSE, PARENTHESIS_OPEN, + REASON_SEPARATOR, REMOVED_CODE, RUST_ATTRIBUTE_PATTERN, RUST_BARE_WORD_PATTERN, @@ -23,10 +25,17 @@ RUST_IMPL_HEADER_PATTERN, RUST_LINE_CONTINUATION, RUST_LITERAL_PATTERN, + RUST_LOOKBEHIND, RUST_MACRO_INVOCATION_PATTERN, + RUST_MATCHES_MACRO_PATTERN, RUST_PATH_PAIR_PATTERN, + RUST_PATTERN_FOLLOWER_PATTERN, + RUST_PATTERN_LEADER_PATTERN, + RUST_QUALIFIED_VARIANT_PATTERN, + RUST_SELF, RUST_SELF_PATH_PATTERN, RUST_SIMPLE_ESCAPES, + RUST_TABLE_ITEM_PATTERN, RUST_TEST_FILE_NAME, RUST_TEST_MODULE_PATTERN, RUST_TEST_PATH_SEGMENTS, @@ -69,6 +78,11 @@ _SELF_PATH: re.Pattern[str] = re.compile(RUST_SELF_PATH_PATTERN) _VARIANT_IMPORT: re.Pattern[str] = re.compile(RUST_VARIANT_IMPORT_PATTERN) _BARE_WORD: re.Pattern[str] = re.compile(RUST_BARE_WORD_PATTERN) +_QUALIFIED: re.Pattern[str] = re.compile(RUST_QUALIFIED_VARIANT_PATTERN) +_TABLE_ITEM: re.Pattern[str] = re.compile(RUST_TABLE_ITEM_PATTERN) +_MATCHES_MACRO: re.Pattern[str] = re.compile(RUST_MATCHES_MACRO_PATTERN) +_PATTERN_FOLLOWER: re.Pattern[str] = re.compile(RUST_PATTERN_FOLLOWER_PATTERN) +_PATTERN_LEADER: re.Pattern[str] = re.compile(RUST_PATTERN_LEADER_PATTERN) _SQL_COMMENTS: re.Pattern[str] = re.compile(SQL_COMMENT_PATTERN, re.DOTALL) _SQL_TOKENS: re.Pattern[str] = re.compile(SQL_TOKEN_PATTERN) _SQL_WORD: re.Pattern[str] = re.compile(SQL_WORD_PATTERN) @@ -256,19 +270,108 @@ def enum_variants(*, path: str, code: str) -> list[EnumVariant]: return variants -def named_variants(*, variants: list[EnumVariant], codes: dict[str, str]) -> set[EnumVariant]: - """Variants named by a path, by `Self` in an impl's file, or by an imported variant.""" +def named_variants( + *, variants: list[EnumVariant], codes: dict[str, str], strict: frozenset[str] = frozenset() +) -> set[EnumVariant]: + """Variants named in code; a `strict` enum's variants count only where built as a value.""" bodies: dict[str, list[tuple[int, int]]] = {} variant: EnumVariant for variant in variants: bodies.setdefault(variant.path, []).append(variant.body) names: set[tuple[str, str]] = set() + constructed: set[tuple[str, str]] = set() path: str code: str for path, code in codes.items(): - names.update(_file_names(code=_without_spans(text=code, spans=bodies.get(path, [])))) - return {variant for variant in variants if (variant.enum, variant.variant) in names} + visible: str = _without_spans(text=code, spans=bodies.get(path, [])) + names.update(_file_names(code=visible)) + if strict: + constructed.update(_constructed_names(code=visible, strict=strict)) + return { + variant + for variant in variants + if (variant.enum, variant.variant) in (constructed if variant.enum in strict else names) + } + + +def reasoned_entries(*, entries: tuple[str, ...]) -> tuple[dict[str, str], list[str]]: + """`name=reason` option entries by name, and the entries that lack a name or a reason.""" + + reasons: dict[str, str] = {} + malformed: list[str] = [] + entry: str + for entry in entries: + name, _, reason = entry.partition(REASON_SEPARATOR) + if name.strip() and reason.strip(): + reasons[name.strip()] = reason.strip() + else: + malformed.append(entry) + return reasons, malformed + + +def _constructed_names(*, code: str, strict: frozenset[str]) -> set[tuple[str, str]]: + value_code: str = _without_tables(code=code) + implemented: set[str] = set() + header: re.Match[str] + for header in _IMPL_HEADER.finditer(value_code): + implemented.update(set(_SQL_WORD.findall(header.group(0))) & strict) + names: set[tuple[str, str]] = set() + occurrence: re.Match[str] + for occurrence in _QUALIFIED.finditer(value_code): + owner: str = occurrence.group(1) + enums: set[str] = implemented if owner == RUST_SELF else {owner} & strict + if enums and not _pattern_position( + code=value_code, start=occurrence.start(), end=occurrence.end() + ): + names.update(itertools.product(enums, (occurrence.group(2),))) + return names + + +def _without_tables(*, code: str) -> str: + spans: list[tuple[int, int]] = [ + (item.start(), _statement_end(text=code, start=item.end())) + for item in _TABLE_ITEM.finditer(code) + ] + macro: re.Match[str] + for macro in _MATCHES_MACRO.finditer(code): + closing: int = _closing( + text=code, opening=macro.end() - 1, pair=(PARENTHESIS_OPEN, PARENTHESIS_CLOSE) + ) + pieces: list[tuple[int, str]] = _top_level_pieces(text=code, start=macro.end(), end=closing) + if len(pieces) > 1: + spans.append((pieces[1][0], closing)) + return _without_spans(text=code, spans=spans) + + +def _statement_end(*, text: str, start: int) -> int: + depth: int = 0 + offset: int + for offset in range(start, len(text)): + character: str = text[offset] + depth += (character in (PARENTHESIS_OPEN, BRACKET_OPEN, BRACE_OPEN)) - ( + character in (PARENTHESIS_CLOSE, BRACKET_CLOSE, BRACE_CLOSE) + ) + if character == SQL_STATEMENT_SEPARATOR and depth == 0: + return offset + return len(text) + + +def _pattern_position(*, code: str, start: int, end: int) -> bool: + after: int = end + while after < len(code) and code[after].isspace(): + after += 1 + if after < len(code) and code[after] in (PARENTHESIS_OPEN, BRACE_OPEN): + pair: tuple[str, str] = ( + (PARENTHESIS_OPEN, PARENTHESIS_CLOSE) + if code[after] == PARENTHESIS_OPEN + else (BRACE_OPEN, BRACE_CLOSE) + ) + after = _closing(text=code, opening=after, pair=pair) + 1 + return ( + _PATTERN_FOLLOWER.match(code, after) is not None + or _PATTERN_LEADER.search(code[max(0, start - RUST_LOOKBEHIND) : start]) is not None + ) def _file_names(*, code: str) -> set[tuple[str, str]]: @@ -432,3 +535,57 @@ def _column_name(*, piece: str) -> str | None: if first in SQL_COLUMN_CONSTRAINT_WORDS or _SQL_WORD.fullmatch(first) is None: return None return first + + +def allowed_state( + *, definitions: list[StateDefinition], unread: list[StateDefinition], entries: tuple[str, ...] +) -> tuple[list[StateDefinition], list[str]]: + """Unread state not allowlisted, and malformed or stale entries for tables defined here.""" + + reasons: dict[str, str] + malformed: list[str] + reasons, malformed = reasoned_entries(entries=entries) + defined_tables: set[str] = {definition.table for definition in definitions} + unread_keys: set[str] = {_state_key(definition=definition) for definition in unread} + stale: list[str] = [ + f"allowlisted {key} is read or no longer defined; remove the entry" + for key in reasons + if key.partition(SQL_QUALIFIER)[0] in defined_tables and key not in unread_keys + ] + problems: list[str] = [f"allowlist entry {entry!r} needs `name=reason`" for entry in malformed] + kept: list[StateDefinition] = [ + definition for definition in unread if _state_key(definition=definition) not in reasons + ] + return kept, problems + stale + + +def excepted_variants( + *, variants: list[EnumVariant], unnamed: list[EnumVariant], entries: tuple[str, ...] +) -> tuple[list[EnumVariant], list[str]]: + """Unnamed variants not excepted, and problems: malformed or no-longer-needed entries.""" + + reasons: dict[str, str] + malformed: list[str] + reasons, malformed = reasoned_entries(entries=entries) + declared: set[str] = {variant.enum for variant in variants} + covered: set[str] = set() + kept: list[EnumVariant] = [] + variant: EnumVariant + for variant in unnamed: + keys: set[str] = {variant.enum, f"{variant.enum}{NATIVE_PATH_SEPARATOR}{variant.variant}"} + covered.update(keys & set(reasons)) + if not keys & set(reasons): + kept.append(variant) + stale: list[str] = [ + f"exception {key} no longer matches an unnamed variant; remove the entry" + for key in reasons + if key.partition(NATIVE_PATH_SEPARATOR)[0] in declared and key not in covered + ] + problems: list[str] = [f"exception entry {entry!r} needs `name=reason`" for entry in malformed] + return kept, problems + stale + + +def _state_key(*, definition: StateDefinition) -> str: + if definition.column is None: + return definition.table + return f"{definition.table}{SQL_QUALIFIER}{definition.column}" diff --git a/scripts/fensu_policy/constants.py b/scripts/fensu_policy/constants.py index 67e54a61b5..e6f5bcab4e 100644 --- a/scripts/fensu_policy/constants.py +++ b/scripts/fensu_policy/constants.py @@ -444,3 +444,35 @@ SQL_WRITE_WORDS: frozenset[str] = frozenset( {"insert", "into", "values", "update", "set", "delete", "conflict", "do"} ) +REASON_SEPARATOR: str = "=" +RUST_LOOKBEHIND: int = 16 +RUST_MATCHES_MACRO_PATTERN: str = r"\bmatches!\s*\(" +RUST_PATTERN_FOLLOWER_PATTERN: str = r"\s*(?:=>|\|(?!\|)|if\b|==|!=|=(?![=>]))" +RUST_PATTERN_LEADER_PATTERN: str = r"(?:(? list[Fault]: del project @@ -51,7 +60,17 @@ def control_store_unread_state(*, project: Project, ctx: RuleContext) -> list[Fa if not facts.test and not is_rust_test_path(path=facts.file.path.value): fragments.extend(string_literals(source=facts.source)) reads: StateReads = collect_reads(fragments=fragments, definitions=definitions) + unread: list[StateDefinition] + problems: list[str] + unread, problems = allowed_state( + definitions=definitions, + unread=unread_definitions(definitions=definitions, reads=reads), + entries=ctx.option(CONTROL_STORE_ALLOWED), + ) return [ + ctx.fault_for(path=ctx.repo_root / POLICY_CONFIG_FILE, line=1, column=0, message=problem) + for problem in problems + ] + [ ctx.fault_for( path=ctx.repo_root / definition.path, line=definition.line, @@ -63,7 +82,7 @@ def control_store_unread_state(*, project: Project, ctx: RuleContext) -> list[Fa "code" ), ) - for definition in unread_definitions(definitions=definitions, reads=reads) + for definition in unread ] @@ -77,6 +96,7 @@ def control_store_unread_state(*, project: Project, ctx: RuleContext) -> list[Fa "tests and serde input alone do not keep a variant alive." ), analyzers=(AnalyzerId.RUST,), + options=(VARIANT_EXCEPTIONS, VARIANT_CONSTRUCTION_REQUIRED), ) def unconstructed_enum_variant(*, project: Project, ctx: RuleContext) -> list[Fault]: del project @@ -90,14 +110,35 @@ def unconstructed_enum_variant(*, project: Project, ctx: RuleContext) -> list[Fa code: str for path, code in codes.items(): variants.extend(enum_variants(path=path, code=code)) - named: set[EnumVariant] = named_variants(variants=variants, codes=codes) + strict: dict[str, str] + malformed: list[str] + strict, malformed = reasoned_entries(entries=ctx.option(VARIANT_CONSTRUCTION_REQUIRED)) + named: set[EnumVariant] = named_variants( + variants=variants, codes=codes, strict=frozenset(strict) + ) + unnamed: list[EnumVariant] + problems: list[str] + unnamed, problems = excepted_variants( + variants=variants, + unnamed=[variant for variant in variants if variant not in named], + entries=ctx.option(VARIANT_EXCEPTIONS), + ) + problems.extend( + f"construction_required entry {entry!r} needs `name=reason`" for entry in malformed + ) return [ + ctx.fault_for(path=ctx.repo_root / POLICY_CONFIG_FILE, line=1, column=0, message=problem) + for problem in problems + ] + [ ctx.fault_for( path=ctx.repo_root / variant.path, line=variant.line, column=0, - message=f"variant {variant.enum}::{variant.variant} is never constructed or matched", + message=( + f"variant {variant.enum}::{variant.variant} is never constructed" + if variant.enum in strict + else f"variant {variant.enum}::{variant.variant} is never constructed or matched" + ), ) - for variant in variants - if variant not in named + for variant in unnamed ] diff --git a/tests/unit/scripts/fensu_policy/rules/_test_types.py b/tests/unit/scripts/fensu_policy/rules/_test_types.py index ebfe7938f9..9d510452ae 100644 --- a/tests/unit/scripts/fensu_policy/rules/_test_types.py +++ b/tests/unit/scripts/fensu_policy/rules/_test_types.py @@ -72,3 +72,18 @@ class EnumVariantTestCase: description: str files: tuple[tuple[str, str], ...] expected_unnamed: tuple[tuple[str, str], ...] + + +@dataclass(frozen=True) +class StrictVariantTestCase: + description: str + source: str + expected_unnamed: tuple[str, ...] + + +@dataclass(frozen=True) +class ReasonedEntriesTestCase: + description: str + entries: tuple[str, ...] + expected_kept: tuple[object, ...] + expected_problem_count: int diff --git a/tests/unit/scripts/fensu_policy/rules/test_unused_state.py b/tests/unit/scripts/fensu_policy/rules/test_unused_state.py index 4320209d15..a3f482f340 100644 --- a/tests/unit/scripts/fensu_policy/rules/test_unused_state.py +++ b/tests/unit/scripts/fensu_policy/rules/test_unused_state.py @@ -4,18 +4,22 @@ import pytest from scripts.fensu_policy._helpers.unused_state import ( + allowed_state, code_view, collect_reads, enum_variants, + excepted_variants, named_variants, schema_definitions, sql_read_fragments, string_literals, unread_definitions, ) -from scripts.fensu_policy.models import EnumVariant, StateDefinition +from scripts.fensu_policy.models import EnumVariant, StateDefinition, StateReads from tests.unit.scripts.fensu_policy.rules._test_types import ( EnumVariantTestCase, + ReasonedEntriesTestCase, + StrictVariantTestCase, UnreadStateTestCase, ) @@ -218,3 +222,154 @@ def test_given_rust_enums_when_finding_named_variants_then_reports_unnamed_varia assert {(variant.enum, variant.variant) for variant in unnamed} == set( test_case.expected_unnamed ) + + +@pytest.mark.parametrize( + "test_case", + [ + StrictVariantTestCase( + description="tables, patterns, matches! and comparisons do not construct a variant", + source=dedent( + """ + pub enum Permission { View, Run, Export, Manage, Preview, Audit, Cancel } + + const ADMINISTRATIVE: [Permission; 1] = [Permission::Manage]; + + fn label(permission: Permission) -> &'static str { + match permission { Permission::View => "view", _ => "other" } + } + + fn is_read(permission: Permission) -> bool { + matches!(permission, Permission::Preview | Permission::Run) + } + + fn is_audit(permission: Permission) -> bool { permission == Permission::Audit } + + fn routes() { + require(Scope { permission: Permission::Run }); + require_optional(Some(Permission::View)); + let cancel = Permission::Cancel; + } + + fn arms(permission: Permission) { + match permission { Permission::Export | Permission::Cancel => {} _ => {} } + } + """ + ), + expected_unnamed=("Audit", "Export", "Manage", "Preview"), + ), + ], + ids=lambda case: case.description, +) +def test_given_a_strict_enum_when_finding_named_variants_then_only_constructions_count( + test_case: StrictVariantTestCase, +) -> None: + code: str = code_view(source=test_case.source) + variants: list[EnumVariant] = enum_variants(path="crates/shop/src/access.rs", code=code) + named: set[EnumVariant] = named_variants( + variants=variants, + codes={"crates/shop/src/access.rs": code}, + strict=frozenset({"Permission"}), + ) + + assert {variant.variant for variant in set(variants) - named} == set(test_case.expected_unnamed) + + +@pytest.mark.parametrize( + "test_case", + [ + ReasonedEntriesTestCase( + description="an allowlisted unread column is hidden", + entries=("orders.retries=Read through order exports.",), + expected_kept=(("orders", "note_json"), ("order_events", None)), + expected_problem_count=0, + ), + ReasonedEntriesTestCase( + description="an entry without a reason is a problem and hides nothing", + entries=("orders.retries",), + expected_kept=( + ("orders", "retries"), + ("orders", "note_json"), + ("order_events", None), + ), + expected_problem_count=1, + ), + ReasonedEntriesTestCase( + description="an entry for state that is read is stale; one for an absent table is not", + entries=("orders.status=Shown in the UI.", "invoices.total=Belongs to another branch."), + expected_kept=( + ("orders", "retries"), + ("orders", "note_json"), + ("order_events", None), + ), + expected_problem_count=1, + ), + ], + ids=lambda case: case.description, +) +def test_given_allowlist_entries_when_filtering_unread_state_then_stale_and_malformed_entries_report( + test_case: ReasonedEntriesTestCase, +) -> None: + definitions: list[StateDefinition] = schema_definitions( + path="store/schema.sql", sql=ORDERS_SCHEMA + ) + reads: StateReads = collect_reads( + fragments=["SELECT order_id, status, shipped_at FROM orders"], definitions=definitions + ) + kept: list[StateDefinition] + problems: list[str] + kept, problems = allowed_state( + definitions=definitions, + unread=unread_definitions(definitions=definitions, reads=reads), + entries=test_case.entries, + ) + + assert tuple((definition.table, definition.column) for definition in kept) == ( + test_case.expected_kept + ) + assert len(problems) == test_case.expected_problem_count + + +@pytest.mark.parametrize( + "test_case", + [ + ReasonedEntriesTestCase( + description="enum and single-variant exceptions hide only what they name", + entries=( + "Outcome::Cancelled=Written as SQL text.", + "Missing=Belongs to another crate.", + ), + expected_kept=("Outcome::Returned",), + expected_problem_count=0, + ), + ReasonedEntriesTestCase( + description="an exception for a variant that code names is stale", + entries=("Outcome::Pending=Built by serde.",), + expected_kept=("Outcome::Returned", "Outcome::Cancelled"), + expected_problem_count=1, + ), + ], + ids=lambda case: case.description, +) +def test_given_variant_exceptions_when_filtering_unnamed_variants_then_stale_entries_report( + test_case: ReasonedEntriesTestCase, +) -> None: + code: str = code_view( + source="pub enum Outcome { Pending, Returned, Cancelled }\nfn f() -> Outcome { Outcome::Pending }\n" + ) + variants: list[EnumVariant] = enum_variants(path="crates/shop/src/models.rs", code=code) + named: set[EnumVariant] = named_variants( + variants=variants, codes={"crates/shop/src/models.rs": code} + ) + kept: list[EnumVariant] + problems: list[str] + kept, problems = excepted_variants( + variants=variants, + unnamed=list(itertools.filterfalse(named.__contains__, variants)), + entries=test_case.entries, + ) + + assert tuple(f"{variant.enum}::{variant.variant}" for variant in kept) == ( + test_case.expected_kept + ) + assert len(problems) == test_case.expected_problem_count diff --git a/uv.lock b/uv.lock index 95b2e27b10..4132c0a699 100644 --- a/uv.lock +++ b/uv.lock @@ -1343,31 +1343,31 @@ wheels = [ [[package]] name = "fensu" -version = "0.28.3" +version = "0.29.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fensu-cli" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8f/3d/d5b4fb0ad09426dae015af2817ba8416d2ea974a0c4a6ebea63112f90b1b/fensu-0.28.3.tar.gz", hash = "sha256:a7fc87cb60eaef964b4e980d19bd6de9c95cecc75c019d8b0058e02b6751f4b5", size = 539031, upload-time = "2026-11-01T09:41:23.715Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/3b/37b6037b076a1afcc7b415b151f53ba62ee8b1655c20222b965a56a5c925/fensu-0.29.0.tar.gz", hash = "sha256:b2e14bfe5a22047d7d5231f1c186b9514cd3074dbd68071e7012fcd1ced8611b", size = 531167, upload-time = "2026-11-05T05:35:58.655Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/a7/9493fbcf3158ae009651d19c7289855e8ef571dfaf5c7c77154a8a6a4a21/fensu-0.28.3-cp312-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e3e3896f173d2bb676fe04defdd4ebdf4db38aee3ee6e57d7a5a297df32d7c5c", size = 4154274, upload-time = "2026-11-01T09:41:03.614Z" }, - { url = "https://files.pythonhosted.org/packages/a2/71/20fdb7147e268c5dbd9eae0fc9dd99641e416f9e75e8fd067ac089f6db78/fensu-0.28.3-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:d74db7466b2ddd533413d2daf4c704aa4acbcae61e2a0edba2564a4910666707", size = 4004309, upload-time = "2026-11-01T09:41:05.719Z" }, - { url = "https://files.pythonhosted.org/packages/d0/4c/40b01d8e46ff3464eb503529e00ad23eddd2ffbd605282618672d0aff45f/fensu-0.28.3-cp312-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:da5843aa40c7d6398a95b340b3c60d4360b5feddac34960663ee84aec09df8ef", size = 4230261, upload-time = "2026-11-01T09:41:07.481Z" }, - { url = "https://files.pythonhosted.org/packages/e7/7a/243cccd83531c3c86c660b993e77a7014c65d1d3f20c07e45b067dba81cf/fensu-0.28.3-cp312-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:52e1623e614f101c5ada2eda59548ff441b7efe4228813e69bc0f0ef9b3fbca6", size = 4307334, upload-time = "2026-11-01T09:41:09.938Z" }, - { url = "https://files.pythonhosted.org/packages/88/a3/38dbb50d5079a1a5fcaf62bcdd582c12a53609c575a747cb2461e83ddfcf/fensu-0.28.3-cp312-abi3-win_amd64.whl", hash = "sha256:a39c407725ea4d92123414bdbe9f80d5db0b7a412af49082a87378203c9ecb04", size = 3977904, upload-time = "2026-11-01T09:41:11.672Z" }, + { url = "https://files.pythonhosted.org/packages/99/5c/ca84eb56ff799658cb7bd97f71f55e6052330d5b13df260ac543ecda557f/fensu-0.29.0-cp312-abi3-macosx_10_12_x86_64.whl", hash = "sha256:93f1f7a6aa3cb5ee0244c78aa3b49fcbfd3a651d22e6a55680c9d823418d7369", size = 4150776, upload-time = "2026-11-05T05:35:39.663Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/c698086874490c4276d89761de6043c924bbe4490744ef1f99f5cdc47138/fensu-0.29.0-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:aeee18c4fa544338d6598eaf08078869b2925f0356e202e85fd1eb920f0947a1", size = 4000547, upload-time = "2026-11-05T05:35:41.502Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2c/aee170e742ef92b374d8fa18dee21e9daeb370c36d3073a452980f76121c/fensu-0.29.0-cp312-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:976f5020c2e43fb292d4957a110d27df832b922db974ab56f44e0d1a9a369dad", size = 4227183, upload-time = "2026-11-05T05:35:43.101Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/3934ea7d9ce3a3ed7e822d696bd426de5986183c248d1ccc277fb619856f/fensu-0.29.0-cp312-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b76d225db92aa76e1ea2f9caf8eb3ed364bc3b5247bce51aa38f3890284e1ace", size = 4305962, upload-time = "2026-11-05T05:35:44.936Z" }, + { url = "https://files.pythonhosted.org/packages/83/35/0dfcdaf9c649e7f66ccb2dfa1cc866dc540ea23520c4f1dd07e6ea846e0c/fensu-0.29.0-cp312-abi3-win_amd64.whl", hash = "sha256:12576e70874c36b5230d200629244371e685afc09fe696602f86265c2bad3ecb", size = 3972953, upload-time = "2026-11-05T05:35:46.711Z" }, ] [[package]] name = "fensu-cli" -version = "0.28.3" +version = "0.29.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/17/3d0953c33387caa0a6becc9bb02210d4a020eaaf73ef07bdc0c8f8952562/fensu_cli-0.28.3.tar.gz", hash = "sha256:3ba38bedc88a9e6db8563f638aa6ba8649a3e5750de176e22a7b35561d14bd9e", size = 766571, upload-time = "2026-11-01T09:41:25.271Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/2c/112024abb6f5450a62590d111bb451a62c7feaf8828463128dc605412f7e/fensu_cli-0.29.0.tar.gz", hash = "sha256:b041c901a56b7f62504f5ce4716a69c79ee4eb87e8ad86245242ef5e6bdc5169", size = 767433, upload-time = "2026-11-05T05:35:59.823Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/bb/09753cc0584c21af1d76eca7c9a6ea5e2d876d71c079c5505dda93742f3d/fensu_cli-0.28.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5179b032074f4ff5097950c0c0b063fd872cffcd23a4bf8a89c71ab0d3d8074b", size = 7963143, upload-time = "2026-11-01T09:41:13.908Z" }, - { url = "https://files.pythonhosted.org/packages/47/59/663fb61d56172bfb053375e8623a23db389b746052fb00dd087cf6c1a76b/fensu_cli-0.28.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:41077b9ec6e2eb46405af809d1010b26519c8928d4264137ca4c6d47ce81d2e1", size = 7479791, upload-time = "2026-11-01T09:41:16.253Z" }, - { url = "https://files.pythonhosted.org/packages/f7/7c/1a03bd25bc2a231ca03478ba3dfd8f21c909eae564c3df751a8982222beb/fensu_cli-0.28.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ac929d598046846c18eae62c8b75a9a49e9b6bbcadb46bd413a5da96ef6453e2", size = 7854377, upload-time = "2026-11-01T09:41:18.109Z" }, - { url = "https://files.pythonhosted.org/packages/17/5e/8395e4bd4e9e4d282a0949e42e721101127fb30f45cb71d6e5af05329051/fensu_cli-0.28.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9ce453889008381184a416acaf274ffa4a46c7cb96ad4397de264816bb10626a", size = 8316670, upload-time = "2026-11-01T09:41:20.045Z" }, - { url = "https://files.pythonhosted.org/packages/89/40/6e3831cd39b16f163916b36c03010754c1cfb58583fd737517bd544751d5/fensu_cli-0.28.3-py3-none-win_amd64.whl", hash = "sha256:66ec08187a6dcc43392244ba4b7349ee29e8da63fbe7110114468f07a000d577", size = 7710605, upload-time = "2026-11-01T09:41:22.073Z" }, + { url = "https://files.pythonhosted.org/packages/70/e7/c24620ccbde6e866c3e9861857074fe41374e8c65222aff87957ca178f54/fensu_cli-0.29.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7d2a8362b7ed58b7905940b1fa3843bf84ee74927d89e66856ddb6f2b29dad26", size = 7972350, upload-time = "2026-11-05T05:35:48.973Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6c/dd9724e5e3be6a417110ee4e6e55765fcfce1f19bcc993676f69510922a9/fensu_cli-0.29.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:97fd9bc38b671a7023ec1ff8bddd7fae8af9dba836bb4743b62e96aa77d73958", size = 7480670, upload-time = "2026-11-05T05:35:51.259Z" }, + { url = "https://files.pythonhosted.org/packages/8b/dc/5c511a2674745b6c6b89aac59dfdc80f5720f2c245c768da56ee00d36e61/fensu_cli-0.29.0-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bbd160215be622b753e106beec8015b37fd24e4963eb181f1ed8b93c1dd65c3b", size = 7855217, upload-time = "2026-11-05T05:35:53.392Z" }, + { url = "https://files.pythonhosted.org/packages/f7/e1/e3576302f9046da9c976060ecd8a69da3d68111759ade1c85dcd07c887fd/fensu_cli-0.29.0-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:837fc9093705ca1030531dc211b8a55d78cfcbfc7755dd859ddc5599cd48685c", size = 8322729, upload-time = "2026-11-05T05:35:55.224Z" }, + { url = "https://files.pythonhosted.org/packages/8c/df/2c1026d826997c9ff7cb71f7a4cad589f0b2d9bec37a96d3caf553d55992/fensu_cli-0.29.0-py3-none-win_amd64.whl", hash = "sha256:bd11c5b98f8dae326b159cc5c8fff78960ee4ef5c044dfbe15b5df392681f356", size = 7701604, upload-time = "2026-11-05T05:35:56.934Z" }, ] [[package]] @@ -4346,7 +4346,7 @@ dev = [ { name = "dbt-snowflake", specifier = "==1.12.0b1" }, { name = "dbt-sqlserver", specifier = ">=1.9.0,<1.11" }, { name = "dlt", extras = ["duckdb", "filesystem", "sql-database"], specifier = ">=1.28.0,<2" }, - { name = "fensu", specifier = ">=0.28.3" }, + { name = "fensu", specifier = ">=0.29.0" }, { name = "ingestr", specifier = ">=1.1.73" }, { name = "jsonschema", specifier = ">=4.23.0" }, { name = "maturin", specifier = ">=1.9,<2" },