diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c69cb49af..52331974f 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -16,7 +16,7 @@ jobs: # every job passed. See docs/engineering/skills/github-prs.md. name: CI complete if: ${{ always() }} - needs: [test, lint, quality-guards, docker-build, test-integration] + needs: [test, lint, pyright, quality-guards, docker-build, test-integration] runs-on: ubuntu-latest permissions: contents: read @@ -119,6 +119,9 @@ jobs: with: python-version: '3.13' + - name: Install uv (the Pyright check's tests run the pinned Pyright) + uses: astral-sh/setup-uv@v8.1.0 + - name: Test the rules registry tool run: python -m unittest discover -s scripts/rules/tests -t . @@ -128,6 +131,53 @@ jobs: - name: Run quality guards run: python scripts/run_quality_guards.py + pyright: + name: Pyright + # Full CI runs only on the lowest unmerged pull request in a stack. + if: ${{ !github.event.pull_request.stack || github.event.pull_request.stack.base.ref == github.event.pull_request.base.ref }} + runs-on: ubuntu-latest + strategy: + # Each package passes or fails on its own; report every one. + fail-fast: false + matrix: + # Each entry is a uv package, type-checked in its own locked + # environment with the Pyright version pinned in + # scripts/rules/pyright.py. The Pyright rules in rules/registry.toml + # list the same packages, and the rules guard fails when one is + # missing here. + package: + - libs/policyengine-simulation-contract + - libs/policyengine-simulation-observability + - libs/policyengine-stage12-persistence + - libs/policyengine-fastapi + - projects/policyengine-simulation-entry + - projects/policyengine-simulation-gateway + - projects/policyengine-simulation-executor + - projects/policyengine-apis-integ + + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + with: + enable-cache: true + + - name: Generate API clients + # apis-integ installs the generated client from its output directory. + if: matrix.package == 'projects/policyengine-apis-integ' + run: ./scripts/generate-clients.sh + + - name: Run Pyright in the package's locked environment + env: + PACKAGE: ${{ matrix.package }} + run: python -m scripts.rules pyright --sync "$PACKAGE" + docker-build: name: Docker build needs: [test, lint] # Only run if tests and linting pass diff --git a/AGENTS.md b/AGENTS.md index 631b454b7..a8ba0614c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,10 @@ Before adding a Pydantic model or a class that other models inherit from, or moving an import under `if TYPE_CHECKING:`, read `docs/engineering/skills/typing-only-imports.md`. +Before changing a package's Pyright settings, a Pyright baseline or the pinned +Pyright version, when the Pyright check fails, or when adding a package, read +`docs/engineering/skills/type-checking.md`. + ## Development - Use Python 3.13 and `uv`. @@ -77,8 +81,9 @@ same-repository draft PR. - The simulation service lives in `projects/policyengine-simulation-executor`. - API integration tests live in `projects/policyengine-apis-integ`. -- PR CI runs simulation unit tests, Ruff format checks, quality guards - (including the rules registry), Docker build, and local integration tests. +- PR CI runs simulation unit tests, Ruff format and lint checks, Pyright for + every package, quality guards (including the rules registry), Docker build, + and local integration tests. - There is currently no repository-wide changelog fragment requirement. ## Safety Boundaries @@ -99,44 +104,22 @@ To add or change a rule, read `docs/engineering/skills/rules-registry.md`. ### Service and package boundaries +- Checked: `sim.boundaries.sqlalchemy-and-alembic-only` - `sim.boundaries.gateway-executor-decoupled`: The gateway and executor never import each other; they communicate only through the models and state helpers in policyengine-simulation-contract. (doc-only, not checked: follow it yourself) -- `sim.boundaries.sqlalchemy-and-alembic-only`: Code connects to databases only through SQLAlchemy engines and connections, never by importing a database driver directly, and runtime code never changes the schema, which changes only through Alembic migrations. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_database_access_boundaries.py`) - `sim.boundaries.unit-tests-need-no-services`: Unit tests never require real network credentials, Modal, Hugging Face, GCP or deployed services; those seams are mocked. (doc-only, not checked: follow it yourself) ### Stage 12 persistence -- `sim.stage12.deployment-scope`: Main-branch deploys roll out Stage 12 in both environments; a Stage 12-only deploy runs candidate tests and the requested promotion but never redeploys existing Modal resources or runs the existing stack's complete integration suite; the Stage 12 worker can request a Google OIDC token. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py`) -- `sim.stage12.deployment-uses-api-schema`: Stage 12 deployment uses the API-owned schema directly, and the Stage 12 runtime it deploys uses typed DML without raw SQL or schema changes. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py`) -- `sim.stage12.no-migration-chain`: This repository has no schema migration chain (no Alembic); Stage 12 tables are migrated by policyengine-api. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_stage12_schema_authority.py`) -- `sim.stage12.no-production-user-associations`: Stage 12 runtime modules never create production user associations (user_simulations or user_reports). (blocking: pytest `projects/policyengine-simulation-entry/tests/test_stage12_schema_authority.py`) -- `sim.stage12.no-schema-ddl`: Simulation runtimes and the Stage 12 persistence library contain no schema DDL (CREATE, ALTER, DROP or TRUNCATE of tables, types, schemas or databases, or create_all); policyengine-api owns the schema. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_stage12_schema_authority.py`) -- `sim.stage12.persistence-typed-dml-only`: The Stage 12 persistence library and runtime use typed SQLAlchemy Core DML only: no raw SQL text, driver-level SQL or schema operations. (blocking: pytest `libs/policyengine-stage12-persistence/tests/test_source_boundaries.py`) -- `sim.stage12.production-lock-has-db-runtime`: The production lock file includes the direct Stage 12 database runtime dependencies. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py`) +- Checked: `sim.stage12.deployment-scope`, `sim.stage12.deployment-uses-api-schema`, `sim.stage12.no-migration-chain`, `sim.stage12.no-production-user-associations`, `sim.stage12.no-schema-ddl`, `sim.stage12.persistence-typed-dml-only`, `sim.stage12.production-lock-has-db-runtime` ### Deployment pipeline -- `sim.deploy.container-context-minimal`: Container builds exclude local environments and unrelated projects, and the entrypoint container builds for the requested target platform. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py`) -- `sim.deploy.docker-images-build`: The simulation entry and executor Docker images build. (blocking: CI job `docker-build`) -- `sim.deploy.gcloud-without-terraform`: Deployment uses the gcloud-based workflow, not Terraform. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py`) +- Checked: `sim.deploy.container-context-minimal`, `sim.deploy.docker-images-build`, `sim.deploy.gcloud-without-terraform`, `sim.deploy.modal-image-smoke`, `sim.deploy.numerical-sentinel-gate`, `sim.deploy.permission-enforcement-wired`, `sim.deploy.promotion-order-explicit`, `sim.deploy.release-identity-smoke`, `sim.deploy.revision-tags-valid`, `sim.deploy.routing-publication-journaled`, `sim.deploy.shell-scripts-valid`, `sim.deploy.smoke-tests-strict`, `sim.deploy.traffic-changes-exact`, `sim.deploy.workflow-shell-safety` - `sim.deploy.image-smoke-covers-every-image-lib`: The image smoke path filter includes every library mounted into a Modal image, so a change to any of them runs the smoke. (doc-only, not checked: follow it yourself) -- `sim.deploy.modal-image-smoke`: App entrypoints import inside the real Modal images before merge, whenever a library baked into an image changes. (blocking: CI job `image-smoke`) -- `sim.deploy.numerical-sentinel-gate`: An existing-stack deployment computes the numerical release sentinel from the exact bundle it installs, and the candidate and stable services must reproduce it; a failing sentinel check fails its step. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py`) -- `sim.deploy.permission-enforcement-wired`: Simulation Entrypoint permission enforcement defaults to log-only, rejects any value other than 0 or 1, and reaches the candidate revision and every deployed authentication run. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py`) -- `sim.deploy.promotion-order-explicit`: Full-stack promotion order is explicit, and the reusable deployment rejects unexpectedly skipped phases. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py`) -- `sim.deploy.release-identity-smoke`: Deploy gates pass the whole release identity to the entrypoint smoke, which requires every expectation, refuses malformed ones, and rejects a release whose routes, latest pointers, bundle, UK model or UK dataset differ from that identity. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py`) -- `sim.deploy.revision-tags-valid`: Cloud Run revision tags are valid on a workflow's first run and fit the existing service names. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py`) -- `sim.deploy.routing-publication-journaled`: Each routing publication is named by its run, attempt and environment and journaled before it is applied; an always-running finalizer resolves it from the journal with the release-gate outcome, a release reports success only after that finalizer completes, and routing jobs never tolerate a failed command. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py`) -- `sim.deploy.shell-scripts-valid`: Deployment shell scripts have valid shell syntax. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py`) -- `sim.deploy.smoke-tests-strict`: Candidate and stable smoke tests reject a revision mismatch without retrying, and fail if the previous revision does not converge. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py`) -- `sim.deploy.traffic-changes-exact`: Traffic changes target an exact, validated revision, are reversible, and refuse to run over an intervening promotion. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py`) -- `sim.deploy.workflow-shell-safety`: Cloud Run deployment never interpolates GitHub expressions into shell, escapes environment values, and pins secret versions. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py`) ### API contracts and integration -- `sim.contracts.clients-publish-deployed-commit`: Client publication checks out the commit that was actually deployed. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py`) -- `sim.contracts.full-integration-beta-only`: The complete integration suite runs against beta deployments only, never production. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_deployment_assets.py`) -- `sim.contracts.gateway-openapi-golden`: The gateway's OpenAPI spec matches the checked-in golden file; regenerate it deliberately when the API changes. (blocking: pytest `projects/policyengine-simulation-gateway/tests/test_openapi_golden.py`) -- `sim.contracts.local-integration`: Generated-client integration tests pass against the locally built services. (blocking: CI job `test-integration`) +- Checked: `sim.contracts.clients-publish-deployed-commit`, `sim.contracts.full-integration-beta-only`, `sim.contracts.gateway-openapi-golden`, `sim.contracts.local-integration` - `sim.contracts.regenerate-clients`: When public API schemas change, regenerate the clients and run the generated-client integration tests. (doc-only, not checked: follow it yourself) ### Observability @@ -148,40 +131,25 @@ To add or change a rule, read `docs/engineering/skills/rules-registry.md`. ### Weighted microdata -- `sim.microdata.builder-tests-use-weighted-frames`: Output-builder tests build entity frames only through fixtures/weighted_frames.weighted_entity_frame, as MicroDataFrames with non-uniform weights, never as plain pd.DataFrames. (blocking: pytest `projects/policyengine-simulation-executor/tests/test_microdata_weight_boundaries.py`) -- `sim.microdata.weight-stripping-confined`: Raw values are read from a weighted frame or column (np.asarray, np.array, .values, .to_numpy(), .tolist(), pd.DataFrame or pd.Series) only in simulation_output_common.py; there, counting entities is always allowed and every other read is a recorded exception whose list may only shrink. (blocking: pytest `projects/policyengine-simulation-executor/tests/test_microdata_weight_boundaries.py`) -- `sim.microdata.weights-not-aggregated-as-values`: A weight column is never aggregated as a value: never the variable of a weighted aggregator (_sum_output_variable, _change_output_variable, Aggregate, ChangeAggregate) or frame[] followed by a weighted reduction; count entities with _count_output_entities. (blocking: pytest `projects/policyengine-simulation-executor/tests/test_microdata_weight_boundaries.py`) +- Checked: `sim.microdata.builder-tests-use-weighted-frames`, `sim.microdata.weight-stripping-confined`, `sim.microdata.weights-not-aggregated-as-values` - `sim.microdata.weights-not-values`: Microdata columns are weights, values, labels or counts: weights are only summed plainly to count entities or passed as weights=, values are aggregated with their own entity's weights, labels only group or filter, and counts derive from weights. (doc-only, not checked: follow it yourself) ### Code quality and tests -- `sim.quality.ci-cd-scripts-ruff-exemptions`: CI/CD scripts under .github may print their output (T201); every other ruff rule applies to them. (blocking: ruff per-file ignore of T201 for `.github/**`) -- `sim.quality.command-line-scripts-ruff-exemptions`: Command-line scripts under a scripts directory may print their output (T201), run other programs (S603, S607) and use today's date (DTZ011); every other ruff rule applies to them. (blocking: ruff per-file ignore of DTZ011, S603, S607, T201 for `**/scripts/**`) -- `sim.quality.deployed-tests-ruff-exemptions`: The deployed-service test suite (projects/*/authenticated_tests, pytest run against a live candidate) gets the same ruff exemptions as unit tests (sim.quality.tests-ruff-exemptions); every other ruff rule applies to it. (blocking: ruff per-file ignore of ANN, ARG001, ARG002, ARG005, D1, DTZ001, DTZ007, INP001, PLC0415, PLR2004, S101, S102, S105, S106, S108, S301, S603, S607, SLF001, TID253 for `**/authenticated_tests/**`) +- Checked: `sim.quality.ci-cd-scripts-ruff-exemptions`, `sim.quality.command-line-scripts-ruff-exemptions`, `sim.quality.deployed-tests-ruff-exemptions`, `sim.quality.every-package-type-checked`, `sim.quality.pyright-apis-integ`, `sim.quality.pyright-executor`, `sim.quality.pyright-gateway`, `sim.quality.pyright-libraries-and-entry`, `sim.quality.ruff-format-src`, `sim.quality.ruff-lint`, `sim.quality.runtime-evaluated-models-resolve`, `sim.quality.src-programs-ruff-exemptions`, `sim.quality.tests-pass`, `sim.quality.tests-ruff-exemptions`, `sim.quality.unittest-ruff-exemptions` - `sim.quality.every-project-tested`: Every project and library with unit tests runs them in the PR test matrix. (doc-only, not checked: follow it yourself) -- `sim.quality.ruff-format-src`: Source code under projects/*/src and libs/*/src is formatted with ruff format. (blocking: ruff format) - `sim.quality.ruff-format-tests`: Test code is formatted with ruff format too; CI currently checks only src directories. (doc-only, not checked: follow it yourself) -- `sim.quality.ruff-lint`: All Python code passes ruff check with every ruff rule selected (ALL), with no ratchet, except the ignores in ruff.toml: COM812 and ISC001, which conflict with ruff format, and D203 and D213, which conflict with the kept D211 and D212. (blocking: ruff check (ALL)) -- `sim.quality.runtime-evaluated-models-resolve`: Every Pydantic model is runtime-evaluated for ruff: one of its bases, read through the imports of the model's own module, is listed in ruff.toml's runtime-evaluated-base-classes or is a runtime-evaluated class in that same module; and every Pydantic model resolves its annotations once its package is imported. (blocking: pytest `projects/policyengine-simulation-entry/tests/test_runtime_evaluated_annotations.py`, `projects/policyengine-simulation-executor/tests/test_runtime_evaluated_annotations.py`) -- `sim.quality.src-programs-ruff-exemptions`: Programs under src (the Modal deployment utilities, and the OpenAPI and Stage 12 infrastructure commands) may print their output (T201), open URLs from their arguments (S310) and, for the Modal utilities run with python -m, live in a directory that isn't a package (INP001); every other ruff rule applies to them. (blocking: ruff per-file ignore of INP001, S310, T201 for `projects/*/src/{modal/utils/*,policyengine_simulation_entry/generate_openapi,policyengine_simulation_entry/stage12_infrastructure,policyengine_simulation_gateway/generate_openapi}.py`) -- `sim.quality.tests-pass`: Each project's unit tests pass in its own locked environment. (blocking: CI job `test`) -- `sim.quality.tests-ruff-exemptions`: Test files may use pytest's bare assert, need no docstrings, annotations or named constants for magic numbers, and may reach into private members, live outside packages and import inside a test (S101, D1, ANN, PLR2004, SLF001, INP001, PLC0415). They may also run repository scripts (S603, S607) and use fake credentials (S105, S106), temporary paths (S108), naive datetimes (DTZ001, DTZ007), pickle (S301) and exec (S102), may import the lazily loaded packages at module level (TID253), and may leave arguments unused in fakes and stubs that mirror a real signature (ARG001, ARG002, ARG005). Every other ruff rule applies to tests. (blocking: ruff per-file ignore of ANN, ARG001, ARG002, ARG005, D1, DTZ001, DTZ007, INP001, PLC0415, PLR2004, S101, S102, S105, S106, S108, S301, S603, S607, SLF001, TID253 for `**/tests/**`) -- `sim.quality.unittest-ruff-exemptions`: The shared rules tool's tests use unittest, because the tool needs only the standard library, so ruff's pytest-style rules (PT) don't apply to them. (blocking: ruff per-file ignore of PT for `scripts/rules/tests/**`) ### CI workflows and scripts -- `sim.ci.multiline-run-is-script`: A GitHub Actions run: block holds one line of code and no chained commands (&&, ||, ;); anything more lives in a tested script under .github/scripts/, called from a single-line step. (blocking: quality guard `ci-scripts`) -- `sim.ci.shell-scripts-pass-shellcheck`: Every shell script under .github/scripts/ passes shellcheck. (blocking: quality guard `ci-scripts`) -- `sim.ci.workflow-scripts-tested`: Every shell script a workflow calls is run by at least one test. (blocking: quality guard `ci-scripts`) +- Checked: `sim.ci.multiline-run-is-script`, `sim.ci.shell-scripts-pass-shellcheck`, `sim.ci.workflow-scripts-tested` ### Process and repository hygiene -- `sim.agents.adapters-point-to-agents-md`: CLAUDE.md and .github/copilot-instructions.md only point to AGENTS.md; skills and rules are listed in AGENTS.md alone. (blocking: quality guard `rules-registry`) +- Checked: `sim.agents.adapters-point-to-agents-md`, `sim.process.rules-registry`, `sim.process.stacked-prs-full-ci-at-bottom` - `sim.process.agents-never-bypass`: Agents never bypass branch rules, even with admin credentials: no gh pr merge --admin and no bypass option; a blocked merge is reported to a human. (doc-only, not checked: follow it yourself) - `sim.process.pr-tracking-issue`: Every PR has a tracking issue in this repository, and the first line of its description is Fixes #ISSUE_NUMBER. (doc-only, not checked: follow it yourself) - `sim.process.rule-machinery-code-owned`: Changes to the rule machinery (rules/, scripts/rules/, scripts/run_quality_guards.py and CODEOWNERS) need approval from a core developer other than the author. (doc-only, not checked: follow it yourself) -- `sim.process.rules-registry`: Every rule is registered in rules/registry.toml, is enforced where it claims to be, and its rendered views are current. (blocking: quality guard `rules-registry`) - `sim.process.same-repo-draft-prs`: PRs are opened as drafts from branches in PolicyEngine/policyengine-sim-api, never from forks. (doc-only, not checked: follow it yourself) -- `sim.process.stacked-prs-full-ci-at-bottom`: In a stack of pull requests, only the lowest unmerged one runs the full PR workflow and the Modal image smoke. Every pr.yml job is needed by the CI complete gate, which fails on the pull requests above it and otherwise passes only when every job passed, so a stack merges bottom-up and nothing merges untested. (blocking: pytest `projects/policyengine-simulation-executor/tests/test_ci_complete_gate.py`) diff --git a/Makefile b/Makefile index dd81d034b..a886fb14e 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # Simplified Makefile using docker-compose -.PHONY: help dev up down build test clean logs format check push-pr-branch +.PHONY: help dev up down build test clean logs format check require-ruff-version push-pr-branch # Load environment variables if .env exists ifneq (,$(wildcard deployment/.env)) @@ -30,13 +30,19 @@ help: @echo "Maintenance:" @echo " make build - Build Docker images" @echo " make format - Format code with ruff" - @echo " make check - ruff check + pyright" + @echo " make check - CI's ruff checks + Pyright in each package" @echo " make update - uv lock --upgrade across packages" @echo " make clean - Remove caches and stop containers" @echo " make push-pr-branch - Push current branch to origin for a PR" @echo "" @echo "Deployment is automated via GitHub Actions to Cloud Run and Modal; there is no manual deploy target." +# CI's ruff, read from the lint job's `uv tool install ruff==` step in +# .github/workflows/pr.yml so the two can't drift. A ruff found on PATH may be +# another version, which selects different rules under select = ["ALL"]. +RUFF_VERSION := $(shell sed -n 's/.*uv tool install ruff==\([0-9][0-9.]*\).*/\1/p' .github/workflows/pr.yml | head -n 1) +RUFF := uv tool run ruff@$(RUFF_VERSION) + # Development commands dev: docker-compose -f deployment/docker-compose.yml up --build @@ -138,24 +144,33 @@ update: @echo "✅ All dependencies updated" # Code quality -format: - @echo "Formatting code with ruff..." +format: require-ruff-version + @echo "Formatting code with ruff $(RUFF_VERSION)..." @for dir in projects/*/src libs/*/src; do \ if [ -d "$$dir" ]; then \ echo "Formatting $$dir..."; \ - uv run ruff format $$dir; \ + $(RUFF) format $$dir; \ fi \ done -check: - @echo "Running code quality checks..." - @for dir in projects/*/src libs/*/src; do \ - if [ -d "$$dir" ]; then \ - echo "Checking $$dir..."; \ - uv run ruff check $$dir; \ - uv run pyright $$dir; \ - fi \ - done +# Pyright runs in each package's own locked environment, with the version CI +# pins; apis-integ needs the generated client. See +# docs/engineering/skills/type-checking.md. +# The ruff commands are the lint job's, so a passing make check means the job +# passes too. +check: require-ruff-version generate-clients + @echo "Running code quality checks (ruff $(RUFF_VERSION))..." + @status=0; \ + $(RUFF) format --check projects/*/src libs/*/src || status=1; \ + $(RUFF) check . || status=1; \ + python3 -m scripts.rules pyright --sync || status=1; \ + exit $$status + +require-ruff-version: + @if [ -z "$(RUFF_VERSION)" ]; then \ + echo "Could not read the ruff version from .github/workflows/pr.yml (uv tool install ruff==)"; \ + exit 1; \ + fi push-pr-branch: @BRANCH=$$(git branch --show-current); \ diff --git a/docs/engineering/skills/observability.md b/docs/engineering/skills/observability.md index 4f9e6b1c5..2134d896f 100644 --- a/docs/engineering/skills/observability.md +++ b/docs/engineering/skills/observability.md @@ -79,6 +79,11 @@ All span names for supported run configurations are defined in appropriate `StagePlan` and call `plan.name(Stage.VALUE)`. Do not add span name string literals in services or workers. +Open a span with `scopes.span(runtime, plan.name(Stage.VALUE))` from +`policyengine_simulation_observability.scopes` rather than `runtime.span(...)`. +It returns the same scope, typed so that Pyright knows an exception raised in +the block propagates; see `docs/engineering/skills/type-checking.md`. + When adding a run configuration or runtime stage: 1. add it to `RunConfiguration` or `Stage`; diff --git a/docs/engineering/skills/rules-registry.md b/docs/engineering/skills/rules-registry.md index 32a6028ff..b81892a91 100644 --- a/docs/engineering/skills/rules-registry.md +++ b/docs/engineering/skills/rules-registry.md @@ -58,7 +58,7 @@ gap is visible, rather than leaving it out. | `[rule.proof]` | Optional; see "Proof". | | `[rule.baseline]` | Only for `ratcheted` rules: `file` listing tolerated violations, and their `count`. | | `[[rule.exception]]` | Temporary carve-out: `path`, `reason`, `expires`. | -| `[rule.downgrade]` | Required `reason` when a rule moves from enforced/ratcheted to advisory/doc-only. | +| `[rule.downgrade]` | Required `reason` when a rule moves from enforced/ratcheted to advisory/doc-only, or from enforced to ratcheted. | `blocking` is derived, never declared: a rule blocks merges when its status is `enforced` or `ratcheted` and its tier is not `judgment`. @@ -72,7 +72,8 @@ gap is visible, rather than leaving it out. | `ruff` | `mode` = `format`, `lint` (with `codes`) or `per-file-ignore` (with `pattern`, `codes`) | Codes are selected / the ignore entry exists and covers only [files allowed file-level exceptions](#file-level-exceptions) | | `mypy` | `paths` | Each path is in `[tool.mypy].files` | | `workflow-job` | optional `step` | The CI job exists (the job itself is the check) | -| `pyright`, `import-linter` | `config` (+ `contract` for Import Linter) | The config file exists | +| `pyright` | `projects`: the package directories it checks | Each package exists, is listed by no other Pyright rule, has no `pyrightconfig.json`, lists what Pyright checks in its `pyproject.toml` `[tool.pyright] include`, and is in the CI job's matrix | +| `import-linter` | `config`, `contract` | The config file exists | | `docs-guard` | `doc`, `marker` | The doc exists | | `llm-review` | `prompt`, `model` | The prompt exists; judgment tier only | | `none` | | Nothing; only for `doc-only` and `advisory` rules | @@ -125,8 +126,8 @@ fail the guard yet. - Other enforcers (mypy, Pyright, Import Linter, custom guards) may start `ratcheted` with a baseline file of tolerated violations. Baselines may only shrink: the guard compares counts against the base branch. -- Moving a rule from enforced/ratcheted to advisory/doc-only needs an explicit - `[rule.downgrade] reason`. +- Moving a rule from enforced/ratcheted to advisory/doc-only, or from enforced + to ratcheted, needs an explicit `[rule.downgrade] reason`. ## File-level exceptions diff --git a/docs/engineering/skills/type-checking.md b/docs/engineering/skills/type-checking.md new file mode 100644 index 000000000..bef001d5b --- /dev/null +++ b/docs/engineering/skills/type-checking.md @@ -0,0 +1,121 @@ +# Type Checking + +Read this before changing a package's Pyright settings, its Pyright baseline or +the pinned Pyright version, when the Pyright check fails, or when adding a +package. + +## Rules + +Every uv package is type-checked with Pyright on every pull request, by the +`pyright` job in `.github/workflows/pr.yml`: + +- `sim.quality.pyright-libraries-and-entry`: the contract, observability, + Stage 12 persistence and fastapi libraries and the simulation entry have no + Pyright errors. +- `sim.quality.pyright-gateway`, `sim.quality.pyright-executor` and + `sim.quality.pyright-apis-integ`: these packages may have only the errors + their baselines list (see [Baselines](#baselines)). +- `sim.quality.every-package-type-checked`: a Pyright rule lists every uv + package under `libs/` and `projects/`. When you add a package, give it a + `[tool.pyright] include`, add it to a Pyright rule's `enforcer.projects`, and + add it to the `pyright` job's matrix. + +## Running the check + +From the repository root: + +```bash +make check # CI's ruff, then Pyright in every package +python -m scripts.rules pyright --sync projects/policyengine-simulation-gateway +python -m scripts.rules pyright --list-packages +``` + +`--sync` first runs `uv sync --frozen --all-extras` in the package; leave it +out to check the environment as it is. apis-integ installs the generated API +client, so run `./scripts/generate-clients.sh` before checking it (`make check` +does). + +The check: + +- runs one pinned Pyright version, `PYRIGHT_VERSION` in + `scripts/rules/pyright.py`, through `uv tool run`; +- points it at the interpreter of the package's locked environment + (`/.venv/bin/python`), so imports resolve against the package's own + dependencies; +- checks the paths in the package's `[tool.pyright] include`. Without an + `include`, Pyright would check the whole directory, tests included. The rules + guard fails when a package a Pyright rule lists has none, has a + `pyrightconfig.json` (which Pyright would read instead), is listed by another + Pyright rule, or is missing from the `pyright` job's matrix. + +Other `[tool.pyright]` settings can weaken the check without failing anything, +for example `ignore`, a lower `typeCheckingMode` or a rule set to `"none"`. +`pyproject.toml` files aren't code-owned, so review a change to them as you +would a rule change. + +### Why one pinned version + +Each package's `build` extra also installs Pyright, for editors, and its lock +file pin can differ from package to package (1.1.409 to 1.1.414 when the check +was added). CI ignores those pins: + +- Pyright's messages change between releases. For the same executor error, + 1.1.409 prints `Series | Unknown | DataFrame` and 1.1.414 prints + `Series | Unknown | Self@DataFrame`, and baseline entries quote messages. +- The daily dependency update relocks every package with `--upgrade`, so the + locked versions move whenever Pyright releases. A dependency update shouldn't + change type-check results, just as a ruff release can't (ruff is pinned in + `pr.yml`). + +To upgrade Pyright, change `PYRIGHT_VERSION` in its own pull request, and fix +the errors the new version reports. + +## Baselines + +A ratcheted package's baseline, `rules/baselines/pyright-.txt`, lists +the errors it may still have, one line per error, sorted: + +```text + :: :: +``` + +Entries carry no line numbers, so editing elsewhere in a file doesn't change +them. The check fails on an error the baseline doesn't list, and on an entry +for an error that no longer occurs, so each fix removes its entry in the same +commit and the baseline only shrinks. + +After fixing errors: + +1. run `python -m scripts.rules pyright --write-baseline` (without a + package, it does every ratcheted package). It rewrites the baseline as the + errors that occur now, and refuses when one of them isn't already listed; +2. set the rule's `[rule.baseline] count` in `rules/registry.toml` to the + number of entries left. + +What stops a baseline growing is the rules guard, which compares counts. It +fails when the count doesn't match the file, when the count is higher than on +the base branch, and when an enforced rule becomes ratcheted without a +`[rule.downgrade] reason`. It doesn't compare entries: a change that fixes one +error and adds another keeps the count and passes the guard, so code-owner +review of `rules/` is what catches that. `--write-baseline` refuses an error +an existing baseline doesn't list, but records every current error when the +file doesn't exist yet, which is how a package is first ratcheted. + +Once a baseline is empty, delete it and make the rule `enforced`. + +Baselines are under `rules/`, so changing one needs code-owner approval +(`sim.process.rule-machinery-code-owned`). + +## Fixing errors + +- Prefer narrowing a type (an `isinstance` check, an early return) or + correcting an annotation to `cast`. +- Suppress an error only as a last resort, with the rule and the reason on the + same line: `# pyright: ignore[reportAttributeAccessIssue] # `. +- If an error shows a real bug, first write a test that demonstrates it, then + fix it. +- Open observability spans with `scopes.span(runtime, ...)` from + `policyengine_simulation_observability.scopes`, not `runtime.span(...)`. + policyengine-observability types its scope managers as possibly suppressing + exceptions, so after `with runtime.span(...)` Pyright reports each variable + the block assigned as possibly unbound. diff --git a/docs/generated/rules.md b/docs/generated/rules.md index 7d2a09995..03836cba6 100644 --- a/docs/generated/rules.md +++ b/docs/generated/rules.md @@ -8,11 +8,11 @@ Every rule that governs `policyengine-sim-api` is listed here with how it is enf | Tier | enforced | ratcheted | advisory | doc-only | Total | | --- | ---: | ---: | ---: | ---: | ---: | -| mechanical | 43 | 2 | 0 | 15 | 60 | +| mechanical | 45 | 5 | 0 | 15 | 65 | | metric | 0 | 0 | 0 | 0 | 0 | | judgment | 0 | 0 | 0 | 0 | 0 | -**Blocking:** 45 of 60 rules. +**Blocking:** 50 of 65 rules. ## Gaps @@ -67,6 +67,9 @@ Every rule that governs `policyengine-sim-api` is listed here with how it is enf - `sim.ci.multiline-run-is-script`: 13 remaining in `rules/baselines/ci-multiline-run.txt` - `sim.microdata.weight-stripping-confined`: 1 remaining in `rules/baselines/sim-microdata-weight-stripping.txt` +- `sim.quality.pyright-apis-integ`: 4 remaining in `rules/baselines/pyright-apis-integ.txt` +- `sim.quality.pyright-executor`: 178 remaining in `rules/baselines/pyright-executor.txt` +- `sim.quality.pyright-gateway`: 81 remaining in `rules/baselines/pyright-gateway.txt` ### Exceptions and review dates @@ -282,7 +285,12 @@ flowchart LR | `sim.quality.ci-cd-scripts-ruff-exemptions` | CI/CD scripts under .github may print their output (T201); every other ruff rule applies to them. | mechanical | enforced | yes | ruff per-file ignore of T201 for `.github/**` | `pr.yml:lint` | — | | `sim.quality.command-line-scripts-ruff-exemptions` | Command-line scripts under a scripts directory may print their output (T201), run other programs (S603, S607) and use today's date (DTZ011); every other ruff rule applies to them. | mechanical | enforced | yes | ruff per-file ignore of DTZ011, S603, S607, T201 for `**/scripts/**` | `pr.yml:lint` | — | | `sim.quality.deployed-tests-ruff-exemptions` | The deployed-service test suite (projects/*/authenticated_tests, pytest run against a live candidate) gets the same ruff exemptions as unit tests (sim.quality.tests-ruff-exemptions); every other ruff rule applies to it. | mechanical | enforced | yes | ruff per-file ignore of ANN, ARG001, ARG002, ARG005, D1, DTZ001, DTZ007, INP001, PLC0415, PLR2004, S101, S102, S105, S106, S108, S301, S603, S607, SLF001, TID253 for `**/authenticated_tests/**` | `pr.yml:lint` | — | +| `sim.quality.every-package-type-checked` | Every uv package under libs/ and projects/ (a directory with a pyproject.toml) is listed by a Pyright rule, so the pyright CI job type-checks it. | mechanical | enforced | yes | quality guard `pyright-coverage` | `pr.yml:quality-guards` | yes | | `sim.quality.every-project-tested` | Every project and library with unit tests runs them in the PR test matrix. | mechanical | doc-only | no | nothing | — | — | +| `sim.quality.pyright-apis-integ` | The generated-client integration-test project, apis-integ, has no Pyright errors beyond those its baseline lists, and the baseline lists only errors that still occur, so it only shrinks; checked with the pinned Pyright version in the project's own locked environment. | mechanical | ratcheted | yes | pyright in `projects/policyengine-apis-integ` | `pr.yml:pyright` | yes | +| `sim.quality.pyright-executor` | The simulation executor has no Pyright errors beyond those its baseline lists, and the baseline lists only errors that still occur, so it only shrinks; checked with the pinned Pyright version in the executor's own locked environment. | mechanical | ratcheted | yes | pyright in `projects/policyengine-simulation-executor` | `pr.yml:pyright` | yes | +| `sim.quality.pyright-gateway` | The simulation gateway has no Pyright errors beyond those its baseline lists, and the baseline lists only errors that still occur, so it only shrinks; checked with the pinned Pyright version in the gateway's own locked environment. | mechanical | ratcheted | yes | pyright in `projects/policyengine-simulation-gateway` | `pr.yml:pyright` | yes | +| `sim.quality.pyright-libraries-and-entry` | The contract, observability, Stage 12 persistence and fastapi libraries and the simulation entry have no Pyright errors, checked with the pinned Pyright version in each package's own locked environment. | mechanical | enforced | yes | pyright in `libs/policyengine-simulation-contract`, `libs/policyengine-simulation-observability`, `libs/policyengine-stage12-persistence`, `libs/policyengine-fastapi`, `projects/policyengine-simulation-entry` | `pr.yml:pyright` | yes | | `sim.quality.ruff-format-src` | Source code under projects/*/src and libs/*/src is formatted with ruff format. | mechanical | enforced | yes | ruff format | `pr.yml:lint` | — | | `sim.quality.ruff-format-tests` | Test code is formatted with ruff format too; CI currently checks only src directories. | mechanical | doc-only | no | nothing | — | — | | `sim.quality.ruff-lint` | All Python code passes ruff check with every ruff rule selected (ALL), with no ratchet, except the ignores in ruff.toml: COM812 and ISC001, which conflict with ruff format, and D203 and D213, which conflict with the kept D211 and D212. | mechanical | enforced | yes | ruff check (ALL) | `pr.yml:lint` | — | @@ -301,8 +309,20 @@ flowchart LR e_ruff_per_file_ignore_of_DTZ011__S603__S607__T201_for____scripts___["ruff per-file ignore of DTZ011, S603, S607, T201 for **/scripts/**"] r_sim_quality_deployed_tests_ruff_exemptions("sim.quality.deployed-tests-ruff-exemptions (enforced)") e_ruff_per_file_ignore_of_ANN__ARG001__ARG002__ARG005__D1__DTZ001__DTZ007__INP001__PLC0415__PLR2004__S101__S102__S105__S106__S108__S301__S603__S607__SLF001__TID253_for____authenticated_tests___["ruff per-file ignore of ANN, ARG001, ARG002, ARG005, D1, DTZ001, DTZ007, INP001, PLC0415, PLR2004, S101, S102, S105, S106, S108, S301, S603, S607, SLF001, TID253 for **/authenticated_tests/**"] + r_sim_quality_every_package_type_checked("sim.quality.every-package-type-checked (enforced)") + e_quality_guard_pyright_coverage["quality guard pyright-coverage"] + j_CI_pr_yml_quality_guards["CI pr.yml:quality-guards"] r_sim_quality_every_project_tested("sim.quality.every-project-tested (doc-only)") gap_unenforced>"not enforced"] + r_sim_quality_pyright_apis_integ("sim.quality.pyright-apis-integ (ratcheted)") + e_pyright_in_projects_policyengine_apis_integ["pyright in projects/policyengine-apis-integ"] + j_CI_pr_yml_pyright["CI pr.yml:pyright"] + r_sim_quality_pyright_executor("sim.quality.pyright-executor (ratcheted)") + e_pyright_in_projects_policyengine_simulation_executor["pyright in projects/policyengine-simulation-executor"] + r_sim_quality_pyright_gateway("sim.quality.pyright-gateway (ratcheted)") + e_pyright_in_projects_policyengine_simulation_gateway["pyright in projects/policyengine-simulation-gateway"] + r_sim_quality_pyright_libraries_and_entry("sim.quality.pyright-libraries-and-entry (enforced)") + e_pyright_in_libs_policyengine_simulation_contract__libs_policyengine_simulation_observability__libs_policyengine_stage12_persistence__libs_policyengine_fastapi__projects_policyengine_simulation_entry["pyright in libs/policyengine-simulation-contract, libs/policyengine-simulation-observability, libs/policyengine-stage12-persistence, libs/policyengine-fastapi, projects/policyengine-simulation-entry"] r_sim_quality_ruff_format_src("sim.quality.ruff-format-src (enforced)") e_ruff_format["ruff format"] r_sim_quality_ruff_format_tests("sim.quality.ruff-format-tests (doc-only)") @@ -325,7 +345,17 @@ flowchart LR e_ruff_per_file_ignore_of_DTZ011__S603__S607__T201_for____scripts___ --> j_CI_pr_yml_lint r_sim_quality_deployed_tests_ruff_exemptions --> e_ruff_per_file_ignore_of_ANN__ARG001__ARG002__ARG005__D1__DTZ001__DTZ007__INP001__PLC0415__PLR2004__S101__S102__S105__S106__S108__S301__S603__S607__SLF001__TID253_for____authenticated_tests___ e_ruff_per_file_ignore_of_ANN__ARG001__ARG002__ARG005__D1__DTZ001__DTZ007__INP001__PLC0415__PLR2004__S101__S102__S105__S106__S108__S301__S603__S607__SLF001__TID253_for____authenticated_tests___ --> j_CI_pr_yml_lint + r_sim_quality_every_package_type_checked --> e_quality_guard_pyright_coverage + e_quality_guard_pyright_coverage --> j_CI_pr_yml_quality_guards r_sim_quality_every_project_tested -.-> gap_unenforced + r_sim_quality_pyright_apis_integ --> e_pyright_in_projects_policyengine_apis_integ + e_pyright_in_projects_policyengine_apis_integ --> j_CI_pr_yml_pyright + r_sim_quality_pyright_executor --> e_pyright_in_projects_policyengine_simulation_executor + e_pyright_in_projects_policyengine_simulation_executor --> j_CI_pr_yml_pyright + r_sim_quality_pyright_gateway --> e_pyright_in_projects_policyengine_simulation_gateway + e_pyright_in_projects_policyengine_simulation_gateway --> j_CI_pr_yml_pyright + r_sim_quality_pyright_libraries_and_entry --> e_pyright_in_libs_policyengine_simulation_contract__libs_policyengine_simulation_observability__libs_policyengine_stage12_persistence__libs_policyengine_fastapi__projects_policyengine_simulation_entry + e_pyright_in_libs_policyengine_simulation_contract__libs_policyengine_simulation_observability__libs_policyengine_stage12_persistence__libs_policyengine_fastapi__projects_policyengine_simulation_entry --> j_CI_pr_yml_pyright r_sim_quality_ruff_format_src --> e_ruff_format e_ruff_format --> j_CI_pr_yml_lint r_sim_quality_ruff_format_tests -.-> gap_unenforced diff --git a/libs/policyengine-fastapi/pyproject.toml b/libs/policyengine-fastapi/pyproject.toml index 92b985d0b..0eeed269d 100644 --- a/libs/policyengine-fastapi/pyproject.toml +++ b/libs/policyengine-fastapi/pyproject.toml @@ -39,3 +39,7 @@ pythonpath = [ "src" ] addopts = "--cov=policyengine_fastapi --cov-report=term-missing --cov-fail-under=30" + +[tool.pyright] +# The pr.yml pyright job checks these paths: python -m scripts.rules pyright. +include = ["src"] diff --git a/libs/policyengine-simulation-contract/pyproject.toml b/libs/policyengine-simulation-contract/pyproject.toml index 7db8a0aab..7214eee3b 100644 --- a/libs/policyengine-simulation-contract/pyproject.toml +++ b/libs/policyengine-simulation-contract/pyproject.toml @@ -46,3 +46,7 @@ pythonpath = [ "src" ] testpaths = ["tests"] + +[tool.pyright] +# The pr.yml pyright job checks these paths: python -m scripts.rules pyright. +include = ["src"] diff --git a/libs/policyengine-simulation-observability/pyproject.toml b/libs/policyengine-simulation-observability/pyproject.toml index fa8a65533..15f8048ca 100644 --- a/libs/policyengine-simulation-observability/pyproject.toml +++ b/libs/policyengine-simulation-observability/pyproject.toml @@ -31,3 +31,7 @@ pythonpath = [ "src" ] testpaths = ["tests"] + +[tool.pyright] +# The pr.yml pyright job checks these paths: python -m scripts.rules pyright. +include = ["src"] diff --git a/libs/policyengine-simulation-observability/src/policyengine_simulation_observability/scopes.py b/libs/policyengine-simulation-observability/src/policyengine_simulation_observability/scopes.py new file mode 100644 index 000000000..c79bc552c --- /dev/null +++ b/libs/policyengine-simulation-observability/src/policyengine_simulation_observability/scopes.py @@ -0,0 +1,37 @@ +"""The observability runtime's span scope, typed as never suppressing an exception. + +policyengine-observability annotates its scope managers' ``__exit__`` as +returning ``bool``, although it always returns ``False``. A type checker reads a +``bool`` result as "this context manager may suppress the exception raised in +its block", so after ``with runtime.span(...)`` Pyright reports every variable +the block assigned as possibly unbound, and a function that returns from inside +the block as missing a return. + +``span`` returns the runtime's own scope unchanged, typed as a context manager +whose ``__exit__`` returns ``bool | None``: the type ``contextlib.contextmanager`` +scopes have, which type checkers don't treat as suppressing exceptions. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Mapping + from contextlib import AbstractContextManager + + from policyengine_observability import ObservabilityRuntime + + +def span( + runtime: ObservabilityRuntime, + name: str, + *, + attributes: Mapping[str, Any] | None = None, +) -> AbstractContextManager[object]: + """Return ``runtime.span(name, attributes=attributes)``. + + The scope times its block as a child span, records an exception raised in + the block, and lets that exception propagate. + """ + return runtime.span(name, attributes=attributes) diff --git a/libs/policyengine-simulation-observability/tests/test_scopes.py b/libs/policyengine-simulation-observability/tests/test_scopes.py new file mode 100644 index 000000000..c86cad316 --- /dev/null +++ b/libs/policyengine-simulation-observability/tests/test_scopes.py @@ -0,0 +1,63 @@ +"""Tests for the typed span scope of the observability runtime.""" + +from contextlib import nullcontext + +import pytest +from policyengine_simulation_observability import scopes +from policyengine_simulation_observability.observability import build_runtime + + +class RecordingRuntime: + """Records the spans it is asked to open, and enters each with a marker.""" + + def __init__(self) -> None: + self.opened_spans: list[tuple[str, object]] = [] + + def span(self, name, *, attributes=None): + self.opened_spans.append((name, attributes)) + return nullcontext("entered span state") + + +@pytest.fixture +def runtime(monkeypatch): + monkeypatch.setenv("OTEL_SDK_DISABLED", "true") + real_runtime = build_runtime( + service_name="policyengine-simulation-scopes-test", + service_role="test", + platform="local", + environment="test", + ) + yield real_runtime + real_runtime.shutdown() + + +def test_span_opens_the_runtime_span_with_its_name_and_attributes(): + recording_runtime = RecordingRuntime() + + with scopes.span( + recording_runtime, "simulation.parse", attributes={"country": "us"} + ) as entered_state: + assert recording_runtime.opened_spans == [ + ("simulation.parse", {"country": "us"}) + ] + + assert entered_state == "entered span state" + + +def test_span_defaults_to_no_attributes(): + recording_runtime = RecordingRuntime() + + with scopes.span(recording_runtime, "simulation.parse"): + pass + + assert recording_runtime.opened_spans == [("simulation.parse", None)] + + +def test_span_lets_an_exception_raised_inside_it_propagate(runtime): + message = "invalid simulation request" + + with ( + pytest.raises(ValueError, match=message), + scopes.span(runtime, "simulation.parse"), + ): + raise ValueError(message) diff --git a/libs/policyengine-stage12-persistence/pyproject.toml b/libs/policyengine-stage12-persistence/pyproject.toml index 3c83e093d..87587670c 100644 --- a/libs/policyengine-stage12-persistence/pyproject.toml +++ b/libs/policyengine-stage12-persistence/pyproject.toml @@ -38,3 +38,7 @@ policyengine-simulation-contract = { path = "../policyengine-simulation-contract [tool.pytest.ini_options] pythonpath = ["src"] testpaths = ["tests"] + +[tool.pyright] +# The pr.yml pyright job checks these paths: python -m scripts.rules pyright. +include = ["src"] diff --git a/projects/policyengine-apis-integ/pyproject.toml b/projects/policyengine-apis-integ/pyproject.toml index f3cca65c2..0a7797dd5 100644 --- a/projects/policyengine-apis-integ/pyproject.toml +++ b/projects/policyengine-apis-integ/pyproject.toml @@ -30,7 +30,10 @@ markers = [ policyengine_api_simulation_client = { path = "../policyengine-simulation-entry/artifacts/clients/python" } [tool.pyright] -#The generated clients do not do the "public export" convention +# The pr.yml pyright job checks these paths: python -m scripts.rules pyright. +# The project has no src directory: its code is the integration tests. +include = ["policyengine_api_full_integ", "tests"] +# The generated clients do not do the "public export" convention reportPrivateImportUsage = "none" [dependency-groups] diff --git a/projects/policyengine-simulation-entry/pyproject.toml b/projects/policyengine-simulation-entry/pyproject.toml index 9351f4908..bc419e070 100644 --- a/projects/policyengine-simulation-entry/pyproject.toml +++ b/projects/policyengine-simulation-entry/pyproject.toml @@ -49,4 +49,5 @@ pythonpath = ["src"] testpaths = ["tests"] [tool.pyright] +# The pr.yml pyright job checks these paths: python -m scripts.rules pyright. include = ["src"] diff --git a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/stage12_backend.py b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/stage12_backend.py index 29dad7567..a1d232941 100644 --- a/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/stage12_backend.py +++ b/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/stage12_backend.py @@ -24,6 +24,7 @@ V2ManifestLoader, V2WorkerVersion, ) +from policyengine_simulation_observability import scopes from policyengine_simulation_observability.identifiers import ( normalize_observability_id, ) @@ -492,7 +493,8 @@ async def _spawn_report( else STAGE12_CANONICAL_REPORT_STAGES ) try: - with self._runtime.span( + with scopes.span( + self._runtime, stage_plan.name(Stage.STAGE12_ENTRY_DISPATCH), attributes={ "evaluation_id": str(parent.evaluation_id), diff --git a/projects/policyengine-simulation-executor/pyproject.toml b/projects/policyengine-simulation-executor/pyproject.toml index 83314045d..c635c4228 100644 --- a/projects/policyengine-simulation-executor/pyproject.toml +++ b/projects/policyengine-simulation-executor/pyproject.toml @@ -93,3 +93,7 @@ addopts = "-m 'not integration'" markers = [ "integration: runs against a real (ephemeral) Modal deployment", ] + +[tool.pyright] +# The pr.yml pyright job checks these paths: python -m scripts.rules pyright. +include = ["src"] diff --git a/projects/policyengine-simulation-gateway/pyproject.toml b/projects/policyengine-simulation-gateway/pyproject.toml index 615322049..d72967079 100644 --- a/projects/policyengine-simulation-gateway/pyproject.toml +++ b/projects/policyengine-simulation-gateway/pyproject.toml @@ -66,3 +66,7 @@ addopts = "-m 'not integration'" markers = [ "integration: runs against a real (ephemeral) Modal deployment", ] + +[tool.pyright] +# The pr.yml pyright job checks these paths: python -m scripts.rules pyright. +include = ["src"] diff --git a/rules/baselines/pyright-apis-integ.txt b/rules/baselines/pyright-apis-integ.txt new file mode 100644 index 000000000..d220f2a32 --- /dev/null +++ b/rules/baselines/pyright-apis-integ.txt @@ -0,0 +1,13 @@ +# Tolerated Pyright errors in projects/policyengine-apis-integ. +# This list may only shrink. +# +# Rule sim.quality.pyright-apis-integ. Each entry is +# :: :: , one line per +# error, sorted. After fixing errors, remove the entries that no longer +# occur with +# python -m scripts.rules pyright projects/policyengine-apis-integ --write-baseline +# then set the rule's [rule.baseline] count in rules/registry.toml to match. +projects/policyengine-apis-integ/tests/simulation/conftest.py :: reportArgumentType :: Argument of type "Client | AuthenticatedClient" cannot be assigned to parameter "client" of type "AuthenticatedClient" in function "sync_detailed" +projects/policyengine-apis-integ/tests/simulation/conftest.py :: reportArgumentType :: Argument of type "Client | AuthenticatedClient" cannot be assigned to parameter "client" of type "AuthenticatedClient" in function "sync_detailed" +projects/policyengine-apis-integ/tests/simulation/test_calculate.py :: reportArgumentType :: Argument of type "Client | AuthenticatedClient" cannot be assigned to parameter "client" of type "AuthenticatedClient" in function "sync_detailed" +projects/policyengine-apis-integ/tests/simulation/test_calculate.py :: reportArgumentType :: Argument of type "Client | AuthenticatedClient" cannot be assigned to parameter "client" of type "AuthenticatedClient" in function "sync_detailed" diff --git a/rules/baselines/pyright-executor.txt b/rules/baselines/pyright-executor.txt new file mode 100644 index 000000000..a1f792020 --- /dev/null +++ b/rules/baselines/pyright-executor.txt @@ -0,0 +1,187 @@ +# Tolerated Pyright errors in projects/policyengine-simulation-executor. +# This list may only shrink. +# +# Rule sim.quality.pyright-executor. Each entry is +# :: :: , one line per +# error, sorted. After fixing errors, remove the entries that no longer +# occur with +# python -m scripts.rules pyright projects/policyengine-simulation-executor --write-baseline +# then set the rule's [rule.baseline] count in rules/registry.toml to match. +projects/policyengine-simulation-executor/src/modal/app.py :: reportAttributeAccessIssue :: "App" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/app.py :: reportAttributeAccessIssue :: "Image" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/app.py :: reportAttributeAccessIssue :: "Image" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/app.py :: reportAttributeAccessIssue :: "Image" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/app.py :: reportAttributeAccessIssue :: "Secret" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/app.py :: reportAttributeAccessIssue :: "Secret" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/app.py :: reportAttributeAccessIssue :: "current_function_call_id" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/app.py :: reportAttributeAccessIssue :: "current_function_call_id" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/app.py :: reportAttributeAccessIssue :: "is_local" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/app.py :: reportAttributeAccessIssue :: "is_local" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/app.py :: reportAttributeAccessIssue :: "is_local" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/app.py :: reportAttributeAccessIssue :: "is_local" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/app.py :: reportReturnType :: Function with declared return type "dict[Unknown, Unknown]" must return value on all code paths +projects/policyengine-simulation-executor/src/modal/app.py :: reportReturnType :: Function with declared return type "dict[Unknown, Unknown]" must return value on all code paths +projects/policyengine-simulation-executor/src/modal/app.py :: reportReturnType :: Function with declared return type "dict[Unknown, Unknown]" must return value on all code paths +projects/policyengine-simulation-executor/src/modal/budget_window_batch.py :: reportAttributeAccessIssue :: "current_function_call_id" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/budget_window_batch.py :: reportPossiblyUnboundVariable :: "context" is possibly unbound +projects/policyengine-simulation-executor/src/modal/budget_window_results.py :: reportArgumentType :: Argument of type "float" cannot be assigned to parameter "year" of type "Literal['Total']" in function "__init__" +projects/policyengine-simulation-executor/src/modal/budget_window_scheduler.py :: reportAttributeAccessIssue :: "Function" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/budget_window_scheduler.py :: reportAttributeAccessIssue :: "FunctionCall" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/budget_window_scheduler.py :: reportOptionalMemberAccess :: "get" is not a known attribute of "None" +projects/policyengine-simulation-executor/src/modal/budget_window_scheduler.py :: reportPossiblyUnboundVariable :: "annual_impact" is possibly unbound +projects/policyengine-simulation-executor/src/modal/budget_window_scheduler.py :: reportPossiblyUnboundVariable :: "call" is possibly unbound +projects/policyengine-simulation-executor/src/modal/budget_window_scheduler.py :: reportPossiblyUnboundVariable :: "call" is possibly unbound +projects/policyengine-simulation-executor/src/modal/budget_window_scheduler.py :: reportPossiblyUnboundVariable :: "call" is possibly unbound +projects/policyengine-simulation-executor/src/modal/budget_window_scheduler.py :: reportPossiblyUnboundVariable :: "child_request" is possibly unbound +projects/policyengine-simulation-executor/src/modal/budget_window_scheduler.py :: reportPossiblyUnboundVariable :: "result" is possibly unbound +projects/policyengine-simulation-executor/src/modal/budget_window_scheduler.py :: reportPossiblyUnboundVariable :: "state" is possibly unbound +projects/policyengine-simulation-executor/src/modal/budget_window_scheduler.py :: reportPossiblyUnboundVariable :: "state" is possibly unbound +projects/policyengine-simulation-executor/src/modal/budget_window_scheduler.py :: reportReturnType :: Function with declared return type "dict[str, Any]" must return value on all code paths +projects/policyengine-simulation-executor/src/modal/precompute_app.py :: reportArgumentType :: Argument of type "(bucket: str) -> dict[Unknown, Unknown]" cannot be assigned to parameter "plan_artifacts" of type "RemoteFunction" in function "__init__" +projects/policyengine-simulation-executor/src/modal/precompute_app.py :: reportArgumentType :: Argument of type "(bucket: str, expected: dict[Unknown, Unknown]) -> dict[Unknown, Unknown]" cannot be assigned to parameter "build_dataset" of type "RemoteFunction" in function "__init__" +projects/policyengine-simulation-executor/src/modal/precompute_app.py :: reportArgumentType :: Argument of type "(bucket: str, expected: dict[Unknown, Unknown]) -> dict[Unknown, Unknown]" cannot be assigned to parameter "compute_baseline" of type "RemoteFunction" in function "__init__" +projects/policyengine-simulation-executor/src/modal/precompute_app.py :: reportArgumentType :: Argument of type "(bucket: str, expected: dict[Unknown, Unknown]) -> dict[Unknown, Unknown]" cannot be assigned to parameter "verify_determinism" of type "RemoteFunction" in function "__init__" +projects/policyengine-simulation-executor/src/modal/precompute_app.py :: reportArgumentType :: Argument of type "(bucket: str, plan: dict[Unknown, Unknown]) -> str" cannot be assigned to parameter "publish_manifest" of type "RemoteFunction" in function "__init__" +projects/policyengine-simulation-executor/src/modal/precompute_app.py :: reportAttributeAccessIssue :: "App" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/segmented_national.py :: reportAttributeAccessIssue :: "Function" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/segmented_national.py :: reportAttributeAccessIssue :: "FunctionCall" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/segmented_national.py :: reportAttributeAccessIssue :: "FunctionCall" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/segmented_national.py :: reportPossiblyUnboundVariable :: "output" is possibly unbound +projects/policyengine-simulation-executor/src/modal/segmented_national.py :: reportPossiblyUnboundVariable :: "output" is possibly unbound +projects/policyengine-simulation-executor/src/modal/segmented_national.py :: reportPossiblyUnboundVariable :: "output" is possibly unbound +projects/policyengine-simulation-executor/src/modal/segmented_national.py :: reportPossiblyUnboundVariable :: "output" is possibly unbound +projects/policyengine-simulation-executor/src/modal/smoke_app.py :: reportAttributeAccessIssue :: "App" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/smoke_app.py :: reportAttributeAccessIssue :: "is_local" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/smoke_app.py :: reportFunctionMemberAccess :: Cannot access attribute "remote" for class "FunctionType" +projects/policyengine-simulation-executor/src/modal/utils/release_sentinel.py :: reportOptionalMemberAccess :: "split" is not a known attribute of "None" +projects/policyengine-simulation-executor/src/modal/utils/update_v2_version_manifest.py :: reportAttributeAccessIssue :: "Dict" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/utils/update_version_registry.py :: reportArgumentType :: Argument of type "dict[Unknown, Unknown]" cannot be assigned to parameter "value" of type "BundleManifestMetadata" in function "__setitem__" +projects/policyengine-simulation-executor/src/modal/utils/update_version_registry.py :: reportAttributeAccessIssue :: "Dict" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/utils/update_version_registry.py :: reportAttributeAccessIssue :: "Dict" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/utils/update_version_registry.py :: reportAttributeAccessIssue :: "Dict" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/utils/update_version_registry.py :: reportAttributeAccessIssue :: "Dict" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/utils/update_version_registry.py :: reportAttributeAccessIssue :: "Dict" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/utils/update_version_registry.py :: reportAttributeAccessIssue :: "Dict" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/utils/update_version_registry.py :: reportAttributeAccessIssue :: "Dict" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/utils/update_version_registry.py :: reportAttributeAccessIssue :: "Dict" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/utils/update_version_registry.py :: reportAttributeAccessIssue :: "Dict" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/utils/update_version_registry.py :: reportAttributeAccessIssue :: "Dict" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/utils/update_version_registry.py :: reportAttributeAccessIssue :: "Dict" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/utils/update_version_registry.py :: reportGeneralTypeIssues :: Could not assign item in TypedDict +projects/policyengine-simulation-executor/src/modal/utils/update_version_registry.py :: reportIndexIssue :: "__getitem__" method not defined on type "object" +projects/policyengine-simulation-executor/src/modal/utils/update_version_registry.py :: reportIndexIssue :: "__setitem__" method not defined on type "object" +projects/policyengine-simulation-executor/src/modal/utils/update_version_registry.py :: reportOptionalMemberAccess :: "get" is not a known attribute of "None" +projects/policyengine-simulation-executor/src/modal/utils/update_version_registry.py :: reportOptionalMemberAccess :: "get" is not a known attribute of "None" +projects/policyengine-simulation-executor/src/modal/utils/update_version_registry.py :: reportOptionalMemberAccess :: "get" is not a known attribute of "None" +projects/policyengine-simulation-executor/src/modal/utils/update_version_registry.py :: reportOptionalMemberAccess :: "get" is not a known attribute of "None" +projects/policyengine-simulation-executor/src/modal/utils/update_version_registry.py :: reportOptionalMemberAccess :: "get" is not a known attribute of "None" +projects/policyengine-simulation-executor/src/modal/utils/update_version_registry.py :: reportOptionalMemberAccess :: "get" is not a known attribute of "None" +projects/policyengine-simulation-executor/src/modal/utils/update_version_registry.py :: reportOptionalMemberAccess :: "get" is not a known attribute of "None" +projects/policyengine-simulation-executor/src/modal/utils/update_version_registry.py :: reportOptionalSubscript :: Object of type "None" is not subscriptable +projects/policyengine-simulation-executor/src/modal/utils/update_version_registry.py :: reportReturnType :: Type "dict[str, int | str | RouteMaps | dict[str, BundleManifestMetadata]]" is not assignable to return type "RoutingState" +projects/policyengine-simulation-executor/src/modal/utils/validate_v2_worker_release.py :: reportAttributeAccessIssue :: "Function" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/baseline_artifacts.py :: reportAttributeAccessIssue :: Cannot access attribute "resolve_entity_variables" for class "TaxBenefitModelVersion" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/precompute.py :: reportArgumentType :: Argument of type "str | None" cannot be assigned to parameter "outcome" of type "ArtifactOutcome | None" in function "__init__" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/precompute.py :: reportAttributeAccessIssue :: Cannot access attribute "artifact_outcome" for class "Simulation" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/precompute.py :: reportAttributeAccessIssue :: Cannot access attribute "artifact_outcome" for class "Simulation" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/precompute.py :: reportAttributeAccessIssue :: Cannot access attribute "entity_data" for class "BaseModel" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/precompute.py :: reportAttributeAccessIssue :: Cannot access attribute "entity_data" for class "BaseModel" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/precompute.py :: reportCallIssue :: Argument missing for parameter "schema" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/precompute.py :: reportCallIssue :: No parameter named "manifest_schema" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/precompute.py :: reportOptionalMemberAccess :: "data" is not a known attribute of "None" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/precompute.py :: reportOptionalMemberAccess :: "data" is not a known attribute of "None" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/precompute.py :: reportOptionalMemberAccess :: "entity_data" is not a known attribute of "None" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/precompute.py :: reportOptionalMemberAccess :: "entity_data" is not a known attribute of "None" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_microdata.py :: reportAttributeAccessIssue :: Cannot access attribute "entity_data" for class "BaseModel" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_microdata.py :: reportOptionalMemberAccess :: "data" is not a known attribute of "None" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_microdata.py :: reportOptionalMemberAccess :: "entity_data" is not a known attribute of "None" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_common.py :: reportArgumentType :: Argument of type "Any | None" cannot be assigned to parameter "x" of type "ConvertibleToFloat" in function "__new__" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_common.py :: reportArgumentType :: Argument of type "Any | None" cannot be assigned to parameter "x" of type "ConvertibleToFloat" in function "__new__" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_common.py :: reportArgumentType :: Argument of type "Any | None" cannot be assigned to parameter "x" of type "ConvertibleToFloat" in function "__new__" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_common.py :: reportArgumentType :: Argument of type "Any | None" cannot be assigned to parameter "x" of type "ConvertibleToFloat" in function "__new__" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_common.py :: reportArgumentType :: Argument of type "Unknown | Any | Series" cannot be assigned to parameter "x" of type "ConvertibleToFloat" in function "__new__" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_common.py :: reportArgumentType :: Argument of type "Unknown | Any | Series" cannot be assigned to parameter "x" of type "ConvertibleToFloat" in function "__new__" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_common.py :: reportAttributeAccessIssue :: Cannot access attribute "household" for class "BaseModel" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_common.py :: reportAttributeAccessIssue :: Cannot access attribute "model_dump" for class "object" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_common.py :: reportOptionalMemberAccess :: "data" is not a known attribute of "None" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_common.py :: reportOptionalMemberAccess :: "data" is not a known attribute of "None" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_common.py :: reportOptionalMemberAccess :: "household" is not a known attribute of "None" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_geographic.py :: reportAttributeAccessIssue :: Cannot access attribute "household" for class "BaseModel" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_geographic.py :: reportOptionalMemberAccess :: "data" is not a known attribute of "None" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_output_geographic.py :: reportOptionalMemberAccess :: "household" is not a known attribute of "None" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportArgumentType :: Argument of type "Unbound | Simulation" cannot be assigned to parameter "simulation" of type "StageReportingSimulation" in function "_report_calculation_start" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportArgumentType :: Argument of type "Unbound | Simulation" cannot be assigned to parameter "simulation" of type "StageReportingSimulation" in function "_report_calculation_start" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportArgumentType :: Argument of type "str" cannot be assigned to parameter "region_type" of type "RegionType" in function "__init__" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportAssignmentType :: Type "dict[str, Any] | Policy | None" is not assignable to declared type "dict[str, Any] | None" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportAttributeAccessIssue :: Cannot access attribute "entity_data" for class "BaseModel" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportOptionalMemberAccess :: "entity_data" is not a known attribute of "None" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportOptionalMemberAccess :: "items" is not a known attribute of "None" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "baseline" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "baseline" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "baseline" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "baseline" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "baseline" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "baseline_policy" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "country_module" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "country_module" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "country_module" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "dataset" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "dataset" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "dataset" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "dataset" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "dataset" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "dataset_selection" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "dataset_selection" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "dataset_selection" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "dataset_selection" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "metadata" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "reform" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "reform" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "reform" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "reform" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "reform_policy" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "region_resolution" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "region_resolution" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "region_resolution" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "region_resolution" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "region_resolution" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "region_resolution" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "region_resolution" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "simulation_params" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "simulation_params" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "simulation_params" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "simulation_params" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "simulation_params" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "simulation_params" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "simulation_params" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "simulation_params" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "simulation_params" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "simulation_params" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "simulation_params" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "simulation_params" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "simulation_params" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "simulation_params" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "telemetry" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "telemetry" is possibly unbound +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/spm.py :: reportArgumentType :: Argument of type "object" cannot be assigned to parameter "defaults" of type "SPMSelection" in function "__init__" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_cache/precompute.py :: reportAttributeAccessIssue :: "Function" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_cache/precompute.py :: reportAttributeAccessIssue :: "Function" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_cache/precompute.py :: reportAttributeAccessIssue :: "Function" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_cache/precompute.py :: reportAttributeAccessIssue :: "Function" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_cache/precompute.py :: reportAttributeAccessIssue :: "Function" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_parity.py :: reportArgumentType :: Argument of type "Series | Unknown | Self@DataFrame" cannot be assigned to parameter "actual_values" of type "Series" in function "_compare_column" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_parity.py :: reportArgumentType :: Argument of type "Series | Unknown | Self@DataFrame" cannot be assigned to parameter "expected_values" of type "Series" in function "_compare_column" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_parity.py :: reportArgumentType :: Argument of type "object" cannot be assigned to parameter "expected" of type "Sequence[Unknown]" in function "_compare_report_lists" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_parity.py :: reportArgumentType :: Argument of type "object" cannot be assigned to parameter "obj" of type "Sized" in function "len" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_parity.py :: reportArgumentType :: Argument of type "object" cannot be assigned to parameter "x" of type "ConvertibleToFloat" in function "__new__" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_parity.py :: reportArgumentType :: Argument of type "object" cannot be assigned to parameter "x" of type "ConvertibleToFloat" in function "__new__" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_parity.py :: reportIndexIssue :: "__getitem__" method not defined on type "object" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/fanout.py :: reportAttributeAccessIssue :: "FunctionCall" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/fanout.py :: reportAttributeAccessIssue :: "FunctionCall" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/simulation.py :: reportArgumentType :: Argument of type "str" cannot be assigned to parameter "created_at" of type "datetime" in function "__init__" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/simulation.py :: reportArgumentType :: Argument of type "str" cannot be assigned to parameter "dynamic" of type "Dynamic | dict[str, Any] | None" in function "__init__" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/simulation.py :: reportArgumentType :: Argument of type "str" cannot be assigned to parameter "extra_variables" of type "dict[str, list[str]]" in function "__init__" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/simulation.py :: reportArgumentType :: Argument of type "str" cannot be assigned to parameter "output_dataset" of type "Dataset | None" in function "__init__" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/simulation.py :: reportArgumentType :: Argument of type "str" cannot be assigned to parameter "spm" of type "SPMSelection | None" in function "__init__" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/simulation.py :: reportArgumentType :: Argument of type "str" cannot be assigned to parameter "spm_receipt" of type "SPMProvenance | None" in function "__init__" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/stage12_runtime/simulation.py :: reportArgumentType :: Argument of type "str" cannot be assigned to parameter "updated_at" of type "datetime" in function "__init__" diff --git a/rules/baselines/pyright-gateway.txt b/rules/baselines/pyright-gateway.txt new file mode 100644 index 000000000..9b81f388e --- /dev/null +++ b/rules/baselines/pyright-gateway.txt @@ -0,0 +1,90 @@ +# Tolerated Pyright errors in projects/policyengine-simulation-gateway. +# This list may only shrink. +# +# Rule sim.quality.pyright-gateway. Each entry is +# :: :: , one line per +# error, sorted. After fixing errors, remove the entries that no longer +# occur with +# python -m scripts.rules pyright projects/policyengine-simulation-gateway --write-baseline +# then set the rule's [rule.baseline] count in rules/registry.toml to match. +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportArgumentType :: Argument of type "Literal['submitted', 'running', 'complete', 'failed']" cannot be assigned to parameter "status" of type "Literal['submitted']" in function "__init__" +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportArgumentType :: Argument of type "VersionRoutingState" cannot be assigned to parameter "state" of type "dict[Unknown, Unknown]" in function "_routing_state_latest" +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportArgumentType :: Argument of type "VersionRoutingState" cannot be assigned to parameter "state" of type "dict[Unknown, Unknown]" in function "_routing_state_routes" +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportArgumentType :: Argument of type "dict[Unknown, Unknown]" cannot be assigned to parameter "state" of type "VersionRoutingState" in function "_version_map_from_state" +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportArgumentType :: Argument of type "dict[Unknown, Unknown]" cannot be assigned to parameter "state" of type "VersionRoutingState" in function "_version_map_from_state" +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportArgumentType :: Argument of type "dict[Unknown, Unknown]" cannot be assigned to parameter "state" of type "VersionRoutingState" in function "_version_map_from_state" +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportArgumentType :: Argument of type "dict[Unknown, Unknown]" cannot be assigned to parameter "state" of type "VersionRoutingState" in function "_version_map_from_state" +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "bundle" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "bundle" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "bundle" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "bundle" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "bundle" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "bundle" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "bundle" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "call" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "call" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "call" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "call" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "call" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "call" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "call" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "job_metadata" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "job_metadata" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "job_metadata" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "job_metadata" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "payload" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "payload" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "payload" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "payload" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "result" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "result" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "route" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "route" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "route" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "route" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "route" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "route" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "route" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "route" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "route" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "route" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "route" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "route" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "route" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "route" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "route" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "route" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "route" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "route" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "route" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "route" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "route" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "route" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "route" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "route" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "seed_state" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "seed_state" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "seed_state" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "seed_state" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "seed_state" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "seed_state" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "seed_state" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "seed_state" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "seed_state" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "seed_state" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "seed_state" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "state" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "state" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "state" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "state" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "state" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "state" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "status_response" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportReturnType :: Function with declared return type "BudgetWindowBatchSubmitResponse | JSONResponse" must return value on all code paths +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportReturnType :: Function with declared return type "JSONResponse" must return value on all code paths +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportReturnType :: Function with declared return type "JobStatusResponse | JSONResponse" must return value on all code paths +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportReturnType :: Function with declared return type "JobSubmitResponse | JSONResponse" must return value on all code paths +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportReturnType :: Function with declared return type "VersionMap" must return value on all code paths +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportReturnType :: Function with declared return type "VersionsResponse" must return value on all code paths +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/smoke_app.py :: reportAttributeAccessIssue :: Cannot access attribute "methods" for class "BaseRoute" +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/smoke_app.py :: reportAttributeAccessIssue :: Cannot access attribute "path" for class "BaseRoute" diff --git a/rules/fixtures/sim.quality.every-package-type-checked/bad/libs/alpha/pyproject.toml b/rules/fixtures/sim.quality.every-package-type-checked/bad/libs/alpha/pyproject.toml new file mode 100644 index 000000000..c207afe0d --- /dev/null +++ b/rules/fixtures/sim.quality.every-package-type-checked/bad/libs/alpha/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "alpha" +version = "0.1.0" diff --git a/rules/fixtures/sim.quality.every-package-type-checked/bad/libs/beta/pyproject.toml b/rules/fixtures/sim.quality.every-package-type-checked/bad/libs/beta/pyproject.toml new file mode 100644 index 000000000..038d5729b --- /dev/null +++ b/rules/fixtures/sim.quality.every-package-type-checked/bad/libs/beta/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "beta" +version = "0.1.0" diff --git a/rules/fixtures/sim.quality.every-package-type-checked/bad/projects/gamma/pyproject.toml b/rules/fixtures/sim.quality.every-package-type-checked/bad/projects/gamma/pyproject.toml new file mode 100644 index 000000000..46850a794 --- /dev/null +++ b/rules/fixtures/sim.quality.every-package-type-checked/bad/projects/gamma/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "gamma" +version = "0.1.0" diff --git a/rules/fixtures/sim.quality.every-package-type-checked/bad/projects/lock-only/uv.lock b/rules/fixtures/sim.quality.every-package-type-checked/bad/projects/lock-only/uv.lock new file mode 100644 index 000000000..d9914dfa6 --- /dev/null +++ b/rules/fixtures/sim.quality.every-package-type-checked/bad/projects/lock-only/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/rules/fixtures/sim.quality.every-package-type-checked/bad/registry.toml b/rules/fixtures/sim.quality.every-package-type-checked/bad/registry.toml new file mode 100644 index 000000000..2f0158544 --- /dev/null +++ b/rules/fixtures/sim.quality.every-package-type-checked/bad/registry.toml @@ -0,0 +1,20 @@ +# Proof fixture: libs/beta is a package that no Pyright rule lists. +schema_version = 1 +repo = "proof" + +[[category]] +id = "quality" +title = "Quality" + +[[rule]] +id = "proof.quality.pyright" +category = "quality" +statement = "The packages have no Pyright errors." +rationale = "README.md" +scope = ["libs/*/pyproject.toml"] +tier = "mechanical" +status = "enforced" +autofix = false +[rule.enforcer] +kind = "pyright" +projects = ["libs/alpha", "projects/gamma"] diff --git a/rules/fixtures/sim.quality.every-package-type-checked/good/libs/alpha/pyproject.toml b/rules/fixtures/sim.quality.every-package-type-checked/good/libs/alpha/pyproject.toml new file mode 100644 index 000000000..c207afe0d --- /dev/null +++ b/rules/fixtures/sim.quality.every-package-type-checked/good/libs/alpha/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "alpha" +version = "0.1.0" diff --git a/rules/fixtures/sim.quality.every-package-type-checked/good/libs/beta/pyproject.toml b/rules/fixtures/sim.quality.every-package-type-checked/good/libs/beta/pyproject.toml new file mode 100644 index 000000000..038d5729b --- /dev/null +++ b/rules/fixtures/sim.quality.every-package-type-checked/good/libs/beta/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "beta" +version = "0.1.0" diff --git a/rules/fixtures/sim.quality.every-package-type-checked/good/projects/gamma/pyproject.toml b/rules/fixtures/sim.quality.every-package-type-checked/good/projects/gamma/pyproject.toml new file mode 100644 index 000000000..46850a794 --- /dev/null +++ b/rules/fixtures/sim.quality.every-package-type-checked/good/projects/gamma/pyproject.toml @@ -0,0 +1,3 @@ +[project] +name = "gamma" +version = "0.1.0" diff --git a/rules/fixtures/sim.quality.every-package-type-checked/good/projects/lock-only/uv.lock b/rules/fixtures/sim.quality.every-package-type-checked/good/projects/lock-only/uv.lock new file mode 100644 index 000000000..d9914dfa6 --- /dev/null +++ b/rules/fixtures/sim.quality.every-package-type-checked/good/projects/lock-only/uv.lock @@ -0,0 +1 @@ +version = 1 diff --git a/rules/fixtures/sim.quality.every-package-type-checked/good/registry.toml b/rules/fixtures/sim.quality.every-package-type-checked/good/registry.toml new file mode 100644 index 000000000..eb279345b --- /dev/null +++ b/rules/fixtures/sim.quality.every-package-type-checked/good/registry.toml @@ -0,0 +1,20 @@ +# Proof fixture: a Pyright rule lists every package. +schema_version = 1 +repo = "proof" + +[[category]] +id = "quality" +title = "Quality" + +[[rule]] +id = "proof.quality.pyright" +category = "quality" +statement = "The packages have no Pyright errors." +rationale = "README.md" +scope = ["libs/*/pyproject.toml"] +tier = "mechanical" +status = "enforced" +autofix = false +[rule.enforcer] +kind = "pyright" +projects = ["libs/alpha", "libs/beta", "projects/gamma"] diff --git a/rules/fixtures/sim.quality.pyright-apis-integ/bad/baseline.txt b/rules/fixtures/sim.quality.pyright-apis-integ/bad/baseline.txt new file mode 100644 index 000000000..11a2c9887 --- /dev/null +++ b/rules/fixtures/sim.quality.pyright-apis-integ/bad/baseline.txt @@ -0,0 +1,4 @@ +# Proof fixture: the baseline omits the test_calculate.py error in +# pyright.json, and lists the conftest.py error twice although it occurs once. +projects/policyengine-apis-integ/tests/simulation/conftest.py :: reportArgumentType :: Argument of type "Client | AuthenticatedClient" cannot be assigned to parameter "client" of type "AuthenticatedClient" in function "sync_detailed" +projects/policyengine-apis-integ/tests/simulation/conftest.py :: reportArgumentType :: Argument of type "Client | AuthenticatedClient" cannot be assigned to parameter "client" of type "AuthenticatedClient" in function "sync_detailed" diff --git a/rules/fixtures/sim.quality.pyright-apis-integ/bad/pyright.json b/rules/fixtures/sim.quality.pyright-apis-integ/bad/pyright.json new file mode 100644 index 000000000..e1f570448 --- /dev/null +++ b/rules/fixtures/sim.quality.pyright-apis-integ/bad/pyright.json @@ -0,0 +1,43 @@ +{ + "version": "1.1.414", + "generalDiagnostics": [ + { + "file": "/repository/projects/policyengine-apis-integ/tests/simulation/conftest.py", + "severity": "error", + "message": "Argument of type \"Client | AuthenticatedClient\" cannot be assigned to parameter \"client\" of type \"AuthenticatedClient\" in function \"sync_detailed\"\n  Type \"Client | AuthenticatedClient\" is not assignable to type \"AuthenticatedClient\"\n    \"Client\" is not assignable to \"AuthenticatedClient\"", + "range": { + "start": { + "line": 102, + "character": 46 + }, + "end": { + "line": 102, + "character": 52 + } + }, + "rule": "reportArgumentType" + }, + { + "file": "/repository/projects/policyengine-apis-integ/tests/simulation/test_calculate.py", + "severity": "error", + "message": "Argument of type \"Client | AuthenticatedClient\" cannot be assigned to parameter \"client\" of type \"AuthenticatedClient\" in function \"sync_detailed\"\n  Type \"Client | AuthenticatedClient\" is not assignable to type \"AuthenticatedClient\"\n    \"Client\" is not assignable to \"AuthenticatedClient\"", + "range": { + "start": { + "line": 98, + "character": 34 + }, + "end": { + "line": 98, + "character": 40 + } + }, + "rule": "reportArgumentType" + } + ], + "summary": { + "filesAnalyzed": 10, + "errorCount": 2, + "warningCount": 0, + "informationCount": 0 + } +} diff --git a/rules/fixtures/sim.quality.pyright-apis-integ/good/baseline.txt b/rules/fixtures/sim.quality.pyright-apis-integ/good/baseline.txt new file mode 100644 index 000000000..94c3a30d2 --- /dev/null +++ b/rules/fixtures/sim.quality.pyright-apis-integ/good/baseline.txt @@ -0,0 +1,3 @@ +# Proof fixture: the baseline lists exactly the errors in pyright.json. +projects/policyengine-apis-integ/tests/simulation/conftest.py :: reportArgumentType :: Argument of type "Client | AuthenticatedClient" cannot be assigned to parameter "client" of type "AuthenticatedClient" in function "sync_detailed" +projects/policyengine-apis-integ/tests/simulation/test_calculate.py :: reportArgumentType :: Argument of type "Client | AuthenticatedClient" cannot be assigned to parameter "client" of type "AuthenticatedClient" in function "sync_detailed" diff --git a/rules/fixtures/sim.quality.pyright-apis-integ/good/pyright.json b/rules/fixtures/sim.quality.pyright-apis-integ/good/pyright.json new file mode 100644 index 000000000..e1f570448 --- /dev/null +++ b/rules/fixtures/sim.quality.pyright-apis-integ/good/pyright.json @@ -0,0 +1,43 @@ +{ + "version": "1.1.414", + "generalDiagnostics": [ + { + "file": "/repository/projects/policyengine-apis-integ/tests/simulation/conftest.py", + "severity": "error", + "message": "Argument of type \"Client | AuthenticatedClient\" cannot be assigned to parameter \"client\" of type \"AuthenticatedClient\" in function \"sync_detailed\"\n  Type \"Client | AuthenticatedClient\" is not assignable to type \"AuthenticatedClient\"\n    \"Client\" is not assignable to \"AuthenticatedClient\"", + "range": { + "start": { + "line": 102, + "character": 46 + }, + "end": { + "line": 102, + "character": 52 + } + }, + "rule": "reportArgumentType" + }, + { + "file": "/repository/projects/policyengine-apis-integ/tests/simulation/test_calculate.py", + "severity": "error", + "message": "Argument of type \"Client | AuthenticatedClient\" cannot be assigned to parameter \"client\" of type \"AuthenticatedClient\" in function \"sync_detailed\"\n  Type \"Client | AuthenticatedClient\" is not assignable to type \"AuthenticatedClient\"\n    \"Client\" is not assignable to \"AuthenticatedClient\"", + "range": { + "start": { + "line": 98, + "character": 34 + }, + "end": { + "line": 98, + "character": 40 + } + }, + "rule": "reportArgumentType" + } + ], + "summary": { + "filesAnalyzed": 10, + "errorCount": 2, + "warningCount": 0, + "informationCount": 0 + } +} diff --git a/rules/fixtures/sim.quality.pyright-executor/bad/baseline.txt b/rules/fixtures/sim.quality.pyright-executor/bad/baseline.txt new file mode 100644 index 000000000..099021df0 --- /dev/null +++ b/rules/fixtures/sim.quality.pyright-executor/bad/baseline.txt @@ -0,0 +1,5 @@ +# Proof fixture: the baseline omits the "Secret" error in pyright.json, and +# still lists an "Image" error that no longer occurs. +projects/policyengine-simulation-executor/src/modal/app.py :: reportAttributeAccessIssue :: "App" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/app.py :: reportAttributeAccessIssue :: "Image" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "metadata" is possibly unbound diff --git a/rules/fixtures/sim.quality.pyright-executor/bad/pyright.json b/rules/fixtures/sim.quality.pyright-executor/bad/pyright.json new file mode 100644 index 000000000..668fe4ddb --- /dev/null +++ b/rules/fixtures/sim.quality.pyright-executor/bad/pyright.json @@ -0,0 +1,59 @@ +{ + "version": "1.1.414", + "generalDiagnostics": [ + { + "file": "/repository/projects/policyengine-simulation-executor/src/modal/app.py", + "severity": "error", + "message": "\"App\" is not a known attribute of module \"modal\"", + "range": { + "start": { + "line": 118, + "character": 12 + }, + "end": { + "line": 118, + "character": 15 + } + }, + "rule": "reportAttributeAccessIssue" + }, + { + "file": "/repository/projects/policyengine-simulation-executor/src/modal/app.py", + "severity": "error", + "message": "\"Secret\" is not a known attribute of module \"modal\"", + "range": { + "start": { + "line": 122, + "character": 19 + }, + "end": { + "line": 122, + "character": 25 + } + }, + "rule": "reportAttributeAccessIssue" + }, + { + "file": "/repository/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py", + "severity": "error", + "message": "\"metadata\" is possibly unbound", + "range": { + "start": { + "line": 823, + "character": 15 + }, + "end": { + "line": 823, + "character": 23 + } + }, + "rule": "reportPossiblyUnboundVariable" + } + ], + "summary": { + "filesAnalyzed": 90, + "errorCount": 3, + "warningCount": 0, + "informationCount": 0 + } +} diff --git a/rules/fixtures/sim.quality.pyright-executor/good/baseline.txt b/rules/fixtures/sim.quality.pyright-executor/good/baseline.txt new file mode 100644 index 000000000..541795061 --- /dev/null +++ b/rules/fixtures/sim.quality.pyright-executor/good/baseline.txt @@ -0,0 +1,4 @@ +# Proof fixture: the baseline lists exactly the errors in pyright.json. +projects/policyengine-simulation-executor/src/modal/app.py :: reportAttributeAccessIssue :: "App" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/modal/app.py :: reportAttributeAccessIssue :: "Secret" is not a known attribute of module "modal" +projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py :: reportPossiblyUnboundVariable :: "metadata" is possibly unbound diff --git a/rules/fixtures/sim.quality.pyright-executor/good/pyright.json b/rules/fixtures/sim.quality.pyright-executor/good/pyright.json new file mode 100644 index 000000000..668fe4ddb --- /dev/null +++ b/rules/fixtures/sim.quality.pyright-executor/good/pyright.json @@ -0,0 +1,59 @@ +{ + "version": "1.1.414", + "generalDiagnostics": [ + { + "file": "/repository/projects/policyengine-simulation-executor/src/modal/app.py", + "severity": "error", + "message": "\"App\" is not a known attribute of module \"modal\"", + "range": { + "start": { + "line": 118, + "character": 12 + }, + "end": { + "line": 118, + "character": 15 + } + }, + "rule": "reportAttributeAccessIssue" + }, + { + "file": "/repository/projects/policyengine-simulation-executor/src/modal/app.py", + "severity": "error", + "message": "\"Secret\" is not a known attribute of module \"modal\"", + "range": { + "start": { + "line": 122, + "character": 19 + }, + "end": { + "line": 122, + "character": 25 + } + }, + "rule": "reportAttributeAccessIssue" + }, + { + "file": "/repository/projects/policyengine-simulation-executor/src/policyengine_simulation_executor/simulation_runtime.py", + "severity": "error", + "message": "\"metadata\" is possibly unbound", + "range": { + "start": { + "line": 823, + "character": 15 + }, + "end": { + "line": 823, + "character": 23 + } + }, + "rule": "reportPossiblyUnboundVariable" + } + ], + "summary": { + "filesAnalyzed": 90, + "errorCount": 3, + "warningCount": 0, + "informationCount": 0 + } +} diff --git a/rules/fixtures/sim.quality.pyright-gateway/bad/baseline.txt b/rules/fixtures/sim.quality.pyright-gateway/bad/baseline.txt new file mode 100644 index 000000000..673fa595f --- /dev/null +++ b/rules/fixtures/sim.quality.pyright-gateway/bad/baseline.txt @@ -0,0 +1,5 @@ +# Proof fixture: pyright.json has two "route" errors and the baseline lists +# one, and the baseline still lists a "bundle" error that no longer occurs. +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "route" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "payload" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "bundle" is possibly unbound diff --git a/rules/fixtures/sim.quality.pyright-gateway/bad/pyright.json b/rules/fixtures/sim.quality.pyright-gateway/bad/pyright.json new file mode 100644 index 000000000..a902297dd --- /dev/null +++ b/rules/fixtures/sim.quality.pyright-gateway/bad/pyright.json @@ -0,0 +1,59 @@ +{ + "version": "1.1.414", + "generalDiagnostics": [ + { + "file": "/repository/projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py", + "severity": "error", + "message": "\"route\" is possibly unbound", + "range": { + "start": { + "line": 922, + "character": 26 + }, + "end": { + "line": 922, + "character": 31 + } + }, + "rule": "reportPossiblyUnboundVariable" + }, + { + "file": "/repository/projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py", + "severity": "error", + "message": "\"route\" is possibly unbound", + "range": { + "start": { + "line": 923, + "character": 25 + }, + "end": { + "line": 923, + "character": 30 + } + }, + "rule": "reportPossiblyUnboundVariable" + }, + { + "file": "/repository/projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py", + "severity": "error", + "message": "\"payload\" is possibly unbound", + "range": { + "start": { + "line": 934, + "character": 8 + }, + "end": { + "line": 934, + "character": 15 + } + }, + "rule": "reportPossiblyUnboundVariable" + } + ], + "summary": { + "filesAnalyzed": 9, + "errorCount": 3, + "warningCount": 0, + "informationCount": 0 + } +} diff --git a/rules/fixtures/sim.quality.pyright-gateway/good/baseline.txt b/rules/fixtures/sim.quality.pyright-gateway/good/baseline.txt new file mode 100644 index 000000000..3a7b94654 --- /dev/null +++ b/rules/fixtures/sim.quality.pyright-gateway/good/baseline.txt @@ -0,0 +1,4 @@ +# Proof fixture: the baseline lists exactly the errors in pyright.json. +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "route" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "route" is possibly unbound +projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py :: reportPossiblyUnboundVariable :: "payload" is possibly unbound diff --git a/rules/fixtures/sim.quality.pyright-gateway/good/pyright.json b/rules/fixtures/sim.quality.pyright-gateway/good/pyright.json new file mode 100644 index 000000000..a902297dd --- /dev/null +++ b/rules/fixtures/sim.quality.pyright-gateway/good/pyright.json @@ -0,0 +1,59 @@ +{ + "version": "1.1.414", + "generalDiagnostics": [ + { + "file": "/repository/projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py", + "severity": "error", + "message": "\"route\" is possibly unbound", + "range": { + "start": { + "line": 922, + "character": 26 + }, + "end": { + "line": 922, + "character": 31 + } + }, + "rule": "reportPossiblyUnboundVariable" + }, + { + "file": "/repository/projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py", + "severity": "error", + "message": "\"route\" is possibly unbound", + "range": { + "start": { + "line": 923, + "character": 25 + }, + "end": { + "line": 923, + "character": 30 + } + }, + "rule": "reportPossiblyUnboundVariable" + }, + { + "file": "/repository/projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/endpoints.py", + "severity": "error", + "message": "\"payload\" is possibly unbound", + "range": { + "start": { + "line": 934, + "character": 8 + }, + "end": { + "line": 934, + "character": 15 + } + }, + "rule": "reportPossiblyUnboundVariable" + } + ], + "summary": { + "filesAnalyzed": 9, + "errorCount": 3, + "warningCount": 0, + "informationCount": 0 + } +} diff --git a/rules/fixtures/sim.quality.pyright-libraries-and-entry/bad/pyright.json b/rules/fixtures/sim.quality.pyright-libraries-and-entry/bad/pyright.json new file mode 100644 index 000000000..34e4d27cc --- /dev/null +++ b/rules/fixtures/sim.quality.pyright-libraries-and-entry/bad/pyright.json @@ -0,0 +1,43 @@ +{ + "version": "1.1.414", + "generalDiagnostics": [ + { + "file": "/repository/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/stage12_backend.py", + "severity": "error", + "message": "\"invocation_id\" is possibly unbound", + "range": { + "start": { + "line": 549, + "character": 45 + }, + "end": { + "line": 549, + "character": 58 + } + }, + "rule": "reportPossiblyUnboundVariable" + }, + { + "file": "/repository/projects/policyengine-simulation-entry/src/policyengine_simulation_entry/stage12_backend.py", + "severity": "error", + "message": "\"invocation_id\" is possibly unbound", + "range": { + "start": { + "line": 562, + "character": 45 + }, + "end": { + "line": 562, + "character": 58 + } + }, + "rule": "reportPossiblyUnboundVariable" + } + ], + "summary": { + "filesAnalyzed": 10, + "errorCount": 2, + "warningCount": 0, + "informationCount": 0 + } +} diff --git a/rules/fixtures/sim.quality.pyright-libraries-and-entry/good/pyright.json b/rules/fixtures/sim.quality.pyright-libraries-and-entry/good/pyright.json new file mode 100644 index 000000000..d1e814a48 --- /dev/null +++ b/rules/fixtures/sim.quality.pyright-libraries-and-entry/good/pyright.json @@ -0,0 +1,10 @@ +{ + "version": "1.1.414", + "generalDiagnostics": [], + "summary": { + "filesAnalyzed": 10, + "errorCount": 0, + "warningCount": 0, + "informationCount": 0 + } +} diff --git a/rules/registry.toml b/rules/registry.toml index d1d305ea8..02baddac3 100644 --- a/rules/registry.toml +++ b/rules/registry.toml @@ -1034,6 +1034,147 @@ bad_fixture = "rules/fixtures/sim.quality.runtime-evaluated-models-resolve/bad_c good_fixture = "rules/fixtures/sim.quality.runtime-evaluated-models-resolve/good_compat_models.py" test = "projects/policyengine-simulation-executor/tests/test_runtime_evaluated_annotations.py::test_rule_proof_runtime_evaluated_models_resolve" +[[rule]] +id = "sim.quality.every-package-type-checked" +category = "quality" +statement = "Every uv package under libs/ and projects/ (a directory with a pyproject.toml) is listed by a Pyright rule, so the pyright CI job type-checks it." +rationale = "docs/engineering/skills/type-checking.md#rules" +scope = ["libs/*/pyproject.toml", "projects/*/pyproject.toml"] +tier = "mechanical" +status = "enforced" +autofix = false +[rule.enforcer] +kind = "quality-guard" +name = "pyright-coverage" +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "quality-guards" +invocation = "run_quality_guards.py" +[rule.proof] +bad_fixture = "rules/fixtures/sim.quality.every-package-type-checked/bad" +good_fixture = "rules/fixtures/sim.quality.every-package-type-checked/good" +test = "scripts/rules/tests/test_pyright.py::TestCoverageProof::test_rule_proof_every_package_type_checked" +workflow = ".github/workflows/pr.yml" +job = "quality-guards" +invocation = "unittest discover -s scripts/rules/tests" + +[[rule]] +id = "sim.quality.pyright-libraries-and-entry" +category = "quality" +statement = "The contract, observability, Stage 12 persistence and fastapi libraries and the simulation entry have no Pyright errors, checked with the pinned Pyright version in each package's own locked environment." +rationale = "docs/engineering/skills/type-checking.md#rules" +scope = [ + "libs/policyengine-simulation-contract/src/**/*.py", + "libs/policyengine-simulation-observability/src/**/*.py", + "libs/policyengine-stage12-persistence/src/**/*.py", + "libs/policyengine-fastapi/src/**/*.py", + "projects/policyengine-simulation-entry/src/**/*.py", +] +tier = "mechanical" +status = "enforced" +autofix = false +[rule.enforcer] +kind = "pyright" +projects = [ + "libs/policyengine-simulation-contract", + "libs/policyengine-simulation-observability", + "libs/policyengine-stage12-persistence", + "libs/policyengine-fastapi", + "projects/policyengine-simulation-entry", +] +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "pyright" +invocation = "python -m scripts.rules pyright" +[rule.proof] +bad_fixture = "rules/fixtures/sim.quality.pyright-libraries-and-entry/bad" +good_fixture = "rules/fixtures/sim.quality.pyright-libraries-and-entry/good" +test = "scripts/rules/tests/test_pyright.py::TestPyrightProof::test_rule_proof_pyright_libraries_and_entry" +workflow = ".github/workflows/pr.yml" +job = "quality-guards" +invocation = "unittest discover -s scripts/rules/tests" + +[[rule]] +id = "sim.quality.pyright-gateway" +category = "quality" +statement = "The simulation gateway has no Pyright errors beyond those its baseline lists, and the baseline lists only errors that still occur, so it only shrinks; checked with the pinned Pyright version in the gateway's own locked environment." +rationale = "docs/engineering/skills/type-checking.md#baselines" +scope = ["projects/policyengine-simulation-gateway/src/**/*.py"] +tier = "mechanical" +status = "ratcheted" +autofix = false +[rule.enforcer] +kind = "pyright" +projects = ["projects/policyengine-simulation-gateway"] +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "pyright" +invocation = "python -m scripts.rules pyright" +[rule.baseline] +file = "rules/baselines/pyright-gateway.txt" +count = 81 +[rule.proof] +bad_fixture = "rules/fixtures/sim.quality.pyright-gateway/bad" +good_fixture = "rules/fixtures/sim.quality.pyright-gateway/good" +test = "scripts/rules/tests/test_pyright.py::TestPyrightProof::test_rule_proof_pyright_gateway" +workflow = ".github/workflows/pr.yml" +job = "quality-guards" +invocation = "unittest discover -s scripts/rules/tests" + +[[rule]] +id = "sim.quality.pyright-executor" +category = "quality" +statement = "The simulation executor has no Pyright errors beyond those its baseline lists, and the baseline lists only errors that still occur, so it only shrinks; checked with the pinned Pyright version in the executor's own locked environment." +rationale = "docs/engineering/skills/type-checking.md#baselines" +scope = ["projects/policyengine-simulation-executor/src/**/*.py"] +tier = "mechanical" +status = "ratcheted" +autofix = false +[rule.enforcer] +kind = "pyright" +projects = ["projects/policyengine-simulation-executor"] +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "pyright" +invocation = "python -m scripts.rules pyright" +[rule.baseline] +file = "rules/baselines/pyright-executor.txt" +count = 178 +[rule.proof] +bad_fixture = "rules/fixtures/sim.quality.pyright-executor/bad" +good_fixture = "rules/fixtures/sim.quality.pyright-executor/good" +test = "scripts/rules/tests/test_pyright.py::TestPyrightProof::test_rule_proof_pyright_executor" +workflow = ".github/workflows/pr.yml" +job = "quality-guards" +invocation = "unittest discover -s scripts/rules/tests" + +[[rule]] +id = "sim.quality.pyright-apis-integ" +category = "quality" +statement = "The generated-client integration-test project, apis-integ, has no Pyright errors beyond those its baseline lists, and the baseline lists only errors that still occur, so it only shrinks; checked with the pinned Pyright version in the project's own locked environment." +rationale = "docs/engineering/skills/type-checking.md#baselines" +scope = ["projects/policyengine-apis-integ/tests/**/*.py", "projects/policyengine-apis-integ/policyengine_api_full_integ/*.py"] +tier = "mechanical" +status = "ratcheted" +autofix = false +[rule.enforcer] +kind = "pyright" +projects = ["projects/policyengine-apis-integ"] +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "pyright" +invocation = "python -m scripts.rules pyright" +[rule.baseline] +file = "rules/baselines/pyright-apis-integ.txt" +count = 4 +[rule.proof] +bad_fixture = "rules/fixtures/sim.quality.pyright-apis-integ/bad" +good_fixture = "rules/fixtures/sim.quality.pyright-apis-integ/good" +test = "scripts/rules/tests/test_pyright.py::TestPyrightProof::test_rule_proof_pyright_apis_integ" +workflow = ".github/workflows/pr.yml" +job = "quality-guards" +invocation = "unittest discover -s scripts/rules/tests" + # --- CI workflows and scripts ---------------------------------------------- [[rule]] diff --git a/scripts/rules/baseline.py b/scripts/rules/baseline.py index 62fa76c6f..b10b64881 100644 --- a/scripts/rules/baseline.py +++ b/scripts/rules/baseline.py @@ -72,7 +72,11 @@ def check_baselines( ) ) weaker = (Status.ADVISORY, Status.DOC_ONLY) - if old.status in (Status.ENFORCED, Status.RATCHETED) and new.status in weaker: + # A ratcheted rule tolerates its baseline, so ratcheting an enforced + # rule weakens it too, with whatever count its new baseline starts at. + if ( + old.status in (Status.ENFORCED, Status.RATCHETED) and new.status in weaker + ) or (old.status is Status.ENFORCED and new.status is Status.RATCHETED): if new.downgrade_reason: notes.append( f"rule {new.id} downgraded to {new.status}: {new.downgrade_reason}" diff --git a/scripts/rules/cli.py b/scripts/rules/cli.py index 6b370efc7..bd88be8cd 100644 --- a/scripts/rules/cli.py +++ b/scripts/rules/cli.py @@ -1,4 +1,7 @@ -"""Command line: ``python -m scripts.rules {validate,render,guard}``.""" +"""Command line for the rules tool. + +``python -m scripts.rules {validate,render,guard,ci-scripts,pyright}`` +""" from __future__ import annotations @@ -6,7 +9,7 @@ from datetime import date from pathlib import Path -from scripts.rules import ci_scripts +from scripts.rules import ci_scripts, pyright from scripts.rules.guard import REPO_ROOT, run_guard from scripts.rules.load import load_registry from scripts.rules.render import write_outputs @@ -43,6 +46,45 @@ def _ci_scripts_command(root: Path, *, write_baselines: bool) -> int: return 0 +def _pyright_command( + root: Path, + packages: list[str], + *, + sync: bool, + write_baseline: bool, + list_packages: bool, +) -> int: + """Check each package against its rule, or rewrite ratcheted baselines. + + Without packages, check every package a Pyright rule lists or, with + ``write_baseline``, rewrite the baseline of every ratcheted one. + """ + registry, violations = load_registry(root) + if registry is None: + return _print(violations) + if list_packages: + for package in pyright.listed_packages(registry): + _report(package) + return 0 + failed = False + default_packages = ( + pyright.ratcheted_packages(registry) + if write_baseline + else pyright.listed_packages(registry) + ) + if write_baseline and not packages and not default_packages: + _report("No Pyright rule is ratcheted, so there is no baseline to write.") + for package in packages or default_packages: + passed, lines = pyright.check_package( + registry, root, package, sync=sync, write_baseline=write_baseline + ) + for line in lines: + _report(line) + failed = failed or not passed + _report("pyright: failed" if failed else "pyright: passed") + return 1 if failed else 0 + + def _registry_command(command: str, root: Path) -> int: """Validate the registry and, for ``render``, write its views once it is valid.""" clear_caches() @@ -69,7 +111,13 @@ def main(argv: list[str] | None = None) -> int: prog="python -m scripts.rules", description=__doc__ ) parser.add_argument( - "command", choices=["validate", "render", "guard", "ci-scripts"] + "command", choices=["validate", "render", "guard", "ci-scripts", "pyright"] + ) + parser.add_argument( + "packages", + nargs="*", + help="pyright only: package directories to check (default: every one a " + "Pyright rule lists)", ) parser.add_argument("--root", type=Path, default=REPO_ROOT, help="repository root") parser.add_argument( @@ -84,11 +132,55 @@ def main(argv: list[str] | None = None) -> int: "(when adopting the rule)" ), ) + parser.add_argument( + "--sync", + action="store_true", + help=( + "pyright only: run `uv sync --frozen --all-extras` in each package " + "before checking it" + ), + ) + parser.add_argument( + "--write-baseline", + action="store_true", + help=( + "pyright only: rewrite each ratcheted package's baseline (by default " + "every one) as the errors that occur now, refusing an error an " + "existing baseline doesn't list" + ), + ) + parser.add_argument( + "--list-packages", + action="store_true", + help="pyright only: print every package a Pyright rule lists", + ) args = parser.parse_args(argv) + if args.command != "pyright" and ( + args.packages or args.sync or args.write_baseline or args.list_packages + ): + parser.error( + "packages, --sync, --write-baseline and --list-packages apply only " + "to pyright" + ) + if args.command == "pyright" and args.write_baselines: + parser.error( + "--write-baselines applies only to ci-scripts; pyright takes " + "--write-baseline" + ) + if args.list_packages and (args.packages or args.sync or args.write_baseline): + parser.error("--list-packages takes no packages or other options") root = args.root.resolve() if args.command == "ci-scripts": return _ci_scripts_command(root, write_baselines=args.write_baselines) + if args.command == "pyright": + return _pyright_command( + root, + args.packages, + sync=args.sync, + write_baseline=args.write_baseline, + list_packages=args.list_packages, + ) if args.command == "guard": result = run_guard(root, base_ref=args.base_ref) return _print(result.violations, result.notes) diff --git a/scripts/rules/model.py b/scripts/rules/model.py index 95f65570f..6f3bc1397 100644 --- a/scripts/rules/model.py +++ b/scripts/rules/model.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from dataclasses import dataclass, field from enum import StrEnum from typing import TYPE_CHECKING, Any @@ -68,7 +69,10 @@ def _describe_ruff(fields: Mapping[str, Any]) -> str: Kind.RUFF: _describe_ruff, Kind.MYPY: lambda _fields: "mypy", Kind.WORKFLOW_JOB: lambda _fields: "CI job", - Kind.PYRIGHT: lambda fields: f"{Kind.PYRIGHT.value} `{fields.get('config', '')}`", + Kind.PYRIGHT: lambda fields: ( + f"{Kind.PYRIGHT.value} in " + + ", ".join(f"`{project}`" for project in fields.get("projects", [])) + ), Kind.IMPORT_LINTER: ( lambda fields: f"{Kind.IMPORT_LINTER.value} `{fields.get('config', '')}`" ), @@ -77,6 +81,11 @@ def _describe_ruff(fields: Mapping[str, Any]) -> str: } +def normalized_package(package: str) -> str: + """Return a package directory in one spelling, so ./libs/x and libs/x match.""" + return os.path.normpath(package).replace(os.sep, "/") + + @dataclass(frozen=True) class Violation: """One guard failure. ``fix`` always tells the reader what to do next.""" diff --git a/scripts/rules/pyright.py b/scripts/rules/pyright.py new file mode 100644 index 000000000..1be4bf5eb --- /dev/null +++ b/scripts/rules/pyright.py @@ -0,0 +1,496 @@ +"""Run Pyright on a package in its own locked environment, and hold it to its rule. + +``python -m scripts.rules pyright `` finds the Pyright rule whose +enforcer lists the package, then runs the pinned Pyright version in the package +directory against the interpreter of the package's locked environment +(``.venv``, created by ``uv sync --frozen --all-extras``, which ``--sync`` runs +first). The package's own ``[tool.pyright]`` settings decide what is checked. + +- A package under an enforced rule may have no errors. +- A package under a ratcheted rule may have exactly the errors its baseline + lists. A new error fails, and so does an entry for an error that no longer + occurs, so each fix removes its entry and the baseline only shrinks. + +A baseline entry is `` :: :: ``, +one line per error, sorted and without a line number, so edits elsewhere in a +file don't change it, and the rule's baseline count is the number of errors it +tolerates. + +Pyright's messages and checks change between releases, so every package is +checked with one pinned version, PYRIGHT_VERSION, rather than the version each +package's lock file happens to hold. +""" + +from __future__ import annotations + +import json +import os +import subprocess +from collections import Counter +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from scripts.rules.load import load_registry +from scripts.rules.model import ( + Kind, + Registry, + Rule, + Status, + Violation, + normalized_package, +) + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + +PYRIGHT_VERSION = "1.1.414" +REPO_ROOT = Path(__file__).resolve().parents[2] +# Where uv packages live: each directory here with a pyproject.toml is one. +PACKAGE_GLOBS = ("libs/*/pyproject.toml", "projects/*/pyproject.toml") +COVERAGE_GUARD = "pyright-coverage" +ENTRY_SEPARATOR = " :: " +# The name a baseline entry gives an error Pyright reports without a rule. +UNNAMED_RULE = "error" +# Pyright exits with 0 when it finds no errors and 1 when it finds some. +COMPLETED_EXIT_CODES = (0, 1) +LOCKED_INTERPRETER = Path(".venv/bin/python") +SYNC_COMMAND = ("uv", "sync", "--frozen", "--all-extras") +# Read by the pyright PyPI package: one would run another version, the other +# silences its advice to upgrade. +FORCE_VERSION_VARIABLE = "PYRIGHT_PYTHON_FORCE_VERSION" +IGNORE_WARNINGS_VARIABLE = "PYRIGHT_PYTHON_IGNORE_WARNINGS" +# How much of Pyright's output a run error quotes. +QUOTED_OUTPUT_CHARACTERS = 2000 +ENFORCED_FIX = ( + " Fix: fix each error. Suppress one only as a last resort, " + "with # pyright: ignore[] and the reason." +) +NEW_ERROR_FIX = " Fix: fix each new error; the baseline only shrinks." + + +class PyrightRunError(Exception): + """Pyright could not check a package; the message says why and what to do.""" + + +@dataclass(frozen=True, order=True) +class PyrightError: + """One error from Pyright's report, at a repository-relative path.""" + + path: str + line: int + column: int + rule: str + message: str + + @property + def summary(self) -> str: + """Return the first line of the message, which names what is wrong.""" + lines = self.message.splitlines() + return lines[0].strip() if lines else "" + + @property + def baseline_entry(self) -> str: + """Return the baseline entry that tolerates this error.""" + return ENTRY_SEPARATOR.join((self.path, self.rule, self.summary)) + + def describe(self, label: str) -> list[str]: + """Return the error as indented output lines, with its full message.""" + details = [line.strip() for line in self.message.splitlines()[1:]] + return [ + f" {label}: {self.path}:{self.line}:{self.column} - " + f"{self.summary} ({self.rule})", + *(f" {detail}" for detail in details if detail), + ] + + +@dataclass(frozen=True) +class Comparison: + """The errors a baseline doesn't list, and the entries no error matches.""" + + new_errors: tuple[PyrightError, ...] + stale_entries: tuple[str, ...] + + @property + def passed(self) -> bool: + """Whether the errors are exactly the ones the baseline lists.""" + return not self.new_errors and not self.stale_entries + + +def _repository_path(file: str, root: Path) -> str: + """Return ``file`` relative to ``root`` when it lies inside it.""" + path = Path(file) + for base in (root, root.resolve()): + if path.is_relative_to(base): + return path.relative_to(base).as_posix() + return path.as_posix() + + +def errors_in_report(report: Mapping[str, Any], root: Path) -> list[PyrightError]: + """Return the errors in a ``pyright --outputjson`` report, sorted by position.""" + errors = [] + for diagnostic in report.get("generalDiagnostics", []): + if diagnostic.get("severity") != "error": + continue + start = diagnostic.get("range", {}).get("start", {}) + errors.append( + PyrightError( + path=_repository_path(diagnostic.get("file", ""), root), + line=start.get("line", 0) + 1, + column=start.get("character", 0) + 1, + rule=diagnostic.get("rule", UNNAMED_RULE), + message=diagnostic.get("message", ""), + ) + ) + return sorted(errors) + + +def read_baseline(path: Path) -> list[str]: + """Return a baseline's entries, skipping blank and ``#`` comment lines.""" + return [ + line.strip() + for line in path.read_text().splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + + +def compare(errors: Sequence[PyrightError], baseline: Sequence[str]) -> Comparison: + """Match each error with a baseline entry; each entry tolerates one error.""" + unmatched_entries = Counter(baseline) + new_errors = [] + for error in errors: + if unmatched_entries[error.baseline_entry] > 0: + unmatched_entries[error.baseline_entry] -= 1 + else: + new_errors.append(error) + return Comparison( + new_errors=tuple(new_errors), + stale_entries=tuple(sorted(unmatched_entries.elements())), + ) + + +def _quoted(output: str) -> str: + """Return the end of ``output`` on one line, for an error message.""" + return " ".join(output.strip()[-QUOTED_OUTPUT_CHARACTERS:].split()) + + +def sync_environment(package_dir: Path) -> None: + """Create or update the package's locked environment, with every extra. + + Raise PyrightRunError when ``uv sync`` fails. + """ + completed = subprocess.run( + [*SYNC_COMMAND], + cwd=package_dir.resolve(), + capture_output=True, + text=True, + check=False, + ) + if completed.returncode != 0: + msg = ( + f"`{' '.join(SYNC_COMMAND)}` exited with status {completed.returncode}: " + f"{_quoted(completed.stderr or completed.stdout)}" + ) + raise PyrightRunError(msg) + + +def run_pyright(package_dir: Path) -> dict[str, Any]: + """Run the pinned Pyright in ``package_dir`` and return its JSON report. + + Raise PyrightRunError when the package has no locked environment, or when + Pyright fails, prints no report, reports another version or checks nothing. + """ + package_dir = package_dir.resolve() + interpreter = package_dir / LOCKED_INTERPRETER + if not interpreter.exists(): + msg = ( + f"there is no locked environment: {interpreter} does not exist; " + f"run `uv sync --frozen --all-extras` in {package_dir} first" + ) + raise PyrightRunError(msg) + environment = { + name: value + for name, value in os.environ.items() + if name != FORCE_VERSION_VARIABLE + } + environment[IGNORE_WARNINGS_VARIABLE] = "1" + completed = subprocess.run( + [ + "uv", + "tool", + "run", + "--from", + f"pyright=={PYRIGHT_VERSION}", + "pyright", + "--outputjson", + "--pythonpath", + str(interpreter), + # Read only the package's own configuration: without --project, + # Pyright also uses a pyrightconfig.json from a parent directory. + "--project", + str(package_dir), + ], + cwd=package_dir, + env=environment, + capture_output=True, + text=True, + check=False, + ) + if completed.returncode not in COMPLETED_EXIT_CODES: + msg = ( + f"Pyright exited with status {completed.returncode}: " + f"{_quoted(completed.stderr or completed.stdout)}" + ) + raise PyrightRunError(msg) + try: + report = json.loads(completed.stdout) + except json.JSONDecodeError as error: + msg = f"Pyright printed no JSON report: {_quoted(completed.stderr)}" + raise PyrightRunError(msg) from error + if report.get("version") != PYRIGHT_VERSION: + msg = ( + f"Pyright reported version {report.get('version')}, " + f"not the pinned {PYRIGHT_VERSION}" + ) + raise PyrightRunError(msg) + if not report.get("summary", {}).get("filesAnalyzed"): + msg = ( + "Pyright checked no files; list what it checks in the package's " + "[tool.pyright] include" + ) + raise PyrightRunError(msg) + return report + + +def listed_packages(registry: Registry) -> list[str]: + """Return every package a Pyright rule lists, in registry order.""" + return [ + normalized_package(project) + for rule in registry.rules + if rule.enforcer.kind is Kind.PYRIGHT + for project in rule.enforcer.get("projects", []) + ] + + +def ratcheted_packages(registry: Registry) -> list[str]: + """Return every package a ratcheted Pyright rule lists, in registry order.""" + return [ + normalized_package(project) + for rule in registry.rules + if rule.enforcer.kind is Kind.PYRIGHT + and rule.status is Status.RATCHETED + and rule.baseline is not None + for project in rule.enforcer.get("projects", []) + ] + + +def unlisted_packages(registry: Registry, root: Path) -> list[str]: + """Return each uv package under ``root`` that no Pyright rule lists.""" + listed = set(listed_packages(registry)) + packages = { + path.parent.relative_to(root).as_posix() + for pattern in PACKAGE_GLOBS + for path in root.glob(pattern) + } + return sorted(packages - listed) + + +def check_coverage(root: Path = REPO_ROOT) -> list[str]: + """Report each uv package no Pyright rule lists; run_quality_guards.py runs it.""" + registry, violations = load_registry(root) + if registry is None: + return [str(violation) for violation in violations] + rule_id = next( + ( + rule.id + for rule in registry.rules + if rule.enforcer.kind is Kind.QUALITY_GUARD + and rule.enforcer.get("name") == COVERAGE_GUARD + ), + None, + ) + return [ + str( + Violation( + rule_id, + COVERAGE_GUARD, + f"package {package} has no Pyright rule, so CI doesn't type-check it", + "add it to a Pyright rule's enforcer.projects in rules/registry.toml " + "and to the pyright job's matrix in .github/workflows/pr.yml", + ) + ) + for package in unlisted_packages(registry, root) + ] + + +def rule_for(registry: Registry, package: str) -> Rule | None: + """Return the Pyright rule whose enforcer lists ``package``, if any.""" + for rule in registry.rules: + projects = rule.enforcer.get("projects", []) + if rule.enforcer.kind is Kind.PYRIGHT and package in map( + normalized_package, projects + ): + return rule + return None + + +def _baseline_header(rule: Rule, package: str) -> str: + """Return the comment block that starts a new baseline file.""" + return ( + f"# Tolerated Pyright errors in {package}.\n" + "# This list may only shrink.\n" + "#\n" + f"# Rule {rule.id}. Each entry is\n" + "# :: :: , one line per\n" + "# error, sorted. After fixing errors, remove the entries that no longer\n" + "# occur with\n" + f"# python -m scripts.rules pyright {package} --write-baseline\n" + "# then set the rule's [rule.baseline] count in rules/registry.toml to match.\n" + ) + + +def _existing_header(path: Path) -> str: + """Return the comment and blank lines that open an existing baseline file.""" + header = [] + for line in path.read_text().splitlines(): + if line.strip() and not line.lstrip().startswith("#"): + break + header.append(line) + return "".join(f"{line}\n" for line in header) + + +def _entry_count(count: int) -> str: + return f"{count} entry" if count == 1 else f"{count} entries" + + +def _error_count(count: int) -> str: + return f"{count} Pyright error" if count == 1 else f"{count} Pyright errors" + + +@dataclass(frozen=True) +class _PackageCheck: + """One package's rule and what Pyright reported for it.""" + + root: Path + package: str + rule: Rule + errors: list[PyrightError] + + @property + def label(self) -> str: + return f"({self.rule.id}, {self.rule.status})" + + +def _check_enforced(check: _PackageCheck) -> tuple[bool, list[str]]: + if not check.errors: + return True, [f"{check.package}: passed with no Pyright errors {check.label}"] + lines = [ + f"{check.package}: failed with {_error_count(len(check.errors))}, where " + f"{check.rule.id} ({check.rule.status}) allows none" + ] + for error in check.errors: + lines += error.describe("error") + return False, [*lines, ENFORCED_FIX] + + +def _check_ratcheted( + check: _PackageCheck, baseline_file: str +) -> tuple[bool, list[str]]: + path = check.root / baseline_file + comparison = compare(check.errors, read_baseline(path) if path.is_file() else []) + if comparison.passed: + return True, [ + f"{check.package}: passed with {_error_count(len(check.errors))}, " + f"each listed in {baseline_file} {check.label}" + ] + lines = [ + f"{check.package}: failed, its Pyright errors differ from " + f"{baseline_file} {check.label}" + ] + for error in comparison.new_errors: + lines += error.describe("new error") + lines += [f" no longer occurs: {entry}" for entry in comparison.stale_entries] + if comparison.new_errors: + lines.append(NEW_ERROR_FIX) + if comparison.stale_entries: + lines.append( + " Fix: remove the entries that no longer occur with " + f"python -m scripts.rules pyright {check.package} --write-baseline, " + f"then set count under [rule.baseline] of {check.rule.id} to the " + "number left." + ) + return False, lines + + +def _write_baseline(check: _PackageCheck, baseline_file: str) -> tuple[bool, list[str]]: + """Rewrite the baseline as the errors that occur now. + + An existing baseline only loses entries: an error it doesn't list is + refused. A missing baseline is written with every error, which is how a + package is first ratcheted; the rules guard then rejects a count higher + than the base branch's. + """ + path = check.root / baseline_file + if path.is_file(): + comparison = compare(check.errors, read_baseline(path)) + if comparison.new_errors: + lines = [ + f"{check.package}: failed, --write-baseline never adds an error " + f"to {baseline_file}" + ] + for error in comparison.new_errors: + lines += error.describe("new error") + return False, [*lines, NEW_ERROR_FIX] + header = _existing_header(path) + else: + header = _baseline_header(check.rule, check.package) + entries = sorted(error.baseline_entry for error in check.errors) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(header + "".join(f"{entry}\n" for entry in entries)) + return True, [ + f"{check.package}: wrote {baseline_file} with {_entry_count(len(entries))}; " + f"set count = {len(entries)} under [rule.baseline] of {check.rule.id} " + "in rules/registry.toml" + ] + + +def check_package( + registry: Registry, + root: Path, + package: str, + *, + sync: bool = False, + write_baseline: bool = False, +) -> tuple[bool, list[str]]: + """Check one package against its Pyright rule; return (passed, output lines). + + With ``sync``, first create or update the package's locked environment. + With ``write_baseline``, rewrite a ratcheted rule's baseline as the errors + that occur now, refusing when one of them is new. + """ + package = normalized_package(package) + rule = rule_for(registry, package) + if rule is None: + return False, [ + f"{package}: failed, no Pyright rule lists it in enforcer.projects", + " Fix: add it to a Pyright rule in rules/registry.toml.", + ] + baseline = rule.baseline if rule.status is Status.RATCHETED else None + if write_baseline and baseline is None: + return False, [ + f"{package}: failed, {rule.id} is {rule.status}, " + f"so {package} has no baseline", + " Fix: fix each error instead.", + ] + try: + if sync: + sync_environment(root / package) + report = run_pyright(root / package) + except PyrightRunError as error: + return False, [f"{package}: failed, Pyright could not check it: {error}"] + check = _PackageCheck(root, package, rule, errors_in_report(report, root)) + if baseline is None: + return _check_enforced(check) + if write_baseline: + return _write_baseline(check, baseline.file) + return _check_ratcheted(check, baseline.file) diff --git a/scripts/rules/resolve.py b/scripts/rules/resolve.py index f2301f935..a37c394cc 100644 --- a/scripts/rules/resolve.py +++ b/scripts/rules/resolve.py @@ -329,6 +329,38 @@ def _resolve_mypy(rule: Rule, root: Path) -> list[Violation]: return [] +def _resolve_pyright(rule: Rule, root: Path) -> list[Violation]: + """Check that each package exists and says in pyproject.toml what Pyright checks.""" + violations = [] + for project in rule.enforcer.get("projects", []): + if not (root / project).is_dir(): + violations.append( + _v(rule, f"project {project} does not exist", "fix enforcer.projects") + ) + continue + if (root / project / "pyrightconfig.json").exists(): + violations.append( + _v( + rule, + f"{project} has a pyrightconfig.json, which Pyright reads instead " + "of its [tool.pyright]", + "move its settings into [tool.pyright] in the package's " + "pyproject.toml", + ) + ) + pyproject = _toml(root / project / "pyproject.toml") + if not pyproject.get("tool", {}).get("pyright", {}).get("include"): + violations.append( + _v( + rule, + f"{project}/pyproject.toml has no [tool.pyright] include", + "list the paths Pyright checks in the package's " + "[tool.pyright] include", + ) + ) + return violations + + def _resolve_file_field(field_name: str) -> Callable[[Rule, Path], list[Violation]]: def resolve(rule: Rule, root: Path) -> list[Violation]: value = rule.enforcer.get(field_name) @@ -368,7 +400,7 @@ def _resolve_workflow_job(rule: Rule, root: Path) -> list[Violation]: Kind.QUALITY_GUARD: _resolve_quality_guard, Kind.RUFF: _resolve_ruff, Kind.MYPY: _resolve_mypy, - Kind.PYRIGHT: _resolve_file_field("config"), + Kind.PYRIGHT: _resolve_pyright, Kind.IMPORT_LINTER: _resolve_file_field("config"), Kind.WORKFLOW_JOB: _resolve_workflow_job, Kind.DOCS_GUARD: _resolve_file_field("doc"), @@ -436,6 +468,9 @@ def resolve_ci(rule: Rule, root: Path) -> list[Violation]: project=rule.enforcer.get("project"), ) violations += _test_coverage(required, ref.invocation, job, root) + if job is not None and rule.enforcer.kind is Kind.PYRIGHT: + for project in rule.enforcer.get("projects", []): + violations += _matrix_violations(rule.id, "ci", project, job) return violations @@ -526,17 +561,24 @@ def _test_coverage( "add it to what the job runs, or move the test", ) ) - if ( - required.project - and job.matrix - and not any(required.project in values for values in job.matrix.values()) - ): - violations.append( - Violation( - required.rule_id, - required.check, - f"project {required.project} is not in the matrix of job '{job.id}'", - "add the project to the job matrix", - ) + if required.project: + violations += _matrix_violations( + required.rule_id, required.check, required.project, job ) return violations + + +def _matrix_violations( + rule_id: str, check: str, project: str, job: JobBlock +) -> list[Violation]: + """Report ``project`` when the job has a matrix that doesn't list it.""" + if not job.matrix or any(project in values for values in job.matrix.values()): + return [] + return [ + Violation( + rule_id, + check, + f"project {project} is not in the matrix of job '{job.id}'", + "add the project to the job matrix", + ) + ] diff --git a/scripts/rules/tests/test_guard.py b/scripts/rules/tests/test_guard.py index 83f313147..3f28b54d2 100644 --- a/scripts/rules/tests/test_guard.py +++ b/scripts/rules/tests/test_guard.py @@ -444,6 +444,137 @@ def test_proof_with_named_unittest_job(self) -> None: ) +PYRIGHT_WORKFLOW = """ +jobs: + pyright: + strategy: + matrix: + package: + - libs/alpha + - projects/beta + steps: + - run: python -m scripts.rules pyright "$PACKAGE" +""" + +PYRIGHT_RULE = """ +[[rule]] +id = "ex.quality.pyright" +category = "quality" +statement = "The packages have no Pyright errors." +rationale = "docs/guide.md" +scope = ["libs/alpha/src/*.py", "projects/beta/src/*.py"] +tier = "mechanical" +status = "enforced" +autofix = false +[rule.enforcer] +kind = "pyright" +projects = ["libs/alpha", "projects/beta"] +[[rule.ci]] +workflow = ".github/workflows/pyright.yml" +job = "pyright" +invocation = "scripts.rules pyright" +""" + +PYRIGHT_SETTINGS = '[project]\nname = "package"\n\n[tool.pyright]\ninclude = ["src"]\n' + + +def pyright_files() -> dict[str, str]: + """Return a repository whose Pyright rule checks two packages in a matrix job.""" + return { + ".github/workflows/pyright.yml": PYRIGHT_WORKFLOW, + "libs/alpha/pyproject.toml": PYRIGHT_SETTINGS, + "libs/alpha/src/alpha.py": "", + "projects/beta/pyproject.toml": PYRIGHT_SETTINGS, + "projects/beta/src/beta.py": "", + **with_rule(PYRIGHT_RULE), + } + + +class PyrightRuleTest(unittest.TestCase): + """A Pyright rule names the packages it checks; the guard holds CI to them.""" + + def assert_violation(self, repo: Repo, fragment: str) -> None: + messages = repo.messages() + self.assertTrue(any(fragment in m for m in messages), messages) + + def test_packages_that_declare_their_checks_in_the_matrix_pass(self) -> None: + with Repo(pyright_files()) as repo: + self.assertEqual(repo.messages(), []) + + def test_rule_must_name_its_packages(self) -> None: + with Repo(pyright_files(), render=False) as repo: + repo.edit( + "rules/registry.toml", + 'projects = ["libs/alpha", "projects/beta"]', + "projects = []", + ) + self.assert_violation(repo, "pyright enforcer names no projects") + + def test_package_must_exist(self) -> None: + with Repo(pyright_files(), render=False) as repo: + repo.edit("rules/registry.toml", '"projects/beta"]', '"projects/gamma"]') + self.assert_violation(repo, "project projects/gamma does not exist") + + def test_package_must_declare_what_pyright_checks(self) -> None: + with Repo(pyright_files(), render=False) as repo: + repo.write("libs/alpha/pyproject.toml", '[project]\nname = "alpha"\n') + self.assert_violation( + repo, "libs/alpha/pyproject.toml has no [tool.pyright] include" + ) + + def test_package_must_be_in_the_job_matrix(self) -> None: + with Repo(pyright_files(), render=False) as repo: + repo.edit( + ".github/workflows/pyright.yml", " - projects/beta\n", "" + ) + self.assert_violation( + repo, "project projects/beta is not in the matrix of job 'pyright'" + ) + + def test_package_may_belong_to_only_one_pyright_rule(self) -> None: + second_rule = PYRIGHT_RULE.replace( + 'id = "ex.quality.pyright"', 'id = "ex.quality.pyright-again"' + ).replace( + 'projects = ["libs/alpha", "projects/beta"]', 'projects = ["libs/alpha"]' + ) + with Repo(pyright_files(), render=False) as repo: + repo.write( + "rules/registry.toml", repo.read("rules/registry.toml") + second_rule + ) + self.assert_violation( + repo, + "[ex.quality.pyright-again] schema: project libs/alpha is also listed " + "by ex.quality.pyright", + ) + + def test_a_package_written_differently_is_still_the_same_package(self) -> None: + second_rule = PYRIGHT_RULE.replace( + 'id = "ex.quality.pyright"', 'id = "ex.quality.pyright-again"' + ).replace( + 'projects = ["libs/alpha", "projects/beta"]', 'projects = ["./libs/alpha"]' + ) + with Repo(pyright_files(), render=False) as repo: + repo.write( + "rules/registry.toml", repo.read("rules/registry.toml") + second_rule + ) + self.assert_violation( + repo, + "[ex.quality.pyright-again] schema: project libs/alpha is also listed " + "by ex.quality.pyright", + ) + + def test_package_may_not_have_a_pyrightconfig_json(self) -> None: + with Repo(pyright_files(), render=False) as repo: + repo.write( + "projects/beta/pyrightconfig.json", '{"typeCheckingMode": "off"}' + ) + self.assert_violation( + repo, + "projects/beta has a pyrightconfig.json, which Pyright reads instead " + "of its [tool.pyright]", + ) + + class HiddenRuleTest(unittest.TestCase): def assert_violation(self, repo: Repo, fragment: str) -> None: messages = repo.messages() @@ -694,6 +825,31 @@ def test_downgrade_needs_reason(self) -> None: assert with_reason is not None self.assertEqual(check_baselines(with_reason, base)[0], []) + def test_ratcheting_an_enforced_rule_needs_reason(self) -> None: + ratcheted = RATCHETED_RULE.format(count=50) + enforced = ratcheted.replace( + 'status = "ratcheted"', 'status = "enforced"' + ).split("[rule.baseline]")[0] + base = self.registry(enforced) + current = self.registry(ratcheted) + + violations = check_baselines(current, base)[0] + self.assertEqual( + [str(violation) for violation in violations], + [ + "[ex.layers.ratcheted] baseline: status weakened from enforced to " + 'ratcheted. Fix: add [rule.downgrade] reason = "..." so the ' + "downgrade is explicit and reviewable." + ], + ) + with_reason = self.registry( + ratcheted.replace( + "[rule.baseline]", + '[rule.downgrade]\nreason = "a Pyright upgrade"\n[rule.baseline]', + ) + ) + self.assertEqual(check_baselines(with_reason, base)[0], []) + def test_compares_against_git_base_ref(self) -> None: with Repo(with_rule(RATCHETED_RULE.format(count=1)), render=False) as repo: repo.write("rules/baselines/ratcheted.txt", "pkg/routes.py\n") diff --git a/scripts/rules/tests/test_pyright.py b/scripts/rules/tests/test_pyright.py new file mode 100644 index 000000000..378de485d --- /dev/null +++ b/scripts/rules/tests/test_pyright.py @@ -0,0 +1,1020 @@ +"""Tests for the Pyright check: one package, its locked environment, its rule.""" + +from __future__ import annotations + +import contextlib +import io +import json +import os +import shutil +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from scripts.rules.cli import main +from scripts.rules.load import load_text +from scripts.rules.pyright import ( + PYRIGHT_VERSION, + Comparison, + PyrightError, + PyrightRunError, + check_coverage, + compare, + errors_in_report, + read_baseline, + run_pyright, + unlisted_packages, +) +from scripts.rules.tests.support import REGISTRY, Repo + +ROOT = Path("/repository") +FIXTURES = Path(__file__).resolve().parents[3] / "rules" / "fixtures" +ENTRY_BACKEND = ( + "projects/policyengine-simulation-entry/src/policyengine_simulation_entry/" + "stage12_backend.py" +) +GATEWAY_ENDPOINTS = ( + "projects/policyengine-simulation-gateway/src/policyengine_simulation_gateway/" + "endpoints.py" +) +EXECUTOR_APP = "projects/policyengine-simulation-executor/src/modal/app.py" +APIS_INTEG_TESTS = "projects/policyengine-apis-integ/tests/simulation" +SERVICE_FILE = "projects/service/src/service.py" +LIBRARY_FILE = "libs/alpha/src/alpha.py" +UNBOUND = "reportPossiblyUnboundVariable" +ROUTE_UNBOUND_ENTRY = f'{SERVICE_FILE} :: {UNBOUND} :: "route" is possibly unbound' + +PYRIGHT_RULES = """ +[[rule]] +id = "ex.quality.pyright-library" +category = "quality" +statement = "The library has no Pyright errors." +rationale = "docs/guide.md" +scope = ["libs/alpha/src/*.py"] +tier = "mechanical" +status = "enforced" +autofix = false +[rule.enforcer] +kind = "pyright" +projects = ["libs/alpha"] +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "test" +invocation = "make test" + +[[rule]] +id = "ex.quality.pyright-service" +category = "quality" +statement = "The service has no Pyright errors beyond its baseline." +rationale = "docs/guide.md" +scope = ["projects/service/src/*.py"] +tier = "mechanical" +status = "ratcheted" +autofix = false +[rule.enforcer] +kind = "pyright" +projects = ["projects/service"] +[[rule.ci]] +workflow = ".github/workflows/pr.yml" +job = "test" +invocation = "make test" +[rule.baseline] +file = "rules/baselines/pyright-service.txt" +count = 2 +""" + +SERVICE_BASELINE_HEADER = "# Tolerated Pyright errors in the service.\n#\n" +SERVICE_BASELINE = ( + SERVICE_BASELINE_HEADER + f"{ROUTE_UNBOUND_ENTRY}\n{ROUTE_UNBOUND_ENTRY}\n" +) + +# A stand-in for uv: it records how it was called and prints the report the +# test left in the package directory, as `uv tool run ... pyright` would. +FAKE_UV = """#!/bin/sh +printf '%s|%s|%s\\n' "$(pwd -P)" "$*" "${PYRIGHT_PYTHON_FORCE_VERSION-unset}" \ + >> "$FAKE_UV_CALLS" +if [ "$1" = "sync" ]; then + if [ -f uv-sync-stderr.txt ]; then cat uv-sync-stderr.txt >&2; fi + exit "$(cat uv-sync-exit-code.txt 2>/dev/null || echo 0)" +fi +cat pyright-report.json 2>/dev/null +if [ -f pyright-stderr.txt ]; then cat pyright-stderr.txt >&2; fi +exit "$(cat pyright-exit-code.txt 2>/dev/null || echo 0)" +""" + + +def diagnostic( + file: Path, + line: int, + message: str, + *, + rule: str | None = UNBOUND, + severity: str = "error", +) -> dict[str, object]: + """Return one ``generalDiagnostics`` entry, as ``pyright --outputjson`` prints.""" + entry: dict[str, object] = { + "file": str(file), + "severity": severity, + "message": message, + "range": { + "start": {"line": line - 1, "character": 8}, + "end": {"line": line - 1, "character": 13}, + }, + } + if rule is not None: + entry["rule"] = rule + return entry + + +def report( + *diagnostics: dict[str, object], + version: str = PYRIGHT_VERSION, + files_analyzed: int = 3, +) -> dict[str, object]: + """Return a ``pyright --outputjson`` report holding ``diagnostics``.""" + return { + "version": version, + "generalDiagnostics": list(diagnostics), + "summary": { + "filesAnalyzed": files_analyzed, + "errorCount": sum(1 for d in diagnostics if d["severity"] == "error"), + }, + } + + +def route_unbound(root: Path, line: int) -> dict[str, object]: + """Return the service's tolerated error: ``route`` possibly unbound.""" + return diagnostic(root / SERVICE_FILE, line, '"route" is possibly unbound') + + +def pyright_error( + path: str, line: int, message: str, rule: str = UNBOUND +) -> PyrightError: + return PyrightError(path=path, line=line, column=9, rule=rule, message=message) + + +def proof_comparison(rule_id: str, fixture: str) -> Comparison: + """Compare a proof fixture's recorded Pyright report with its baseline, if any. + + The reports were recorded with the pinned Pyright on main at ceb30e17, with + the checkout's path replaced by /repository. + """ + fixture_dir = FIXTURES / rule_id / fixture + recorded_report = json.loads((fixture_dir / "pyright.json").read_text()) + baseline = fixture_dir / "baseline.txt" + return compare( + errors_in_report(recorded_report, ROOT), + read_baseline(baseline) if baseline.exists() else [], + ) + + +class TestPyrightProof(unittest.TestCase): + """Proofs: each Pyright rule's check rejects its bad fixture, accepts its good.""" + + def test_rule_proof_pyright_libraries_and_entry(self) -> None: + rule_id = "sim.quality.pyright-libraries-and-entry" + # The entry's real errors before its fix: invocation_id is assigned + # inside a span, which Pyright took to swallow exceptions. + bad = proof_comparison(rule_id, "bad") + + invocation_id_unbound = ( + f'{ENTRY_BACKEND} :: {UNBOUND} :: "invocation_id" is possibly unbound' + ) + self.assertEqual( + [(error.line, error.baseline_entry) for error in bad.new_errors], + [(550, invocation_id_unbound), (563, invocation_id_unbound)], + ) + self.assertTrue(proof_comparison(rule_id, "good").passed) + + def test_rule_proof_pyright_gateway(self) -> None: + rule_id = "sim.quality.pyright-gateway" + # The baseline lists one of two "route" errors, and an error that no + # longer occurs. + bad = proof_comparison(rule_id, "bad") + + self.assertEqual( + [(error.line, error.summary) for error in bad.new_errors], + [(924, '"route" is possibly unbound')], + ) + self.assertEqual( + bad.stale_entries, + (f'{GATEWAY_ENDPOINTS} :: {UNBOUND} :: "bundle" is possibly unbound',), + ) + self.assertTrue(proof_comparison(rule_id, "good").passed) + + def test_rule_proof_pyright_executor(self) -> None: + rule_id = "sim.quality.pyright-executor" + bad = proof_comparison(rule_id, "bad") + + self.assertEqual( + [(error.line, error.summary) for error in bad.new_errors], + [(123, '"Secret" is not a known attribute of module "modal"')], + ) + self.assertEqual( + bad.stale_entries, + ( + f"{EXECUTOR_APP} :: reportAttributeAccessIssue :: " + '"Image" is not a known attribute of module "modal"', + ), + ) + self.assertTrue(proof_comparison(rule_id, "good").passed) + + def test_rule_proof_pyright_apis_integ(self) -> None: + rule_id = "sim.quality.pyright-apis-integ" + client_mismatch = ( + 'Argument of type "Client | AuthenticatedClient" cannot be assigned to ' + 'parameter "client" of type "AuthenticatedClient" in function ' + '"sync_detailed"' + ) + bad = proof_comparison(rule_id, "bad") + + self.assertEqual( + [(error.path, error.line) for error in bad.new_errors], + [(f"{APIS_INTEG_TESTS}/test_calculate.py", 99)], + ) + self.assertEqual( + bad.stale_entries, + ( + f"{APIS_INTEG_TESTS}/conftest.py :: reportArgumentType :: " + f"{client_mismatch}", + ), + ) + self.assertTrue(proof_comparison(rule_id, "good").passed) + + +def unlisted_in_fixture(fixture: str) -> list[str]: + """Return the packages no Pyright rule lists in a coverage proof fixture.""" + fixture_dir = FIXTURES / "sim.quality.every-package-type-checked" / fixture + registry, problems = load_text((fixture_dir / "registry.toml").read_text()) + assert registry is not None, problems + return unlisted_packages(registry, fixture_dir) + + +COVERAGE_RULE = """ +[[rule]] +id = "ex.quality.every-package-type-checked" +category = "quality" +statement = "Every package is listed by a Pyright rule." +rationale = "docs/guide.md" +scope = ["libs/*/pyproject.toml"] +tier = "mechanical" +status = "enforced" +autofix = false +[rule.enforcer] +kind = "quality-guard" +name = "pyright-coverage" +""" + + +class CoverageTest(unittest.TestCase): + """Every uv package is listed by a Pyright rule.""" + + def test_the_guard_names_each_unlisted_package_and_the_fix(self) -> None: + with Repo( + { + "rules/registry.toml": REGISTRY + PYRIGHT_RULES + COVERAGE_RULE, + "libs/alpha/pyproject.toml": "", + "projects/service/pyproject.toml": "", + "projects/added/pyproject.toml": "", + }, + render=False, + ) as repo: + self.assertEqual( + check_coverage(repo.root), + [ + "[ex.quality.every-package-type-checked] pyright-coverage: " + "package projects/added has no Pyright rule, so CI doesn't " + "type-check it. Fix: add it to a Pyright rule's " + "enforcer.projects in rules/registry.toml and to the pyright " + "job's matrix in .github/workflows/pr.yml." + ], + ) + + +class TestCoverageProof(unittest.TestCase): + """Proof: the coverage guard rejects an unlisted package, accepts full lists.""" + + def test_rule_proof_every_package_type_checked(self) -> None: + # A directory holding only a lock file is not a package. + self.assertEqual(unlisted_in_fixture("bad"), ["libs/beta"]) + self.assertEqual(unlisted_in_fixture("good"), []) + + +class ErrorsInReportTest(unittest.TestCase): + def test_reads_errors_with_repository_paths_and_one_based_positions(self) -> None: + errors = errors_in_report( + report(diagnostic(ROOT / SERVICE_FILE, 12, '"call" is possibly unbound')), + ROOT, + ) + + self.assertEqual( + errors, [pyright_error(SERVICE_FILE, 12, '"call" is possibly unbound')] + ) + + def test_skips_warnings_and_information(self) -> None: + errors = errors_in_report( + report( + diagnostic(ROOT / SERVICE_FILE, 3, "Import cycle", severity="warning"), + diagnostic(ROOT / SERVICE_FILE, 4, "Unused", severity="information"), + ), + ROOT, + ) + + self.assertEqual(errors, []) + + def test_an_error_without_a_rule_is_named_error(self) -> None: + errors = errors_in_report( + report( + diagnostic(ROOT / SERVICE_FILE, 5, "Expected expression", rule=None) + ), + ROOT, + ) + + self.assertEqual( + [error.rule for error in errors], ["error"], "errors without a rule" + ) + + def test_errors_are_sorted_by_path_and_position(self) -> None: + errors = errors_in_report( + report( + route_unbound(ROOT, 20), + diagnostic(ROOT / LIBRARY_FILE, 7, '"value" is possibly unbound'), + route_unbound(ROOT, 10), + ), + ROOT, + ) + + self.assertEqual( + [(error.path, error.line) for error in errors], + [(LIBRARY_FILE, 7), (SERVICE_FILE, 10), (SERVICE_FILE, 20)], + ) + + def test_baseline_entry_has_no_position_and_only_the_first_message_line( + self, + ) -> None: + error = pyright_error( + SERVICE_FILE, + 40, + 'Argument of type "str | None" cannot be assigned to parameter "name"\n' + '\xa0\xa0Type "None" is not assignable to type "str"', + rule="reportArgumentType", + ) + + self.assertEqual( + error.baseline_entry, + f"{SERVICE_FILE} :: reportArgumentType :: " + 'Argument of type "str | None" cannot be assigned to parameter "name"', + ) + + +class CompareTest(unittest.TestCase): + def test_errors_matching_the_baseline_pass(self) -> None: + errors = errors_in_report( + report(route_unbound(ROOT, 10), route_unbound(ROOT, 20)), ROOT + ) + + comparison = compare(errors, [ROUTE_UNBOUND_ENTRY, ROUTE_UNBOUND_ENTRY]) + + self.assertEqual(comparison, Comparison(new_errors=(), stale_entries=())) + self.assertTrue(comparison.passed) + + def test_an_error_the_baseline_does_not_list_is_new(self) -> None: + call_unbound = pyright_error(SERVICE_FILE, 30, '"call" is possibly unbound') + + comparison = compare([call_unbound], []) + + self.assertEqual(comparison.new_errors, (call_unbound,)) + self.assertFalse(comparison.passed) + + def test_one_more_error_than_the_baseline_lists_is_new(self) -> None: + errors = errors_in_report( + report(route_unbound(ROOT, 10), route_unbound(ROOT, 20)), ROOT + ) + + comparison = compare(errors, [ROUTE_UNBOUND_ENTRY]) + + self.assertEqual( + comparison.new_errors, + (pyright_error(SERVICE_FILE, 20, '"route" is possibly unbound'),), + ) + self.assertEqual(comparison.stale_entries, ()) + + def test_an_entry_that_no_longer_occurs_is_stale(self) -> None: + errors = errors_in_report(report(route_unbound(ROOT, 10)), ROOT) + + comparison = compare(errors, [ROUTE_UNBOUND_ENTRY, ROUTE_UNBOUND_ENTRY]) + + self.assertEqual(comparison.new_errors, ()) + self.assertEqual(comparison.stale_entries, (ROUTE_UNBOUND_ENTRY,)) + self.assertFalse(comparison.passed) + + +class ReadBaselineTest(unittest.TestCase): + def test_skips_blank_and_comment_lines(self) -> None: + with tempfile.TemporaryDirectory() as directory: + baseline = Path(directory) / "baseline.txt" + baseline.write_text(SERVICE_BASELINE + "\n # indented comment\n") + + self.assertEqual( + read_baseline(baseline), [ROUTE_UNBOUND_ENTRY, ROUTE_UNBOUND_ENTRY] + ) + + +class FakeUvTestCase(unittest.TestCase): + """Puts a fake ``uv`` first on PATH and records each call it receives.""" + + def setUp(self) -> None: + bin_directory = tempfile.TemporaryDirectory() + self.addCleanup(bin_directory.cleanup) + fake_uv = Path(bin_directory.name) / "uv" + fake_uv.write_text(FAKE_UV) + fake_uv.chmod(0o755) + self.calls_file = Path(bin_directory.name) / "calls.txt" + environment = mock.patch.dict( + os.environ, + { + "PATH": f"{bin_directory.name}{os.pathsep}{os.environ['PATH']}", + "FAKE_UV_CALLS": str(self.calls_file), + "PYRIGHT_PYTHON_FORCE_VERSION": "latest", + }, + ) + environment.start() + self.addCleanup(environment.stop) + + def uv_calls(self) -> list[list[str]]: + """Return each call as [working directory, arguments, forced version].""" + if not self.calls_file.exists(): + return [] + return [line.split("|") for line in self.calls_file.read_text().splitlines()] + + +def make_package(root: Path, package: str) -> Path: + """Create a package directory with a locked environment's interpreter.""" + package_dir = root / package + interpreter = package_dir / ".venv" / "bin" / "python" + interpreter.parent.mkdir(parents=True) + interpreter.write_text("") + return package_dir + + +def leave_report(package_dir: Path, pyright_report: object) -> None: + """Leave the report the fake ``uv`` prints when run in ``package_dir``.""" + (package_dir / "pyright-report.json").write_text(json.dumps(pyright_report)) + + +class RunPyrightTest(FakeUvTestCase): + def setUp(self) -> None: + super().setUp() + root_directory = tempfile.TemporaryDirectory() + self.addCleanup(root_directory.cleanup) + self.package_dir = make_package(Path(root_directory.name), "libs/alpha") + + def assert_run_fails(self, fragment: str) -> None: + with self.assertRaises(PyrightRunError) as raised: + run_pyright(self.package_dir) + self.assertIn(fragment, str(raised.exception)) + + def test_runs_the_pinned_pyright_with_the_package_interpreter(self) -> None: + leave_report(self.package_dir, report()) + + self.assertEqual(run_pyright(self.package_dir), report()) + interpreter = self.package_dir.resolve() / ".venv" / "bin" / "python" + self.assertEqual( + self.uv_calls(), + [ + [ + str(self.package_dir.resolve()), + f"tool run --from pyright=={PYRIGHT_VERSION} pyright " + f"--outputjson --pythonpath {interpreter} " + f"--project {self.package_dir.resolve()}", + "unset", + ] + ], + ) + + def test_requires_the_locked_environment(self) -> None: + (self.package_dir / ".venv" / "bin" / "python").unlink() + + self.assert_run_fails( + "there is no locked environment: " + f"{self.package_dir.resolve() / '.venv' / 'bin' / 'python'} does not " + "exist; run `uv sync --frozen --all-extras` in " + f"{self.package_dir.resolve()} first" + ) + self.assertEqual(self.uv_calls(), []) + + def test_rejects_a_run_that_did_not_finish(self) -> None: + (self.package_dir / "pyright-exit-code.txt").write_text("3") + (self.package_dir / "pyright-stderr.txt").write_text("Config file invalid\n") + + self.assert_run_fails("Pyright exited with status 3: Config file invalid") + + def test_rejects_output_that_is_not_a_json_report(self) -> None: + self.assert_run_fails("Pyright printed no JSON report") + + def test_rejects_another_pyright_version(self) -> None: + leave_report(self.package_dir, report(version="1.1.400")) + + self.assert_run_fails( + f"Pyright reported version 1.1.400, not the pinned {PYRIGHT_VERSION}" + ) + + def test_rejects_a_run_that_checked_no_files(self) -> None: + leave_report(self.package_dir, report(files_analyzed=0)) + + self.assert_run_fails("Pyright checked no files") + + +class ParentConfigurationTest(unittest.TestCase): + """Run the pinned Pyright for real; a parent pyrightconfig.json is not used. + + Started in a package directory without --project, Pyright 1.1.414 also uses + a pyrightconfig.json from a parent directory (the repository root, libs/ or + projects/) instead of the package's own [tool.pyright]. + """ + + def setUp(self) -> None: + if shutil.which("uv") is None: + if os.environ.get("GITHUB_ACTIONS"): + self.fail("uv must be installed in CI") + self.skipTest("uv is not installed") + + def test_a_parent_pyrightconfig_json_does_not_switch_checking_off(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + package_dir = root / "libs" / "alpha" + (package_dir / "src").mkdir(parents=True) + (package_dir / "pyproject.toml").write_text( + '[project]\nname = "alpha"\nversion = "0"\n\n' + '[tool.pyright]\ninclude = ["src"]\n' + ) + (package_dir / "src" / "alpha.py").write_text( + 'def broken() -> int:\n return "text"\n' + ) + interpreter = package_dir / ".venv" / "bin" / "python" + interpreter.parent.mkdir(parents=True) + interpreter.symlink_to(sys.executable) + for parent in (root, root / "libs"): + (parent / "pyrightconfig.json").write_text( + '{"typeCheckingMode": "off"}' + ) + + errors = errors_in_report(run_pyright(package_dir), root) + + self.assertEqual( + [(error.path, error.rule) for error in errors], + [("libs/alpha/src/alpha.py", "reportReturnType")], + ) + + +class PyrightCommandTest(FakeUvTestCase): + def setUp(self) -> None: + super().setUp() + self.repo = Repo( + { + "rules/registry.toml": REGISTRY + PYRIGHT_RULES, + "rules/baselines/pyright-service.txt": SERVICE_BASELINE, + }, + render=False, + ) + self.addCleanup(self.repo.__exit__) + self.root = self.repo.root.resolve() + self.library = make_package(self.root, "libs/alpha") + self.service = make_package(self.root, "projects/service") + leave_report(self.library, report()) + leave_report( + self.service, + report(route_unbound(self.root, 10), route_unbound(self.root, 20)), + ) + + def run_pyright_command(self, *arguments: str) -> tuple[int, list[str]]: + output = io.StringIO() + with contextlib.redirect_stdout(output): + status = main(["pyright", *arguments, "--root", str(self.root)]) + return status, output.getvalue().splitlines() + + def test_enforced_package_without_errors_passes(self) -> None: + self.assertEqual( + self.run_pyright_command("libs/alpha"), + ( + 0, + [ + "libs/alpha: passed with no Pyright errors " + "(ex.quality.pyright-library, enforced)", + "pyright: passed", + ], + ), + ) + + def test_enforced_package_with_an_error_fails(self) -> None: + leave_report( + self.library, + report( + diagnostic( + self.root / LIBRARY_FILE, + 7, + 'Argument of type "str | None" cannot be assigned to parameter ' + '"name"\n\xa0\xa0Type "None" is not assignable to type "str"', + rule="reportArgumentType", + ) + ), + ) + + self.assertEqual( + self.run_pyright_command("libs/alpha"), + ( + 1, + [ + "libs/alpha: failed with 1 Pyright error, where " + "ex.quality.pyright-library (enforced) allows none", + f" error: {LIBRARY_FILE}:7:9 - Argument of type " + '"str | None" cannot be assigned to parameter "name" ' + "(reportArgumentType)", + ' Type "None" is not assignable to type "str"', + " Fix: fix each error. Suppress one only as a last resort, " + "with # pyright: ignore[] and the reason.", + "pyright: failed", + ], + ), + ) + + def test_ratcheted_package_with_exactly_its_baseline_passes(self) -> None: + self.assertEqual( + self.run_pyright_command("projects/service"), + ( + 0, + [ + "projects/service: passed with 2 Pyright errors, each listed in " + "rules/baselines/pyright-service.txt " + "(ex.quality.pyright-service, ratcheted)", + "pyright: passed", + ], + ), + ) + + def test_ratcheted_package_with_a_new_error_fails(self) -> None: + leave_report( + self.service, + report( + route_unbound(self.root, 10), + route_unbound(self.root, 20), + diagnostic(self.root / SERVICE_FILE, 30, '"call" is possibly unbound'), + ), + ) + + self.assertEqual( + self.run_pyright_command("projects/service"), + ( + 1, + [ + "projects/service: failed, its Pyright errors differ from " + "rules/baselines/pyright-service.txt " + "(ex.quality.pyright-service, ratcheted)", + f" new error: {SERVICE_FILE}:30:9 - " + f'"call" is possibly unbound ({UNBOUND})', + " Fix: fix each new error; the baseline only shrinks.", + "pyright: failed", + ], + ), + ) + + def test_ratcheted_package_fails_when_a_listed_error_no_longer_occurs( + self, + ) -> None: + leave_report(self.service, report(route_unbound(self.root, 10))) + + self.assertEqual( + self.run_pyright_command("projects/service"), + ( + 1, + [ + "projects/service: failed, its Pyright errors differ from " + "rules/baselines/pyright-service.txt " + "(ex.quality.pyright-service, ratcheted)", + f" no longer occurs: {ROUTE_UNBOUND_ENTRY}", + " Fix: remove the entries that no longer occur with " + "python -m scripts.rules pyright projects/service " + "--write-baseline, then set count under [rule.baseline] " + "of ex.quality.pyright-service to the number left.", + "pyright: failed", + ], + ), + ) + + def test_a_new_error_and_a_stale_entry_are_both_reported_with_both_fixes( + self, + ) -> None: + leave_report( + self.service, + report( + route_unbound(self.root, 10), + diagnostic(self.root / SERVICE_FILE, 30, '"call" is possibly unbound'), + ), + ) + + status, lines = self.run_pyright_command("projects/service") + + self.assertEqual(status, 1) + self.assertEqual( + lines[1:], + [ + f" new error: {SERVICE_FILE}:30:9 - " + f'"call" is possibly unbound ({UNBOUND})', + f" no longer occurs: {ROUTE_UNBOUND_ENTRY}", + " Fix: fix each new error; the baseline only shrinks.", + " Fix: remove the entries that no longer occur with " + "python -m scripts.rules pyright projects/service " + "--write-baseline, then set count under [rule.baseline] " + "of ex.quality.pyright-service to the number left.", + "pyright: failed", + ], + ) + + def test_write_baseline_removes_entries_that_no_longer_occur(self) -> None: + leave_report(self.service, report(route_unbound(self.root, 10))) + + self.assertEqual( + self.run_pyright_command("projects/service", "--write-baseline"), + ( + 0, + [ + "projects/service: wrote rules/baselines/pyright-service.txt " + "with 1 entry; set count = 1 under [rule.baseline] of " + "ex.quality.pyright-service in rules/registry.toml", + "pyright: passed", + ], + ), + ) + self.assertEqual( + self.repo.read("rules/baselines/pyright-service.txt"), + SERVICE_BASELINE_HEADER + ROUTE_UNBOUND_ENTRY + "\n", + ) + + def test_write_baseline_never_adds_a_new_error(self) -> None: + leave_report( + self.service, + report( + route_unbound(self.root, 10), + diagnostic(self.root / SERVICE_FILE, 30, '"call" is possibly unbound'), + ), + ) + + self.assertEqual( + self.run_pyright_command("projects/service", "--write-baseline"), + ( + 1, + [ + "projects/service: failed, --write-baseline never adds an " + "error to rules/baselines/pyright-service.txt", + f" new error: {SERVICE_FILE}:30:9 - " + f'"call" is possibly unbound ({UNBOUND})', + " Fix: fix each new error; the baseline only shrinks.", + "pyright: failed", + ], + ), + ) + self.assertEqual( + self.repo.read("rules/baselines/pyright-service.txt"), SERVICE_BASELINE + ) + + def test_write_baseline_records_every_error_when_the_rule_is_adopted( + self, + ) -> None: + (self.root / "rules/baselines/pyright-service.txt").unlink() + + status, lines = self.run_pyright_command("projects/service", "--write-baseline") + + self.assertEqual( + (status, lines[0]), + ( + 0, + "projects/service: wrote rules/baselines/pyright-service.txt " + "with 2 entries; set count = 2 under [rule.baseline] of " + "ex.quality.pyright-service in rules/registry.toml", + ), + ) + baseline = self.root / "rules/baselines/pyright-service.txt" + self.assertEqual( + read_baseline(baseline), [ROUTE_UNBOUND_ENTRY, ROUTE_UNBOUND_ENTRY] + ) + self.assertIn( + "python -m scripts.rules pyright projects/service --write-baseline", + baseline.read_text(), + ) + + def test_write_baseline_sorts_its_entries(self) -> None: + (self.root / "rules/baselines/pyright-service.txt").unlink() + leave_report( + self.service, + report( + route_unbound(self.root, 10), + diagnostic(self.root / SERVICE_FILE, 20, '"call" is possibly unbound'), + ), + ) + + self.run_pyright_command("projects/service", "--write-baseline") + + self.assertEqual( + read_baseline(self.root / "rules/baselines/pyright-service.txt"), + [ + f'{SERVICE_FILE} :: {UNBOUND} :: "call" is possibly unbound', + ROUTE_UNBOUND_ENTRY, + ], + ) + + def test_write_baseline_without_packages_rewrites_each_ratcheted_baseline( + self, + ) -> None: + leave_report(self.service, report(route_unbound(self.root, 10))) + + self.assertEqual( + self.run_pyright_command("--write-baseline"), + ( + 0, + [ + "projects/service: wrote rules/baselines/pyright-service.txt " + "with 1 entry; set count = 1 under [rule.baseline] of " + "ex.quality.pyright-service in rules/registry.toml", + "pyright: passed", + ], + ), + ) + self.assertEqual([Path(call[0]) for call in self.uv_calls()], [self.service]) + + def test_write_baseline_refuses_an_enforced_rule(self) -> None: + self.assertEqual( + self.run_pyright_command("libs/alpha", "--write-baseline"), + ( + 1, + [ + "libs/alpha: failed, ex.quality.pyright-library is enforced, " + "so libs/alpha has no baseline", + " Fix: fix each error instead.", + "pyright: failed", + ], + ), + ) + self.assertEqual(self.uv_calls(), []) + + def test_package_without_a_rule_fails(self) -> None: + self.assertEqual( + self.run_pyright_command("libs/unregistered"), + ( + 1, + [ + "libs/unregistered: failed, no Pyright rule lists it in " + "enforcer.projects", + " Fix: add it to a Pyright rule in rules/registry.toml.", + "pyright: failed", + ], + ), + ) + + def test_a_pyright_run_error_fails_the_package(self) -> None: + (self.library / ".venv" / "bin" / "python").unlink() + + status, lines = self.run_pyright_command("libs/alpha") + + self.assertEqual(status, 1) + self.assertEqual( + lines, + [ + "libs/alpha: failed, Pyright could not check it: there is no " + f"locked environment: {self.library}/.venv/bin/python does not " + f"exist; run `uv sync --frozen --all-extras` in {self.library} first", + "pyright: failed", + ], + ) + + def test_sync_installs_the_locked_environment_before_checking(self) -> None: + status, _ = self.run_pyright_command("libs/alpha", "--sync") + + self.assertEqual(status, 0) + self.assertEqual( + [call[:2] for call in self.uv_calls()], + [ + [str(self.library), "sync --frozen --all-extras"], + [ + str(self.library), + f"tool run --from pyright=={PYRIGHT_VERSION} pyright " + f"--outputjson --pythonpath {self.library}/.venv/bin/python " + f"--project {self.library}", + ], + ], + ) + + def test_sync_then_write_baseline(self) -> None: + leave_report(self.service, report(route_unbound(self.root, 10))) + + status, _ = self.run_pyright_command( + "projects/service", "--sync", "--write-baseline" + ) + + self.assertEqual(status, 0) + self.assertEqual( + [call[1].split()[0] for call in self.uv_calls()], ["sync", "tool"] + ) + self.assertEqual( + read_baseline(self.root / "rules/baselines/pyright-service.txt"), + [ROUTE_UNBOUND_ENTRY], + ) + + def test_a_failed_sync_fails_the_package(self) -> None: + (self.library / "uv-sync-exit-code.txt").write_text("2") + (self.library / "uv-sync-stderr.txt").write_text( + "error: The lockfile needs to be updated\n" + ) + + self.assertEqual( + self.run_pyright_command("libs/alpha", "--sync"), + ( + 1, + [ + "libs/alpha: failed, Pyright could not check it: " + "`uv sync --frozen --all-extras` exited with status 2: " + "error: The lockfile needs to be updated", + "pyright: failed", + ], + ), + ) + self.assertEqual(len(self.uv_calls()), 1) + + def test_without_packages_checks_every_package_a_rule_lists(self) -> None: + status, lines = self.run_pyright_command() + + self.assertEqual(status, 0) + self.assertEqual( + [line.split(":")[0] for line in lines], + ["libs/alpha", "projects/service", "pyright"], + ) + self.assertEqual( + [Path(call[0]) for call in self.uv_calls()], + [self.library, self.service], + ) + + def test_package_arguments_apply_only_to_pyright(self) -> None: + with ( + contextlib.redirect_stderr(io.StringIO()) as error_output, + self.assertRaises(SystemExit) as raised, + ): + main(["validate", "libs/alpha", "--root", str(self.root)]) + + self.assertEqual(raised.exception.code, 2) + self.assertIn("apply only to pyright", error_output.getvalue()) + + def assert_usage_error(self, arguments: list[str], fragment: str) -> None: + with ( + contextlib.redirect_stderr(io.StringIO()) as error_output, + self.assertRaises(SystemExit) as raised, + ): + main([*arguments, "--root", str(self.root)]) + self.assertEqual(raised.exception.code, 2) + self.assertIn(fragment, error_output.getvalue()) + + def test_the_ci_scripts_baseline_flag_is_refused(self) -> None: + self.assert_usage_error( + ["pyright", "--write-baselines"], + "--write-baselines applies only to ci-scripts; pyright takes " + "--write-baseline", + ) + + def test_list_packages_takes_no_other_pyright_options(self) -> None: + for arguments in ( + ["pyright", "--list-packages", "libs/alpha"], + ["pyright", "--list-packages", "--sync"], + ["pyright", "--list-packages", "--write-baseline"], + ): + with self.subTest(arguments=arguments): + self.assert_usage_error( + arguments, "--list-packages takes no packages or other options" + ) + + def test_write_baseline_without_a_ratcheted_rule_says_so(self) -> None: + self.repo.edit( + "rules/registry.toml", 'status = "ratcheted"', 'status = "enforced"' + ) + + self.assertEqual( + self.run_pyright_command("--write-baseline"), + ( + 0, + [ + "No Pyright rule is ratcheted, so there is no baseline to write.", + "pyright: passed", + ], + ), + ) + self.assertEqual(self.uv_calls(), []) + + def test_list_packages_prints_every_package_a_rule_lists(self) -> None: + self.assertEqual( + self.run_pyright_command("--list-packages"), + (0, ["libs/alpha", "projects/service"]), + ) + self.assertEqual(self.uv_calls(), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/rules/tests/test_render.py b/scripts/rules/tests/test_render.py index dbed7c2dc..70c50708c 100644 --- a/scripts/rules/tests/test_render.py +++ b/scripts/rules/tests/test_render.py @@ -96,10 +96,10 @@ def test_each_kind_describes_its_checker(self) -> None: (Kind.WORKFLOW_JOB, {"step": "Build"}, "CI job"), ( Kind.PYRIGHT, - {"config": "pyrightconfig.json"}, - "pyright `pyrightconfig.json`", + {"projects": ["libs/alpha", "projects/beta"]}, + "pyright in `libs/alpha`, `projects/beta`", ), - (Kind.PYRIGHT, {}, "pyright ``"), + (Kind.PYRIGHT, {}, "pyright in "), ( Kind.IMPORT_LINTER, {"config": ".importlinter"}, diff --git a/scripts/rules/validate.py b/scripts/rules/validate.py index 67f17ec4e..ca3c8854c 100644 --- a/scripts/rules/validate.py +++ b/scripts/rules/validate.py @@ -6,7 +6,15 @@ from datetime import date from typing import TYPE_CHECKING, Any -from scripts.rules.model import Kind, Registry, Rule, Status, Tier, Violation +from scripts.rules.model import ( + Kind, + Registry, + Rule, + Status, + Tier, + Violation, + normalized_package, +) from scripts.rules.resolve import PROOF_CI_FIELDS, node_exists if TYPE_CHECKING: @@ -20,7 +28,7 @@ Kind.QUALITY_GUARD: ({"name": str}, {"runner": str}), Kind.RUFF: ({"mode": str}, {"codes": list, "pattern": str, "config": str}), Kind.MYPY: ({"paths": list}, {"config": str}), - Kind.PYRIGHT: ({"config": str}, {"project": str}), + Kind.PYRIGHT: ({"projects": list}, {}), Kind.IMPORT_LINTER: ({"config": str, "contract": str}, {}), Kind.WORKFLOW_JOB: ({}, {"step": str}), Kind.DOCS_GUARD: ({"doc": str, "marker": str}, {}), @@ -115,6 +123,15 @@ def _validate_enforcer(rule: Rule) -> list[Violation]: "list at least one test node", ) ) + if kind is Kind.PYRIGHT and not fields.get("projects"): + violations.append( + Violation( + rule.id, + "schema", + "pyright enforcer names no projects", + "list the package directories Pyright checks", + ) + ) if kind is Kind.RUFF: mode = fields.get("mode") if mode not in RUFF_MODES: @@ -471,6 +488,32 @@ def _validate_rule( ] +def _pyright_projects_listed_twice(registry: Registry) -> list[Violation]: + """Report each package a second Pyright rule lists. + + The Pyright check holds a package to the first rule that lists it, so a + second listing would be silently ignored. + """ + owners: dict[str, str] = {} + violations = [] + for rule in registry.rules: + if rule.enforcer.kind is not Kind.PYRIGHT: + continue + for project in rule.enforcer.get("projects", []): + name = normalized_package(str(project)) + if name in owners: + violations.append( + _schema_violation( + rule, + f"project {name} is also listed by {owners[name]}", + "list each package in one Pyright rule", + ) + ) + else: + owners[name] = rule.id + return violations + + def validate(registry: Registry, root: Path, today: date) -> list[Violation]: """Check the registry's fields and file references against the tree at ``root``. @@ -484,4 +527,5 @@ def validate(registry: Registry, root: Path, today: date) -> list[Violation]: ] for rule in registry.rules: violations.extend(_validate_rule(rule, root, category_ids, today)) + violations.extend(_pyright_projects_listed_twice(registry)) return violations diff --git a/scripts/run_quality_guards.py b/scripts/run_quality_guards.py index c6d7f4cc1..45dda356e 100644 --- a/scripts/run_quality_guards.py +++ b/scripts/run_quality_guards.py @@ -10,7 +10,7 @@ if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) -from scripts.rules import ci_scripts # noqa: E402 +from scripts.rules import ci_scripts, pyright # noqa: E402 from scripts.rules import guard as rules_guard # noqa: E402 Guard = tuple[str, Callable[[], list[str]]] @@ -18,6 +18,7 @@ GUARDS: tuple[Guard, ...] = ( ("rules-registry", rules_guard.check), ("ci-scripts", ci_scripts.check), + ("pyright-coverage", pyright.check_coverage), )