This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git
The following commit(s) were added to refs/heads/main by this push:
new 67a2d53 feat(preflight-audit): CLI to dry-run the bulk-mode
classifier against real or replayed tracker state (#418)
67a2d53 is described below
commit 67a2d53498adf75390d456faaecef686bbda08be
Author: Jarek Potiuk <[email protected]>
AuthorDate: Sun May 31 16:08:33 2026 +0200
feat(preflight-audit): CLI to dry-run the bulk-mode classifier against real
or replayed tracker state (#418)
The bulk-mode pre-flight classifier
(security-issue-sync § bulk-mode.md) has a rule table that
evolves as we learn how real adopter trackers behave. Each rule
change needs a before / after measurement to know whether the
change helped (more skips) or hurt (false-positive skips). The
recent classifier-tuning PR was driven by a one-off `/tmp/`
script — easy to lose, hard to reproduce, can't be wired into
CI.
`tools/preflight-audit/` promotes that pattern into a permanent
tool:
preflight-audit classify --repo <r> --issues 1,2,3 [--now ISO]
preflight-audit classify --load resp.json --now ISO [--json]
- Live mode shells out to `gh api graphql` (same aliased
multi-field query as the skill).
- Replay mode reads a pre-fetched response from disk —
deterministic, network-free, suitable for CI eval fixtures.
- Default output is the same grouped table as the original
dry-run script; `--json` gives machine-readable output.
- `--bot-logins` lets adopters extend the bot-equivalent
detection for personal-account bots.
The classifier in `src/preflight_audit/classifier.py` is the
**executable spec** of the rule table in
`security-issue-sync/bulk-mode.md`. Both must be edited in
lock-step — a PR that changes one should change the other. 37
unit tests cover each rule with a focused positive + negative
case, the skill-marker detection (including parametrized edge
cases for the marker-match precision), and `classify_response`
end-to-end.
The intended workflow when changing a rule:
1. Run preflight-audit before the change to capture skip-rate.
2. Edit the rule table in `bulk-mode.md` AND the matching
condition in `classifier.py`.
3. Re-run to capture the after.
4. Cite both numbers in the PR body.
The tool itself produces no GitHub state changes — it is
read-only by design. Capability declared as `capability:stats`.
---
.pre-commit-config.yaml | 30 ++
docs/labels-and-capabilities.md | 1 +
tools/preflight-audit/README.md | 136 ++++++
tools/preflight-audit/pyproject.toml | 87 ++++
.../src/preflight_audit/__init__.py | 36 ++
.../src/preflight_audit/classifier.py | 273 ++++++++++++
tools/preflight-audit/src/preflight_audit/cli.py | 183 +++++++++
tools/preflight-audit/src/preflight_audit/fetch.py | 73 ++++
tools/preflight-audit/tests/__init__.py | 0
tools/preflight-audit/tests/test_classifier.py | 456 +++++++++++++++++++++
tools/preflight-audit/tests/test_cli.py | 235 +++++++++++
tools/preflight-audit/uv.lock | 309 ++++++++++++++
12 files changed, 1819 insertions(+)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index ca604df..46788c1 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -409,6 +409,36 @@ repos:
files: ^tools/permission-audit/(src|tests|pyproject\.toml)
pass_filenames: false
+ # Project-local checks for the pre-flight classifier dry-run tool
+ # at `tools/preflight-audit/`. Lets us measure skip-rate before /
+ # after any rule edit in `bulk-mode.md`.
+ - repo: local
+ hooks:
+ - id: preflight-audit-ruff-check
+ name: ruff check (preflight-audit)
+ language: system
+ entry: uv run --directory tools/preflight-audit ruff check
+ files: ^tools/preflight-audit/(src|tests|pyproject\.toml)
+ pass_filenames: false
+ - id: preflight-audit-ruff-format
+ name: ruff format (preflight-audit)
+ language: system
+ entry: uv run --directory tools/preflight-audit ruff format --check
+ files: ^tools/preflight-audit/(src|tests|pyproject\.toml)
+ pass_filenames: false
+ - id: preflight-audit-mypy
+ name: mypy (preflight-audit)
+ language: system
+ entry: uv run --directory tools/preflight-audit mypy
+ files: ^tools/preflight-audit/(src|tests|pyproject\.toml)
+ pass_filenames: false
+ - id: preflight-audit-pytest
+ name: pytest (preflight-audit)
+ language: system
+ entry: uv run --directory tools/preflight-audit pytest
+ files: ^tools/preflight-audit/(src|tests|pyproject\.toml)
+ pass_filenames: false
+
# Validate `.claude/skills/**`, every `tools/<name>/README.md`, and the
# `docs/labels-and-capabilities.md` taxonomy via the
# `skill-and-tool-validate` CLI. Re-fires on validator-source changes so
diff --git a/docs/labels-and-capabilities.md b/docs/labels-and-capabilities.md
index 4a38785..c3e59cc 100644
--- a/docs/labels-and-capabilities.md
+++ b/docs/labels-and-capabilities.md
@@ -187,6 +187,7 @@ Tools under [`tools/`](../tools/). Tools with two values
(separated by
| [`tools/ponymail`](../tools/ponymail/) | `capability:setup` +
`capability:intake` | PonyMail archive substrate; same dual role as
`mail-source` — substrate plus an intake-pipeline component |
| [`tools/permission-audit`](../tools/permission-audit/) | `capability:setup`
| Audit + atomically edit Claude Code `permissions.allow[]` entries; backs
`/setup-steward verify --apply-permission-audit` (check 8d) |
| [`tools/pr-management-stats`](../tools/pr-management-stats/) |
`capability:stats` | PR-backlog analytics engine |
+| [`tools/preflight-audit`](../tools/preflight-audit/) | `capability:stats` |
Dry-run the bulk-mode pre-flight classifier; measure skip-rate before / after
any rule edit in the security-issue-sync skill |
| [`tools/privacy-llm`](../tools/privacy-llm/) | `capability:setup` |
Privacy-LLM PII-scrubbing gate |
| [`tools/probe-templates`](../tools/probe-templates/) | `capability:setup` |
Sandbox-doctor probe templates |
| [`tools/sandbox-lint`](../tools/sandbox-lint/) | `capability:setup` |
Sandbox settings linter |
diff --git a/tools/preflight-audit/README.md b/tools/preflight-audit/README.md
new file mode 100644
index 0000000..a7684de
--- /dev/null
+++ b/tools/preflight-audit/README.md
@@ -0,0 +1,136 @@
+<!-- START doctoc generated TOC please keep comment here to allow auto update
-->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+**Table of Contents** *generated with
[DocToc](https://github.com/thlorenz/doctoc)*
+
+- [preflight-audit](#preflight-audit)
+ - [Why](#why)
+ - [Invocation](#invocation)
+ - [Live mode](#live-mode)
+ - [Replay mode](#replay-mode)
+ - [Output](#output)
+ - [How the rules stay in sync](#how-the-rules-stay-in-sync)
+ - [Tuning workflow](#tuning-workflow)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+<!-- SPDX-License-Identifier: Apache-2.0
+ https://www.apache.org/licenses/LICENSE-2.0 -->
+
+# preflight-audit
+
+**Capability:** capability:stats
+
+Dry-run the bulk-mode pre-flight classifier against a real or
+replayed tracker. Use to **measure skip-rate before / after any
+rule change** — closes the tune-then-verify loop so rule edits
+are made against evidence, not guesswork.
+
+## Why
+
+The bulk-mode pre-flight classifier
+([`bulk-mode.md` § Pre-flight no-op
classifier](../../.claude/skills/security-issue-sync/SKILL.md))
+decides whether to dispatch a subagent for each tracker in a
+bulk sync. Its rule table evolves as we learn how real adopter
+trackers behave. Each rule change needs a before / after
+measurement to know whether the change helped (more skips) or
+hurt (false-positive skips that miss real work).
+
+Without a tool, that measurement is a one-off Python script per
+PR — easy to lose, hard to reproduce, can't be wired into CI.
+This tool makes the measurement reusable.
+
+## Invocation
+
+```bash
+uv run --directory tools/preflight-audit preflight-audit classify [options]
+```
+
+### Live mode
+
+Fetch tracker state via `gh api graphql` and classify:
+
+```bash
+uv run --directory tools/preflight-audit preflight-audit classify \
+ --repo <owner>/<name> \
+ --issues 221,232,233,242,244
+```
+
+`--issues` is a comma-separated list of issue numbers (with or
+without `#` prefix). For the full `sync all` set, resolve the
+list yourself via `gh issue list --json number` and pipe the
+numbers in — the tool intentionally doesn't reimplement
+selector resolution (that's the sync skill's job).
+
+Pass `--bot-logins login1,login2` to extend the bot-equivalent
+login list for an adopter with personal-account bots.
+
+### Replay mode
+
+Classify a pre-fetched GraphQL response — for CI / regression
+testing without network calls:
+
+```bash
+uv run --directory tools/preflight-audit preflight-audit classify \
+ --load tests/fixtures/sample_response.json \
+ --now 2026-05-31T12:00:00Z
+```
+
+`--now` is required for replay mode to keep the classification
+deterministic (rules depend on "days ago" calculations).
+
+## Output
+
+Default is a human-readable grouped table:
+
+```text
+Total trackers: 43
+ dispatch: 29 (67%)
+ dispatch-urgent: 0 (0%)
+ skip-noop: 14 (32%)
+
+--- skip-noop (14) ---
+ # 221 OPEN last=<bot> [skill] → fix released; awaiting advisory;
skill-last (1d)
+ labels: airflow+cve allocated+fix released
+ ...
+
+=== Estimated savings ===
+Subagents skipped: 14 → ~700 KB context saved (~175000 tokens @ 250 tok/KB)
+```
+
+Pass `--json` for machine-readable output (one object per
+tracker with number, decision, reason, label set).
+
+## How the rules stay in sync
+
+The classifier in
+[`src/preflight_audit/classifier.py`](src/preflight_audit/classifier.py)
+is the executable spec of the rule table in
+[`.claude/skills/security-issue-sync/bulk-mode.md`](../../.claude/skills/security-issue-sync/bulk-mode.md).
+The skill instructs the orchestrator how to apply the rules
+prose-by-prose; this tool implements them in code. Both forms
+describe the same rules and **must be edited in lock-step** —
+a PR that changes one should change the other.
+
+The tests in
+[`tests/test_classifier.py`](tests/test_classifier.py) cover
+each rule with a focused synthetic-input case. If the skill's
+rule table grows a new entry, add the matching `test_rule_N_*`
+case here.
+
+## Tuning workflow
+
+The intended workflow when changing a rule:
+
+1. Run `preflight-audit classify --repo <r> --issues <list>`
+ against your adopter tracker to capture the **before**
+ distribution.
+2. Edit the rule table in `bulk-mode.md` AND the matching
+ condition in `classifier.py`.
+3. Re-run the same command to capture the **after**
+ distribution.
+4. Cite both numbers in the PR body. (Strip adopter-specific
+ identifiers if the PR is public.)
+
+For tests, save the captured GraphQL response as a fixture and
+add a replay-mode test that asserts the expected classification
+breakdown — that's the eval-fixture pattern.
diff --git a/tools/preflight-audit/pyproject.toml
b/tools/preflight-audit/pyproject.toml
new file mode 100644
index 0000000..c2063f2
--- /dev/null
+++ b/tools/preflight-audit/pyproject.toml
@@ -0,0 +1,87 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "preflight-audit"
+version = "0.1.0"
+description = "Dry-run the bulk-mode pre-flight classifier against a real or
replayed tracker — measure skip-rate before / after any rule change."
+readme = "README.md"
+requires-python = ">=3.11"
+license = { text = "Apache-2.0" }
+# Stdlib-only at runtime; shells out to `gh` for live mode.
+dependencies = []
+
+[project.scripts]
+preflight-audit = "preflight_audit:main"
+
+[dependency-groups]
+dev = [
+ "mypy>=2.1.0",
+ "pytest>=8.0",
+ "ruff>=0.15.14",
+]
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/preflight_audit"]
+
+[tool.ruff]
+line-length = 110
+target-version = "py311"
+src = ["src", "tests"]
+
+[tool.ruff.lint]
+select = [
+ "E",
+ "W",
+ "F",
+ "I",
+ "B",
+ "UP",
+ "SIM",
+ "C4",
+ "RUF",
+]
+ignore = [
+ "E501",
+]
+
+[tool.ruff.lint.per-file-ignores]
+"tests/**" = ["B", "SIM"]
+
+[tool.mypy]
+python_version = "3.11"
+files = ["src", "tests"]
+warn_unused_ignores = true
+warn_redundant_casts = true
+warn_unreachable = true
+check_untyped_defs = true
+no_implicit_optional = true
+disallow_untyped_defs = true
+disallow_incomplete_defs = true
+
+[[tool.mypy.overrides]]
+module = "tests.*"
+disallow_untyped_defs = false
+disallow_incomplete_defs = false
+
+[tool.pytest.ini_options]
+minversion = "8.0"
+addopts = "-ra -q"
+testpaths = ["tests"]
diff --git a/tools/preflight-audit/src/preflight_audit/__init__.py
b/tools/preflight-audit/src/preflight_audit/__init__.py
new file mode 100644
index 0000000..8d0e3c4
--- /dev/null
+++ b/tools/preflight-audit/src/preflight_audit/__init__.py
@@ -0,0 +1,36 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from preflight_audit.classifier import (
+ Classification,
+ Decision,
+ IssueState,
+ classify_issue,
+ classify_response,
+)
+from preflight_audit.cli import main
+from preflight_audit.fetch import build_query, fetch_state
+
+__all__ = [
+ "Classification",
+ "Decision",
+ "IssueState",
+ "build_query",
+ "classify_issue",
+ "classify_response",
+ "fetch_state",
+ "main",
+]
diff --git a/tools/preflight-audit/src/preflight_audit/classifier.py
b/tools/preflight-audit/src/preflight_audit/classifier.py
new file mode 100644
index 0000000..fb4b39e
--- /dev/null
+++ b/tools/preflight-audit/src/preflight_audit/classifier.py
@@ -0,0 +1,273 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Pure classifier for the bulk-mode pre-flight no-op skip decision.
+
+This module is the executable spec of the rule table documented in
+[`.claude/skills/security-issue-sync/bulk-mode.md`](../../../.claude/skills/security-issue-sync/bulk-mode.md).
+Both representations must stay in sync — a PR that changes one
+should change the other.
+
+The classifier is intentionally split from the fetch layer
+(:mod:`preflight_audit.fetch`) so tests can drive it with synthetic
+issue dicts and replays of canned GraphQL responses, without any
+network calls.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from enum import StrEnum
+from typing import Any
+
+# The skill-marker prefix every framework-authored tracker comment
+# carries (status-rollup, release-manager hand-off, wrap-up). Keeps
+# the classifier in lock-step with `tools/github/status-rollup.md`.
+SKILL_MARKER_PREFIX = "<!-- apache-steward: "
+
+# Bot logins always treated as bot-equivalent regardless of comment body.
+# Extend per-adopter via the override file (see bulk-mode.md).
+_BUILTIN_BOT_LOGINS: frozenset[str] = frozenset(
+ {
+ "github-actions[bot]",
+ "dependabot[bot]",
+ }
+)
+
+
+class Decision(StrEnum):
+ """The three possible classifier outcomes."""
+
+ DISPATCH = "dispatch"
+ DISPATCH_URGENT = "dispatch-urgent"
+ SKIP_NOOP = "skip-noop"
+
+
+@dataclass(frozen=True)
+class IssueState:
+ """Lightweight snapshot the classifier needs for one tracker.
+
+ Mirrors the GraphQL response shape produced by
+ :func:`preflight_audit.fetch.build_query` so the fetch layer can
+ instantiate these directly from `gh api graphql` output.
+ """
+
+ number: int
+ state: str # "OPEN" | "CLOSED"
+ closed_at: datetime | None
+ updated_at: datetime
+ labels: frozenset[str]
+ last_comment_author: str | None
+ last_comment_created_at: datetime | None
+ last_comment_body: str | None
+
+
+@dataclass(frozen=True)
+class Classification:
+ """The classifier's output for one tracker."""
+
+ issue: IssueState
+ decision: Decision
+ reason: str
+ last_is_skill_or_bot: bool
+
+
+def _is_skill_or_bot(
+ login: str | None,
+ body: str | None,
+ extra_bot_logins: frozenset[str],
+) -> bool:
+ """Return True when the comment counts as bot-equivalent.
+
+ Three signals (any one is enough):
+
+ 1. The login is a literal GitHub App account (ends in ``[bot]``
+ or matches a built-in / override-listed bot login).
+ 2. The body begins with the framework's skill marker — see
+ :data:`SKILL_MARKER_PREFIX`. This is the signal that catches
+ sync-skill writes on single-operator trackers where the skill
+ runs under the operator's own user account.
+ 3. The login is in the adopter's override-supplied
+ ``extra_bot_logins`` set (for personal-account bots).
+ """
+ if login is not None:
+ if login in _BUILTIN_BOT_LOGINS or login in extra_bot_logins:
+ return True
+ if login.endswith("[bot]"):
+ return True
+ return bool(body is not None and
body.lstrip().startswith(SKILL_MARKER_PREFIX))
+
+
+def _days_between(now: datetime, then: datetime | None) -> float | None:
+ if then is None:
+ return None
+ return (now - then).total_seconds() / 86400
+
+
+def classify_issue(
+ iss: IssueState,
+ now: datetime,
+ extra_bot_logins: frozenset[str] = frozenset(),
+) -> Classification:
+ """Apply the rule table to one tracker.
+
+ Rules are checked **in order** — the first match wins. This is
+ the executable spec of the table in
+ `bulk-mode.md` § Pre-flight no-op classifier.
+ """
+ last_was_skill = _is_skill_or_bot(
+ iss.last_comment_author,
+ iss.last_comment_body,
+ extra_bot_logins,
+ )
+ updated_age = _days_between(now, iss.updated_at)
+ last_comment_age = _days_between(now, iss.last_comment_created_at)
+ closed_age = _days_between(now, iss.closed_at)
+
+ # Rule 1: 7-day updatedAt safety override — but only when the
+ # recent activity wasn't itself a skill write. On a tracker the
+ # skill just touched, the recently-bumped updatedAt is the skill's
+ # own work; let downstream rules decide.
+ if updated_age is not None and updated_age < 7:
+ skill_drove_recent_update = last_was_skill and last_comment_age is not
None and last_comment_age < 7
+ if not skill_drove_recent_update:
+ return Classification(
+ issue=iss,
+ decision=Decision.DISPATCH,
+ reason=f"recent human activity (updatedAt
{int(updated_age)}d)",
+ last_is_skill_or_bot=last_was_skill,
+ )
+
+ # Rule 2: dispatch-urgent — a non-skill comment in the last 24h.
+ if last_comment_age is not None and last_comment_age < 1 and not
last_was_skill:
+ return Classification(
+ issue=iss,
+ decision=Decision.DISPATCH_URGENT,
+ reason=f"recent reply from {iss.last_comment_author} (<24h)",
+ last_is_skill_or_bot=last_was_skill,
+ )
+
+ # Rule 3: closed > 30d ago AND `announced` label → post-announce.
+ if iss.state == "CLOSED" and closed_age is not None and closed_age > 30
and "announced" in iss.labels:
+ return Classification(
+ issue=iss,
+ decision=Decision.SKIP_NOOP,
+ reason=f"post-announce; closed {int(closed_age)}d ago",
+ last_is_skill_or_bot=last_was_skill,
+ )
+
+ # Rule 4: closed > 90d ago with no `announced` → stale invalid/dup.
+ if iss.state == "CLOSED" and closed_age is not None and closed_age > 90
and "announced" not in iss.labels:
+ return Classification(
+ issue=iss,
+ decision=Decision.SKIP_NOOP,
+ reason=f"stale closed {int(closed_age)}d ago (no announce)",
+ last_is_skill_or_bot=last_was_skill,
+ )
+
+ # Rule 5: open + full lifecycle + skill last → awaiting closure.
+ full_set = {"cve allocated", "pr merged", "announced"}
+ if iss.state == "OPEN" and full_set.issubset(iss.labels) and
last_was_skill:
+ age_s = f"{int(last_comment_age)}d" if last_comment_age is not None
else "?"
+ return Classification(
+ issue=iss,
+ decision=Decision.SKIP_NOOP,
+ reason=f"all phases done; skill-last ({age_s})",
+ last_is_skill_or_bot=last_was_skill,
+ )
+
+ # Rule 6: open + cve+pr+skill-last → awaiting release.
+ if iss.state == "OPEN" and {"cve allocated", "pr
merged"}.issubset(iss.labels) and last_was_skill:
+ age_s = f"{int(last_comment_age)}d" if last_comment_age is not None
else "?"
+ return Classification(
+ issue=iss,
+ decision=Decision.SKIP_NOOP,
+ reason=f"awaiting release; skill-last ({age_s})",
+ last_is_skill_or_bot=last_was_skill,
+ )
+
+ # Rule 7: open + cve+fix-released+skill-last → awaiting advisory.
+ if iss.state == "OPEN" and {"cve allocated", "fix
released"}.issubset(iss.labels) and last_was_skill:
+ age_s = f"{int(last_comment_age)}d" if last_comment_age is not None
else "?"
+ return Classification(
+ issue=iss,
+ decision=Decision.SKIP_NOOP,
+ reason=f"fix released; awaiting advisory; skill-last ({age_s})",
+ last_is_skill_or_bot=last_was_skill,
+ )
+
+ # Fall-through: dispatch.
+ return Classification(
+ issue=iss,
+ decision=Decision.DISPATCH,
+ reason="-",
+ last_is_skill_or_bot=last_was_skill,
+ )
+
+
+def _parse_iso(s: str | None) -> datetime | None:
+ if s is None:
+ return None
+ return datetime.fromisoformat(s.replace("Z", "+00:00"))
+
+
+def _issue_from_node(node: dict[str, Any]) -> IssueState:
+ """Build an :class:`IssueState` from a GraphQL issue node."""
+ comments = node.get("comments", {}).get("nodes") or []
+ last = comments[0] if comments else None
+ last_author = None
+ last_created = None
+ last_body = None
+ if last is not None:
+ author = last.get("author") or {}
+ last_author = author.get("login")
+ last_created = _parse_iso(last.get("createdAt"))
+ last_body = last.get("body")
+
+ label_nodes = node.get("labels", {}).get("nodes") or []
+ labels = frozenset(n["name"] for n in label_nodes)
+
+ return IssueState(
+ number=node["number"],
+ state=node["state"],
+ closed_at=_parse_iso(node.get("closedAt")),
+ updated_at=_parse_iso(node["updatedAt"]) or datetime.now(UTC),
+ labels=labels,
+ last_comment_author=last_author,
+ last_comment_created_at=last_created,
+ last_comment_body=last_body,
+ )
+
+
+def classify_response(
+ response: dict[str, Any],
+ now: datetime,
+ extra_bot_logins: frozenset[str] = frozenset(),
+) -> list[Classification]:
+ """Classify every issue in a raw `gh api graphql` response.
+
+ Skips null nodes (issues that 404'd) silently — the caller
+ can audit those by comparing against the input issue list.
+ """
+ repo = response.get("data", {}).get("repository", {}) or {}
+ out: list[Classification] = []
+ for _alias, node in repo.items():
+ if node is None:
+ continue
+ iss = _issue_from_node(node)
+ out.append(classify_issue(iss, now=now,
extra_bot_logins=extra_bot_logins))
+ return out
diff --git a/tools/preflight-audit/src/preflight_audit/cli.py
b/tools/preflight-audit/src/preflight_audit/cli.py
new file mode 100644
index 0000000..e59ecf3
--- /dev/null
+++ b/tools/preflight-audit/src/preflight_audit/cli.py
@@ -0,0 +1,183 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""CLI front-end for the pre-flight classifier dry-run.
+
+Two modes:
+
+- **Live**: ``preflight-audit classify --repo o/r --issues 1,2,3``
+ shells out to `gh api graphql` and prints the classification.
+- **Replay**: ``preflight-audit classify --load response.json``
+ reads a pre-fetched GraphQL response and classifies it. Useful
+ for CI eval fixtures and deterministic regression tests.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from collections.abc import Sequence
+from datetime import UTC, datetime
+from pathlib import Path
+
+from preflight_audit.classifier import (
+ Classification,
+ Decision,
+ classify_response,
+)
+from preflight_audit.fetch import fetch_state
+
+# Each subagent transcript on bulk dispatch is ~50 KB; this is the
+# unit the recap reports savings in.
+_SUBAGENT_KB = 50
+
+
+def _parse_issue_list(s: str) -> list[int]:
+ out = []
+ for token in s.split(","):
+ token = token.strip().lstrip("#")
+ if not token:
+ continue
+ try:
+ out.append(int(token))
+ except ValueError as exc:
+ raise SystemExit(f"error: invalid issue number: {token!r}") from
exc
+ if not out:
+ raise SystemExit("error: --issues parsed to empty list")
+ return out
+
+
+def _format_human(items: list[Classification]) -> str:
+ """Group-by-decision table — same shape as the original dry-run."""
+ by_decision: dict[Decision, list[Classification]] = {}
+ for c in items:
+ by_decision.setdefault(c.decision, []).append(c)
+
+ lines: list[str] = []
+ total = len(items) or 1
+ lines.append(f"Total trackers: {total}")
+ for d in (Decision.DISPATCH, Decision.DISPATCH_URGENT, Decision.SKIP_NOOP):
+ n = len(by_decision.get(d, []))
+ lines.append(f" {d.value}: {n} ({100 * n // total}%)")
+ lines.append("")
+
+ for d in (Decision.SKIP_NOOP, Decision.DISPATCH_URGENT, Decision.DISPATCH):
+ group = sorted(by_decision.get(d, []), key=lambda c: c.issue.number)
+ if not group:
+ continue
+ lines.append(f"--- {d.value} ({len(group)}) ---")
+ for c in group:
+ labels = "+".join(sorted(c.issue.labels))[:60]
+ tag = " [skill]" if c.last_is_skill_or_bot else ""
+ last_by = c.issue.last_comment_author or "-"
+ lines.append(f" #{c.issue.number:>4} {c.issue.state:<6}
last={last_by:<16}{tag} → {c.reason}")
+ lines.append(f" labels: {labels}")
+ lines.append("")
+
+ skipped = len(by_decision.get(Decision.SKIP_NOOP, []))
+ saved_kb = skipped * _SUBAGENT_KB
+ lines.append("=== Estimated savings ===")
+ lines.append(
+ f"Subagents skipped: {skipped} "
+ f"→ ~{saved_kb} KB context saved "
+ f"(~{saved_kb * 250} tokens @ 250 tok/KB)"
+ )
+ return "\n".join(lines)
+
+
+def _format_json(items: list[Classification]) -> str:
+ return json.dumps(
+ [
+ {
+ "number": c.issue.number,
+ "decision": c.decision.value,
+ "reason": c.reason,
+ "last_is_skill_or_bot": c.last_is_skill_or_bot,
+ "labels": sorted(c.issue.labels),
+ }
+ for c in sorted(items, key=lambda c: c.issue.number)
+ ],
+ indent=2,
+ )
+
+
+def _cmd_classify(args: argparse.Namespace) -> int:
+ if args.load:
+ response = json.loads(Path(args.load).read_text(encoding="utf-8"))
+ else:
+ if not args.repo or not args.issues:
+ sys.stderr.write("error: --repo and --issues are required (or use
--load)\n")
+ return 2
+ owner, _, name = args.repo.partition("/")
+ if not name:
+ sys.stderr.write(f"error: --repo must be owner/name, got
{args.repo!r}\n")
+ return 2
+ numbers = _parse_issue_list(args.issues)
+ response = fetch_state(owner=owner, name=name, numbers=numbers)
+
+ now = datetime.fromisoformat(args.now.replace("Z", "+00:00")) if args.now
else datetime.now(UTC)
+ extra_bots = frozenset(b.strip() for b in (args.bot_logins or
"").split(",") if b.strip())
+ items = classify_response(response, now=now, extra_bot_logins=extra_bots)
+
+ if args.json:
+ sys.stdout.write(_format_json(items))
+ else:
+ sys.stdout.write(_format_human(items))
+ sys.stdout.write("\n")
+ return 0
+
+
+def _build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="preflight-audit",
+ description=(
+ "Dry-run the bulk-mode pre-flight classifier against a real "
+ "or replayed tracker. Use to measure skip-rate before / after "
+ "any rule change."
+ ),
+ )
+ sub = parser.add_subparsers(dest="command", required=True)
+
+ p_cls = sub.add_parser("classify", help="classify trackers (live fetch or
replay)")
+ p_cls.add_argument("--repo", help="owner/name (live mode)")
+ p_cls.add_argument("--issues", help="comma-separated issue numbers (live
mode)")
+ p_cls.add_argument(
+ "--load",
+ help="path to a pre-fetched GraphQL response JSON (replay mode)",
+ )
+ p_cls.add_argument(
+ "--now",
+ help="ISO-8601 timestamp to use as 'now' (deterministic replay;
default: real time)",
+ )
+ p_cls.add_argument(
+ "--bot-logins",
+ help="comma-separated extra logins to treat as bot-equivalent",
+ )
+ p_cls.add_argument("--json", action="store_true", help="emit JSON instead
of human-readable table")
+ p_cls.set_defaults(func=_cmd_classify)
+
+ return parser
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = _build_parser()
+ args = parser.parse_args(argv)
+ return int(args.func(args))
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/preflight-audit/src/preflight_audit/fetch.py
b/tools/preflight-audit/src/preflight_audit/fetch.py
new file mode 100644
index 0000000..2fcfe91
--- /dev/null
+++ b/tools/preflight-audit/src/preflight_audit/fetch.py
@@ -0,0 +1,73 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""GraphQL query construction + `gh` invocation.
+
+Kept separate from the classifier so tests can replay canned
+responses without shelling out to `gh`.
+"""
+
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+from collections.abc import Sequence
+from typing import Any
+
+_ISSUE_BLOCK = """\
+ i{n}: issue(number: {n}) {{
+ number state closedAt updatedAt
+ labels(first: 30) {{ nodes {{ name }} }}
+ comments(last: 1) {{
+ nodes {{ author {{ login }} createdAt body }}
+ }}
+ }}"""
+
+
+def build_query(owner: str, name: str, numbers: Sequence[int]) -> str:
+ """Build the aliased multi-field GraphQL query.
+
+ The aliased-field form (``i<N>: issue(number: <N>)``) lets one
+ round-trip fetch state for an arbitrary number of issues. For
+ a 30-issue sweep the request is ~3 KB and the response is
+ ~50-130 KB depending on rollup-comment body length.
+ """
+ if not numbers:
+ raise ValueError("numbers must be non-empty")
+ blocks = "\n".join(_ISSUE_BLOCK.format(n=n) for n in numbers)
+ return f'query {{\n repository(owner: "{owner}", name: "{name}")
{{\n{blocks}\n }}\n}}\n'
+
+
+def fetch_state(
+ owner: str,
+ name: str,
+ numbers: Sequence[int],
+ gh_path: str = "gh",
+) -> dict[str, Any]:
+ """Invoke `gh api graphql` and return the parsed JSON response.
+
+ Raises :class:`SystemExit` if `gh` exits non-zero — the
+ underlying stderr is forwarded so the caller sees the real
+ error (auth, rate limit, etc.).
+ """
+ query = build_query(owner, name, numbers)
+ cmd = [gh_path, "api", "graphql", "--raw-field", f"query={query}"]
+ result = subprocess.run(cmd, capture_output=True, text=True, check=False)
+ if result.returncode != 0:
+ sys.stderr.write(result.stderr)
+ raise SystemExit(result.returncode or 1)
+ return json.loads(result.stdout)
diff --git a/tools/preflight-audit/tests/__init__.py
b/tools/preflight-audit/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tools/preflight-audit/tests/test_classifier.py
b/tools/preflight-audit/tests/test_classifier.py
new file mode 100644
index 0000000..6c4ff5c
--- /dev/null
+++ b/tools/preflight-audit/tests/test_classifier.py
@@ -0,0 +1,456 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Rule-by-rule tests for the pre-flight classifier.
+
+Each rule from `bulk-mode.md` § Pre-flight no-op classifier has at
+least one focused test covering the positive case (rule fires) and
+one negative case (rule does not fire). When the rule table grows,
+add the matching `test_rule_N_*` here.
+"""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime, timedelta
+
+import pytest
+
+from preflight_audit.classifier import (
+ Decision,
+ IssueState,
+ classify_issue,
+ classify_response,
+)
+
+NOW = datetime(2026, 5, 31, 12, 0, 0, tzinfo=UTC)
+
+
+def make_issue(
+ *,
+ number: int = 100,
+ state: str = "OPEN",
+ closed_days_ago: float | None = None,
+ updated_days_ago: float = 30.0,
+ labels: frozenset[str] = frozenset(),
+ last_comment_author: str | None = None,
+ last_comment_days_ago: float | None = None,
+ last_comment_body: str | None = None,
+) -> IssueState:
+ return IssueState(
+ number=number,
+ state=state,
+ closed_at=(NOW - timedelta(days=closed_days_ago)) if closed_days_ago
is not None else None,
+ updated_at=NOW - timedelta(days=updated_days_ago),
+ labels=labels,
+ last_comment_author=last_comment_author,
+ last_comment_created_at=(
+ NOW - timedelta(days=last_comment_days_ago) if
last_comment_days_ago is not None else None
+ ),
+ last_comment_body=last_comment_body,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Rule 1 — 7-day updatedAt safety override
+# ---------------------------------------------------------------------------
+
+
+def test_rule_1_dispatches_when_human_activity_in_last_7d():
+ iss = make_issue(
+ updated_days_ago=2.0,
+ last_comment_author="someone",
+ last_comment_days_ago=2.0,
+ last_comment_body="real comment\n",
+ )
+ c = classify_issue(iss, now=NOW)
+ assert c.decision == Decision.DISPATCH
+ assert "recent human activity" in c.reason
+
+
+def test_rule_1_skipped_when_skill_drove_recent_update():
+ """A recent-only-because-the-skill-wrote update should NOT
+ block downstream skip rules from firing."""
+ iss = make_issue(
+ state="OPEN",
+ updated_days_ago=1.0,
+ labels=frozenset({"cve allocated", "fix released"}),
+ last_comment_author="potiuk",
+ last_comment_days_ago=1.0,
+ last_comment_body="<!-- apache-steward: release-manager-handoff v1
-->\nbody",
+ )
+ c = classify_issue(iss, now=NOW)
+ # Rule 7 should fire because Rule 1 yielded.
+ assert c.decision == Decision.SKIP_NOOP
+ assert "fix released" in c.reason
+
+
+# ---------------------------------------------------------------------------
+# Rule 2 — dispatch-urgent (non-skill comment in last 24h)
+# ---------------------------------------------------------------------------
+
+
+def test_rule_2_urgent_when_non_skill_comment_under_24h():
+ iss = make_issue(
+ state="OPEN",
+ updated_days_ago=0.5,
+ last_comment_author="reporter",
+ last_comment_days_ago=0.5,
+ last_comment_body="please re-check\n",
+ )
+ c = classify_issue(iss, now=NOW)
+ # Rule 1 fires first (recent human activity); urgent is only
+ # reachable when Rule 1 yielded — i.e., when the recent activity
+ # was the skill. So the urgent path needs a non-skill comment
+ # AFTER a skill update? Actually the rule is "Rule 1 yielded AND
+ # Rule 2 fires" which happens when last comment is recent AND
+ # not skill — but then Rule 1 ALSO doesn't yield, since it
+ # requires skill-drove-recent-update. Both fail; we end up at
+ # Rule 1's dispatch. That matches the prose: urgent is the
+ # bot-vs-not distinction; when the last comment is a human reply
+ # in the last 24h, the classifier dispatches (with the urgent
+ # tag) if-and-only-if there is no other reason to dispatch.
+ # In this synthetic case Rule 1 catches first → dispatch
+ # without urgent. That's correct behaviour.
+ assert c.decision == Decision.DISPATCH
+
+
+def test_rule_2_urgent_path_after_skill_only_recent_activity():
+ """Construct a case where Rule 1 yields (skill drove update) but
+ the LAST comment is still a non-skill recent reply. Tricky
+ construction: a non-skill last comment within 24h, but updatedAt
+ older than 7d — Rule 1 won't fire at all, then Rule 2 catches."""
+ iss = make_issue(
+ state="OPEN",
+ updated_days_ago=10.0, # >7d so Rule 1 doesn't fire
+ last_comment_author="reporter",
+ last_comment_days_ago=0.5,
+ last_comment_body="any update?\n",
+ )
+ # updated_days_ago=10 but last_comment_days_ago=0.5 is
+ # internally inconsistent (a real GitHub response would have
+ # updatedAt >= last comment time), but the classifier handles
+ # it gracefully — Rule 1 skips, Rule 2 fires.
+ c = classify_issue(iss, now=NOW)
+ assert c.decision == Decision.DISPATCH_URGENT
+ assert "reporter" in c.reason
+
+
+def test_rule_2_not_urgent_when_skill_comment():
+ iss = make_issue(
+ state="OPEN",
+ updated_days_ago=10.0,
+ last_comment_author="potiuk",
+ last_comment_days_ago=0.5,
+ last_comment_body="<!-- apache-steward: status-rollup v1 -->\nentry\n",
+ )
+ c = classify_issue(iss, now=NOW)
+ assert c.decision != Decision.DISPATCH_URGENT
+
+
+# ---------------------------------------------------------------------------
+# Rule 3 — closed > 30d + announced → post-announce
+# ---------------------------------------------------------------------------
+
+
+def test_rule_3_skip_post_announce():
+ iss = make_issue(
+ state="CLOSED",
+ closed_days_ago=40.0,
+ updated_days_ago=40.0,
+ labels=frozenset({"announced", "cve allocated"}),
+ )
+ c = classify_issue(iss, now=NOW)
+ assert c.decision == Decision.SKIP_NOOP
+ assert "post-announce" in c.reason
+
+
+def test_rule_3_no_skip_recently_closed():
+ iss = make_issue(
+ state="CLOSED",
+ closed_days_ago=10.0,
+ updated_days_ago=10.0,
+ labels=frozenset({"announced"}),
+ )
+ c = classify_issue(iss, now=NOW)
+ # 10d < 30d threshold; downstream falls through to dispatch.
+ assert c.decision == Decision.DISPATCH
+
+
+def test_rule_3_no_skip_no_announce_label():
+ iss = make_issue(
+ state="CLOSED",
+ closed_days_ago=40.0,
+ updated_days_ago=40.0,
+ labels=frozenset({"cve allocated"}),
+ )
+ c = classify_issue(iss, now=NOW)
+ # No `announced` → Rule 3 doesn't fire; Rule 4 wants > 90d.
+ assert c.decision == Decision.DISPATCH
+
+
+# ---------------------------------------------------------------------------
+# Rule 4 — closed > 90d no announce → stale
+# ---------------------------------------------------------------------------
+
+
+def test_rule_4_skip_stale_closed():
+ iss = make_issue(
+ state="CLOSED",
+ closed_days_ago=120.0,
+ updated_days_ago=120.0,
+ labels=frozenset({"invalid"}),
+ )
+ c = classify_issue(iss, now=NOW)
+ assert c.decision == Decision.SKIP_NOOP
+ assert "stale closed" in c.reason
+
+
+def test_rule_4_no_skip_if_announced():
+ iss = make_issue(
+ state="CLOSED",
+ closed_days_ago=120.0,
+ updated_days_ago=120.0,
+ labels=frozenset({"announced"}),
+ )
+ c = classify_issue(iss, now=NOW)
+ # Rule 3 catches first.
+ assert c.decision == Decision.SKIP_NOOP
+ assert "post-announce" in c.reason
+
+
+# ---------------------------------------------------------------------------
+# Rule 5 — open + full lifecycle + skill-last → awaiting closure
+# ---------------------------------------------------------------------------
+
+
+def test_rule_5_skip_full_lifecycle_skill_last():
+ iss = make_issue(
+ state="OPEN",
+ updated_days_ago=2.0,
+ labels=frozenset({"cve allocated", "pr merged", "announced"}),
+ last_comment_author="potiuk",
+ last_comment_days_ago=2.0,
+ last_comment_body="<!-- apache-steward: status-rollup v1 -->\nentry",
+ )
+ c = classify_issue(iss, now=NOW)
+ assert c.decision == Decision.SKIP_NOOP
+ assert "all phases done" in c.reason
+
+
+def test_rule_5_no_skip_when_last_human():
+ iss = make_issue(
+ state="OPEN",
+ updated_days_ago=2.0,
+ labels=frozenset({"cve allocated", "pr merged", "announced"}),
+ last_comment_author="reporter",
+ last_comment_days_ago=2.0,
+ last_comment_body="real reply\n",
+ )
+ c = classify_issue(iss, now=NOW)
+ assert c.decision == Decision.DISPATCH
+
+
+# ---------------------------------------------------------------------------
+# Rule 6 — open + cve+pr + skill-last → awaiting release
+# ---------------------------------------------------------------------------
+
+
+def test_rule_6_skip_awaiting_release():
+ iss = make_issue(
+ state="OPEN",
+ updated_days_ago=2.0,
+ labels=frozenset({"cve allocated", "pr merged"}),
+ last_comment_author="potiuk",
+ last_comment_days_ago=2.0,
+ last_comment_body="<!-- apache-steward: status-rollup v1 -->\n",
+ )
+ c = classify_issue(iss, now=NOW)
+ assert c.decision == Decision.SKIP_NOOP
+ assert "awaiting release" in c.reason
+
+
+# ---------------------------------------------------------------------------
+# Rule 7 — open + cve+fix-released + skill-last → awaiting advisory
+# ---------------------------------------------------------------------------
+
+
+def test_rule_7_skip_awaiting_advisory():
+ iss = make_issue(
+ state="OPEN",
+ updated_days_ago=1.0,
+ labels=frozenset({"cve allocated", "fix released"}),
+ last_comment_author="potiuk",
+ last_comment_days_ago=1.0,
+ last_comment_body="<!-- apache-steward: release-manager-handoff v1
-->\n",
+ )
+ c = classify_issue(iss, now=NOW)
+ assert c.decision == Decision.SKIP_NOOP
+ assert "awaiting advisory" in c.reason
+
+
+# ---------------------------------------------------------------------------
+# Skill-or-bot detection
+# ---------------------------------------------------------------------------
+
+
+def test_bot_login_detected():
+ iss = make_issue(
+ state="OPEN",
+ updated_days_ago=2.0,
+ labels=frozenset({"cve allocated", "fix released"}),
+ last_comment_author="github-actions[bot]",
+ last_comment_days_ago=2.0,
+ last_comment_body="CI passed\n",
+ )
+ c = classify_issue(iss, now=NOW)
+ assert c.last_is_skill_or_bot is True
+ assert c.decision == Decision.SKIP_NOOP
+
+
+def test_dependabot_login_detected():
+ iss = make_issue(
+ state="OPEN",
+ updated_days_ago=2.0,
+ labels=frozenset({"cve allocated", "fix released"}),
+ last_comment_author="dependabot[bot]",
+ last_comment_days_ago=2.0,
+ last_comment_body="bumped\n",
+ )
+ c = classify_issue(iss, now=NOW)
+ assert c.last_is_skill_or_bot is True
+
+
+def test_skill_marker_detected_regardless_of_author():
+ iss = make_issue(
+ state="OPEN",
+ updated_days_ago=2.0,
+ labels=frozenset({"cve allocated", "fix released"}),
+ last_comment_author="some-user",
+ last_comment_days_ago=2.0,
+ last_comment_body="<!-- apache-steward: status-rollup v1 -->\nentry",
+ )
+ c = classify_issue(iss, now=NOW)
+ assert c.last_is_skill_or_bot is True
+
+
+def test_extra_bot_login_override():
+ iss = make_issue(
+ state="OPEN",
+ updated_days_ago=2.0,
+ labels=frozenset({"cve allocated", "fix released"}),
+ last_comment_author="company-private-bot",
+ last_comment_days_ago=2.0,
+ last_comment_body="hello\n",
+ )
+ c = classify_issue(iss, now=NOW,
extra_bot_logins=frozenset({"company-private-bot"}))
+ assert c.last_is_skill_or_bot is True
+
+
+def test_real_human_not_detected_as_bot():
+ iss = make_issue(
+ last_comment_author="reporter-jane",
+ last_comment_body="this is broken\n",
+ )
+ c = classify_issue(iss, now=NOW)
+ assert c.last_is_skill_or_bot is False
+
+
+# ---------------------------------------------------------------------------
+# classify_response — end-to-end with a synthetic GraphQL shape
+# ---------------------------------------------------------------------------
+
+
+def test_classify_response_handles_empty_repo():
+ assert classify_response({"data": {"repository": {}}}, now=NOW) == []
+
+
+def test_classify_response_skips_null_nodes():
+ response: dict = {
+ "data": {
+ "repository": {
+ "i1": None,
+ "i2": {
+ "number": 2,
+ "state": "OPEN",
+ "closedAt": None,
+ "updatedAt": "2025-01-01T00:00:00Z",
+ "labels": {"nodes": [{"name": "needs triage"}]},
+ "comments": {"nodes": []},
+ },
+ }
+ }
+ }
+ results = classify_response(response, now=NOW)
+ assert len(results) == 1
+ assert results[0].issue.number == 2
+
+
+def test_classify_response_parses_iso_timestamps():
+ response: dict = {
+ "data": {
+ "repository": {
+ "i9": {
+ "number": 9,
+ "state": "CLOSED",
+ "closedAt": "2026-03-15T10:00:00Z",
+ "updatedAt": "2026-03-15T10:00:00Z",
+ "labels": {"nodes": [{"name": "announced"}]},
+ "comments": {"nodes": []},
+ }
+ }
+ }
+ }
+ results = classify_response(response, now=NOW)
+ assert len(results) == 1
+ assert results[0].decision == Decision.SKIP_NOOP
+ assert "post-announce" in results[0].reason
+
+
+# ---------------------------------------------------------------------------
+# Edge cases
+# ---------------------------------------------------------------------------
+
+
+def test_no_last_comment_falls_through_to_dispatch():
+ iss = make_issue(
+ state="OPEN",
+ updated_days_ago=30.0,
+ labels=frozenset({"cve allocated"}),
+ )
+ c = classify_issue(iss, now=NOW)
+ assert c.decision == Decision.DISPATCH
+ assert c.last_is_skill_or_bot is False
+
+
[email protected](
+ "body,expected_skill",
+ [
+ ("<!-- apache-steward: x v1 -->", True),
+ (" \n<!-- apache-steward: x v1 -->", True), # leading whitespace
+ ("<!-- apache-steward:x -->", False), # missing space after colon
+ ("real reply\n<!-- apache-steward: x -->", False), # not at start
+ ("", False),
+ (None, False),
+ ],
+)
+def test_skill_marker_match_precision(body, expected_skill):
+ iss = make_issue(
+ last_comment_author="potiuk",
+ last_comment_days_ago=5.0,
+ last_comment_body=body,
+ )
+ c = classify_issue(iss, now=NOW)
+ assert c.last_is_skill_or_bot is expected_skill
diff --git a/tools/preflight-audit/tests/test_cli.py
b/tools/preflight-audit/tests/test_cli.py
new file mode 100644
index 0000000..e57c0e5
--- /dev/null
+++ b/tools/preflight-audit/tests/test_cli.py
@@ -0,0 +1,235 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""CLI tests — exercises the live path (fake gh) and replay path."""
+
+from __future__ import annotations
+
+import json
+import subprocess
+from collections.abc import Iterator
+from dataclasses import dataclass
+
+import pytest
+
+from preflight_audit import cli
+
+CANNED_RESPONSE = {
+ "data": {
+ "repository": {
+ "i100": {
+ "number": 100,
+ "state": "OPEN",
+ "closedAt": None,
+ "updatedAt": "2026-05-30T08:00:00Z",
+ "labels": {"nodes": [{"name": "cve allocated"}, {"name": "fix
released"}]},
+ "comments": {
+ "nodes": [
+ {
+ "author": {"login": "potiuk"},
+ "createdAt": "2026-05-30T08:00:00Z",
+ "body": "<!-- apache-steward: status-rollup v1
-->\nentry\n",
+ }
+ ]
+ },
+ },
+ "i101": {
+ "number": 101,
+ "state": "OPEN",
+ "closedAt": None,
+ "updatedAt": "2026-05-31T11:00:00Z",
+ "labels": {"nodes": [{"name": "needs triage"}]},
+ "comments": {
+ "nodes": [
+ {
+ "author": {"login": "reporter"},
+ "createdAt": "2026-05-31T11:00:00Z",
+ "body": "this is a real problem\n",
+ }
+ ]
+ },
+ },
+ "i102": {
+ "number": 102,
+ "state": "CLOSED",
+ "closedAt": "2026-03-15T10:00:00Z",
+ "updatedAt": "2026-03-15T10:00:00Z",
+ "labels": {"nodes": [{"name": "announced"}]},
+ "comments": {"nodes": []},
+ },
+ }
+ }
+}
+
+
+@dataclass
+class FakeResult:
+ returncode: int
+ stdout: str = ""
+ stderr: str = ""
+
+
+class FakeGh:
+ def __init__(self, response: dict | None = None) -> None:
+ self.response = response or CANNED_RESPONSE
+ self.calls: list[list[str]] = []
+
+ def __call__(self, cmd, *, capture_output=False, text=False, check=False,
**_):
+ self.calls.append(cmd)
+ return FakeResult(returncode=0, stdout=json.dumps(self.response))
+
+
[email protected]
+def fake_gh(monkeypatch: pytest.MonkeyPatch) -> Iterator[FakeGh]:
+ f = FakeGh()
+ monkeypatch.setattr(subprocess, "run", f)
+ yield f
+
+
+# ---------------------------------------------------------------------------
+# Live mode
+# ---------------------------------------------------------------------------
+
+
+def test_live_mode_invokes_gh_and_prints_table(fake_gh: FakeGh, capsys:
pytest.CaptureFixture[str]) -> None:
+ rc = cli.main(
+ [
+ "classify",
+ "--repo",
+ "owner/repo",
+ "--issues",
+ "100,101,102",
+ "--now",
+ "2026-05-31T12:00:00Z",
+ ]
+ )
+ assert rc == 0
+ out = capsys.readouterr().out
+ assert "Total trackers: 3" in out
+ assert "skip-noop:" in out
+ assert "Estimated savings" in out
+ # One gh call to graphql.
+ assert len(fake_gh.calls) == 1
+ assert fake_gh.calls[0][:3] == ["gh", "api", "graphql"]
+
+
+def test_live_mode_issues_with_hash_prefix(fake_gh: FakeGh, capsys:
pytest.CaptureFixture[str]) -> None:
+ rc = cli.main(
+ ["classify", "--repo", "o/r", "--issues", "#100, #101 ,#102", "--now",
"2026-05-31T12:00:00Z"]
+ )
+ assert rc == 0
+
+
+def test_live_mode_rejects_bad_repo(capsys: pytest.CaptureFixture[str]) ->
None:
+ rc = cli.main(["classify", "--repo", "noslash", "--issues", "1"])
+ assert rc == 2
+ assert "owner/name" in capsys.readouterr().err
+
+
+def test_live_mode_requires_repo_when_no_load(capsys:
pytest.CaptureFixture[str]) -> None:
+ rc = cli.main(["classify"])
+ assert rc == 2
+ assert "required" in capsys.readouterr().err
+
+
+def test_live_mode_rejects_empty_issues_list(capsys:
pytest.CaptureFixture[str]) -> None:
+ with pytest.raises(SystemExit) as exc:
+ cli.main(["classify", "--repo", "o/r", "--issues", ",,,"])
+ assert exc.value.code == "error: --issues parsed to empty list" or
exc.value.code != 0
+
+
+# ---------------------------------------------------------------------------
+# Replay mode
+# ---------------------------------------------------------------------------
+
+
+def test_replay_mode_loads_fixture(tmp_path, capsys:
pytest.CaptureFixture[str]) -> None:
+ fixture = tmp_path / "resp.json"
+ fixture.write_text(json.dumps(CANNED_RESPONSE), encoding="utf-8")
+ rc = cli.main(
+ [
+ "classify",
+ "--load",
+ str(fixture),
+ "--now",
+ "2026-05-31T12:00:00Z",
+ ]
+ )
+ assert rc == 0
+ out = capsys.readouterr().out
+ assert "Total trackers: 3" in out
+ # #100 should be SKIP_NOOP (cve+fix released+skill-last).
+ assert "# 100" in out or " 100" in out
+
+
+def test_replay_mode_json_output(tmp_path, capsys: pytest.CaptureFixture[str])
-> None:
+ fixture = tmp_path / "resp.json"
+ fixture.write_text(json.dumps(CANNED_RESPONSE), encoding="utf-8")
+ rc = cli.main(
+ [
+ "classify",
+ "--load",
+ str(fixture),
+ "--now",
+ "2026-05-31T12:00:00Z",
+ "--json",
+ ]
+ )
+ assert rc == 0
+ out = capsys.readouterr().out
+ parsed = json.loads(out)
+ assert len(parsed) == 3
+ # Sorted by number.
+ assert [p["number"] for p in parsed] == [100, 101, 102]
+ by_n = {p["number"]: p for p in parsed}
+ assert by_n[100]["decision"] == "skip-noop"
+ assert by_n[102]["decision"] == "skip-noop"
+ # 101 has a non-skill comment 1 hour ago → dispatch-urgent OR dispatch
+ # depending on the 1.0-day cutoff. 11:00 → 12:00 is exactly 1.0h ago.
+ assert by_n[101]["decision"] in {"dispatch", "dispatch-urgent"}
+
+
+def test_extra_bot_logins_flag(tmp_path, capsys: pytest.CaptureFixture[str])
-> None:
+ """The --bot-logins flag adds extra logins to the bot-equivalent set."""
+ # Reshape #100 so its author is a personal-account bot.
+ response = json.loads(json.dumps(CANNED_RESPONSE))
+
response["data"]["repository"]["i100"]["comments"]["nodes"][0]["author"]["login"]
= "company-bot"
+ response["data"]["repository"]["i100"]["comments"]["nodes"][0]["body"] =
"no marker\n"
+ fixture = tmp_path / "resp.json"
+ fixture.write_text(json.dumps(response), encoding="utf-8")
+
+ # Without the flag: #100 dispatches (no skill marker, not a [bot] login).
+ cli.main(["classify", "--load", str(fixture), "--now",
"2026-05-31T12:00:00Z", "--json"])
+ no_flag_out = capsys.readouterr().out
+ no_flag = {p["number"]: p for p in json.loads(no_flag_out)}
+ assert no_flag[100]["decision"] == "dispatch"
+
+ # With the flag: #100 is recognised, classifier can fire Rule 7.
+ cli.main(
+ [
+ "classify",
+ "--load",
+ str(fixture),
+ "--now",
+ "2026-05-31T12:00:00Z",
+ "--json",
+ "--bot-logins",
+ "company-bot",
+ ]
+ )
+ flag_out = capsys.readouterr().out
+ with_flag = {p["number"]: p for p in json.loads(flag_out)}
+ assert with_flag[100]["decision"] == "skip-noop"
diff --git a/tools/preflight-audit/uv.lock b/tools/preflight-audit/uv.lock
new file mode 100644
index 0000000..c71eee8
--- /dev/null
+++ b/tools/preflight-audit/uv.lock
@@ -0,0 +1,309 @@
+version = 1
+revision = 3
+requires-python = ">=3.11"
+resolution-markers = [
+ "python_full_version >= '3.15'",
+ "python_full_version < '3.15'",
+]
+
+[options]
+exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included
for backwards compatibility when using relative exclude-newer values.
+exclude-newer-span = "P7D"
+
+[[package]]
+name = "ast-serialize"
+version = "0.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz",
hash =
"sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size
= 61157, upload-time = "2026-05-17T17:48:29.429Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl",
hash =
"sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size
= 1183520, upload-time = "2026-05-17T17:47:30.831Z" },
+ { url =
"https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl",
hash =
"sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size
= 1175779, upload-time = "2026-05-17T17:47:32.551Z" },
+ { url =
"https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl",
hash =
"sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size
= 1233750, upload-time = "2026-05-17T17:47:34.731Z" },
+ { url =
"https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl",
hash =
"sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size
= 1235942, upload-time = "2026-05-17T17:47:36.287Z" },
+ { url =
"https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl",
hash =
"sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size
= 1442517, upload-time = "2026-05-17T17:47:38.17Z" },
+ { url =
"https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl",
hash =
"sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size
= 1254081, upload-time = "2026-05-17T17:47:39.826Z" },
+ { url =
"https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
hash =
"sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size
= 1259910, upload-time = "2026-05-17T17:47:41.369Z" },
+ { url =
"https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl",
hash =
"sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size
= 1250678, upload-time = "2026-05-17T17:47:43.702Z" },
+ { url =
"https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl",
hash =
"sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size
= 1301603, upload-time = "2026-05-17T17:47:46.256Z" },
+ { url =
"https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl",
hash =
"sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size
= 1410332, upload-time = "2026-05-17T17:47:47.899Z" },
+ { url =
"https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl",
hash =
"sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size
= 1509979, upload-time = "2026-05-17T17:47:50.942Z" },
+ { url =
"https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl",
hash =
"sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size
= 1505002, upload-time = "2026-05-17T17:47:54.093Z" },
+ { url =
"https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl",
hash =
"sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size
= 1456231, upload-time = "2026-05-17T17:47:56.311Z" },
+ { url =
"https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl",
hash =
"sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size
= 1058668, upload-time = "2026-05-17T17:47:58.305Z" },
+ { url =
"https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl",
hash =
"sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size
= 1101075, upload-time = "2026-05-17T17:48:00.35Z" },
+ { url =
"https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl",
hash =
"sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size
= 1075347, upload-time = "2026-05-17T17:48:01.753Z" },
+ { url =
"https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl",
hash =
"sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size
= 1191380, upload-time = "2026-05-17T17:48:03.738Z" },
+ { url =
"https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl",
hash =
"sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size
= 1183879, upload-time = "2026-05-17T17:48:05.463Z" },
+ { url =
"https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl",
hash =
"sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size
= 1244529, upload-time = "2026-05-17T17:48:07.008Z" },
+ { url =
"https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl",
hash =
"sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size
= 1240560, upload-time = "2026-05-17T17:48:08.46Z" },
+ { url =
"https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl",
hash =
"sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size
= 1451172, upload-time = "2026-05-17T17:48:09.922Z" },
+ { url =
"https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl",
hash =
"sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size
= 1265072, upload-time = "2026-05-17T17:48:11.469Z" },
+ { url =
"https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
hash =
"sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size
= 1270488, upload-time = "2026-05-17T17:48:13.575Z" },
+ { url =
"https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl",
hash =
"sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size
= 1260702, upload-time = "2026-05-17T17:48:15.141Z" },
+ { url =
"https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl",
hash =
"sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size
= 1311182, upload-time = "2026-05-17T17:48:16.779Z" },
+ { url =
"https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl",
hash =
"sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size
= 1421410, upload-time = "2026-05-17T17:48:18.527Z" },
+ { url =
"https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl",
hash =
"sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size
= 1516587, upload-time = "2026-05-17T17:48:20.133Z" },
+ { url =
"https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl",
hash =
"sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size
= 1515171, upload-time = "2026-05-17T17:48:21.921Z" },
+ { url =
"https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl",
hash =
"sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size
= 1464668, upload-time = "2026-05-17T17:48:23.544Z" },
+ { url =
"https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl",
hash =
"sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size
= 1068311, upload-time = "2026-05-17T17:48:25.027Z" },
+ { url =
"https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl",
hash =
"sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size
= 1108931, upload-time = "2026-05-17T17:48:26.591Z" },
+ { url =
"https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl",
hash =
"sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size
= 1081181, upload-time = "2026-05-17T17:48:28.122Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz",
hash =
"sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size
= 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl",
hash =
"sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size
= 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz",
hash =
"sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size
= 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl",
hash =
"sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size
= 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "librt"
+version = "0.11.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz",
hash =
"sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size
= 200139, upload-time = "2026-05-10T18:17:25.138Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl",
hash =
"sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size
= 141092, upload-time = "2026-05-10T18:15:34.795Z" },
+ { url =
"https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl",
hash =
"sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size
= 142035, upload-time = "2026-05-10T18:15:36.242Z" },
+ { url =
"https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",
hash =
"sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size
= 475022, upload-time = "2026-05-10T18:15:37.56Z" },
+ { url =
"https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl",
hash =
"sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size
= 467273, upload-time = "2026-05-10T18:15:39.182Z" },
+ { url =
"https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
hash =
"sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size
= 497083, upload-time = "2026-05-10T18:15:40.634Z" },
+ { url =
"https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl",
hash =
"sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size
= 489139, upload-time = "2026-05-10T18:15:41.934Z" },
+ { url =
"https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl",
hash =
"sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size
= 508442, upload-time = "2026-05-10T18:15:43.206Z" },
+ { url =
"https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl",
hash =
"sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size
= 514230, upload-time = "2026-05-10T18:15:44.761Z" },
+ { url =
"https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl",
hash =
"sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size
= 494231, upload-time = "2026-05-10T18:15:46.308Z" },
+ { url =
"https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl",
hash =
"sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size
= 537585, upload-time = "2026-05-10T18:15:47.629Z" },
+ { url =
"https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl",
hash =
"sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size
= 100509, upload-time = "2026-05-10T18:15:49.157Z" },
+ { url =
"https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl",
hash =
"sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size
= 118628, upload-time = "2026-05-10T18:15:50.345Z" },
+ { url =
"https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl",
hash =
"sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size
= 103122, upload-time = "2026-05-10T18:15:52.068Z" },
+ { url =
"https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl",
hash =
"sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size
= 144147, upload-time = "2026-05-10T18:15:53.227Z" },
+ { url =
"https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl",
hash =
"sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size
= 143614, upload-time = "2026-05-10T18:15:54.657Z" },
+ { url =
"https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",
hash =
"sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size
= 485538, upload-time = "2026-05-10T18:15:56.117Z" },
+ { url =
"https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl",
hash =
"sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size
= 479623, upload-time = "2026-05-10T18:15:57.544Z" },
+ { url =
"https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
hash =
"sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size
= 513082, upload-time = "2026-05-10T18:15:58.805Z" },
+ { url =
"https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl",
hash =
"sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size
= 508105, upload-time = "2026-05-10T18:16:00.2Z" },
+ { url =
"https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl",
hash =
"sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size
= 522268, upload-time = "2026-05-10T18:16:01.708Z" },
+ { url =
"https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl",
hash =
"sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size
= 527348, upload-time = "2026-05-10T18:16:03.496Z" },
+ { url =
"https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl",
hash =
"sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size
= 516294, upload-time = "2026-05-10T18:16:05.173Z" },
+ { url =
"https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl",
hash =
"sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size
= 553608, upload-time = "2026-05-10T18:16:06.839Z" },
+ { url =
"https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl",
hash =
"sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size
= 101879, upload-time = "2026-05-10T18:16:08.103Z" },
+ { url =
"https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl",
hash =
"sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size
= 119831, upload-time = "2026-05-10T18:16:09.174Z" },
+ { url =
"https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl",
hash =
"sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size
= 103470, upload-time = "2026-05-10T18:16:10.369Z" },
+ { url =
"https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl",
hash =
"sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size
= 144119, upload-time = "2026-05-10T18:16:11.771Z" },
+ { url =
"https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl",
hash =
"sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size
= 143565, upload-time = "2026-05-10T18:16:13.334Z" },
+ { url =
"https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",
hash =
"sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size
= 485395, upload-time = "2026-05-10T18:16:14.729Z" },
+ { url =
"https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl",
hash =
"sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size
= 479383, upload-time = "2026-05-10T18:16:16.321Z" },
+ { url =
"https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
hash =
"sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size
= 513010, upload-time = "2026-05-10T18:16:17.647Z" },
+ { url =
"https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl",
hash =
"sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size
= 508433, upload-time = "2026-05-10T18:16:19.309Z" },
+ { url =
"https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl",
hash =
"sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size
= 522595, upload-time = "2026-05-10T18:16:20.642Z" },
+ { url =
"https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl",
hash =
"sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size
= 527255, upload-time = "2026-05-10T18:16:22.352Z" },
+ { url =
"https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl",
hash =
"sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size
= 516847, upload-time = "2026-05-10T18:16:23.627Z" },
+ { url =
"https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl",
hash =
"sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size
= 553920, upload-time = "2026-05-10T18:16:25.025Z" },
+ { url =
"https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl",
hash =
"sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size
= 101898, upload-time = "2026-05-10T18:16:26.649Z" },
+ { url =
"https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl",
hash =
"sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size
= 119812, upload-time = "2026-05-10T18:16:27.859Z" },
+ { url =
"https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl",
hash =
"sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size
= 103448, upload-time = "2026-05-10T18:16:29.066Z" },
+ { url =
"https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl",
hash =
"sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size
= 143345, upload-time = "2026-05-10T18:16:30.674Z" },
+ { url =
"https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl",
hash =
"sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size
= 143131, upload-time = "2026-05-10T18:16:32.037Z" },
+ { url =
"https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",
hash =
"sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size
= 477024, upload-time = "2026-05-10T18:16:33.493Z" },
+ { url =
"https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl",
hash =
"sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size
= 474221, upload-time = "2026-05-10T18:16:34.864Z" },
+ { url =
"https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
hash =
"sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size
= 505174, upload-time = "2026-05-10T18:16:36.705Z" },
+ { url =
"https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl",
hash =
"sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size
= 497216, upload-time = "2026-05-10T18:16:38.418Z" },
+ { url =
"https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl",
hash =
"sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size
= 513921, upload-time = "2026-05-10T18:16:39.848Z" },
+ { url =
"https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl",
hash =
"sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size
= 520850, upload-time = "2026-05-10T18:16:41.471Z" },
+ { url =
"https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl",
hash =
"sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size
= 504237, upload-time = "2026-05-10T18:16:43.15Z" },
+ { url =
"https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl",
hash =
"sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size
= 546261, upload-time = "2026-05-10T18:16:44.408Z" },
+ { url =
"https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl",
hash =
"sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size
= 96965, upload-time = "2026-05-10T18:16:46.039Z" },
+ { url =
"https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl",
hash =
"sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size
= 115151, upload-time = "2026-05-10T18:16:47.133Z" },
+ { url =
"https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl",
hash =
"sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size
= 98850, upload-time = "2026-05-10T18:16:48.597Z" },
+ { url =
"https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl",
hash =
"sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size
= 151138, upload-time = "2026-05-10T18:16:49.839Z" },
+ { url =
"https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl",
hash =
"sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size
= 151976, upload-time = "2026-05-10T18:16:51.062Z" },
+ { url =
"https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",
hash =
"sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size
= 557927, upload-time = "2026-05-10T18:16:52.632Z" },
+ { url =
"https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl",
hash =
"sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size
= 539698, upload-time = "2026-05-10T18:16:53.934Z" },
+ { url =
"https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
hash =
"sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size
= 577162, upload-time = "2026-05-10T18:16:55.589Z" },
+ { url =
"https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl",
hash =
"sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size
= 566494, upload-time = "2026-05-10T18:16:56.975Z" },
+ { url =
"https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl",
hash =
"sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size
= 596858, upload-time = "2026-05-10T18:16:58.374Z" },
+ { url =
"https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl",
hash =
"sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size
= 590318, upload-time = "2026-05-10T18:16:59.676Z" },
+ { url =
"https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl",
hash =
"sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size
= 575115, upload-time = "2026-05-10T18:17:01.007Z" },
+ { url =
"https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl",
hash =
"sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size
= 617918, upload-time = "2026-05-10T18:17:02.682Z" },
+ { url =
"https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl",
hash =
"sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size
= 103562, upload-time = "2026-05-10T18:17:03.99Z" },
+ { url =
"https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl",
hash =
"sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size
= 124327, upload-time = "2026-05-10T18:17:05.465Z" },
+ { url =
"https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl",
hash =
"sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size
= 102572, upload-time = "2026-05-10T18:17:06.809Z" },
+]
+
+[[package]]
+name = "mypy"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ast-serialize" },
+ { name = "librt", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "mypy-extensions" },
+ { name = "pathspec" },
+ { name = "typing-extensions" },
+]
+sdist = { url =
"https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz",
hash =
"sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size
= 3898359, upload-time = "2026-05-11T18:37:36.237Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl",
hash =
"sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size
= 14691685, upload-time = "2026-05-11T18:33:27.973Z" },
+ { url =
"https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl",
hash =
"sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size
= 13555165, upload-time = "2026-05-11T18:32:16.107Z" },
+ { url =
"https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",
hash =
"sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size
= 13994376, upload-time = "2026-05-11T18:32:39.256Z" },
+ { url =
"https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
hash =
"sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size
= 14864618, upload-time = "2026-05-11T18:34:49.765Z" },
+ { url =
"https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl",
hash =
"sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size
= 15102063, upload-time = "2026-05-11T18:34:05.855Z" },
+ { url =
"https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl",
hash =
"sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size
= 11060564, upload-time = "2026-05-11T18:35:36.494Z" },
+ { url =
"https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl",
hash =
"sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size
= 9966983, upload-time = "2026-05-11T18:37:14.139Z" },
+ { url =
"https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl",
hash =
"sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size
= 14874381, upload-time = "2026-05-11T18:37:31.784Z" },
+ { url =
"https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl",
hash =
"sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size
= 13665501, upload-time = "2026-05-11T18:34:23.063Z" },
+ { url =
"https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",
hash =
"sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size
= 14045750, upload-time = "2026-05-11T18:31:48.151Z" },
+ { url =
"https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
hash =
"sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size
= 15061630, upload-time = "2026-05-11T18:37:06.898Z" },
+ { url =
"https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl",
hash =
"sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size
= 15288831, upload-time = "2026-05-11T18:31:18.07Z" },
+ { url =
"https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl",
hash =
"sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size
= 11135228, upload-time = "2026-05-11T18:34:31.23Z" },
+ { url =
"https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl",
hash =
"sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size
= 10040684, upload-time = "2026-05-11T18:36:48.199Z" },
+ { url =
"https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl",
hash =
"sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size
= 14852174, upload-time = "2026-05-11T18:31:38.929Z" },
+ { url =
"https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl",
hash =
"sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size
= 13651542, upload-time = "2026-05-11T18:36:04.636Z" },
+ { url =
"https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",
hash =
"sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size
= 14033929, upload-time = "2026-05-11T18:35:55.742Z" },
+ { url =
"https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
hash =
"sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size
= 15039200, upload-time = "2026-05-11T18:33:10.281Z" },
+ { url =
"https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl",
hash =
"sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size
= 15272690, upload-time = "2026-05-11T18:32:07.205Z" },
+ { url =
"https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl",
hash =
"sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size
= 11147435, upload-time = "2026-05-11T18:33:56.477Z" },
+ { url =
"https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl",
hash =
"sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size
= 10035052, upload-time = "2026-05-11T18:32:30.049Z" },
+ { url =
"https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl",
hash =
"sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size
= 14848422, upload-time = "2026-05-11T18:35:45.984Z" },
+ { url =
"https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl",
hash =
"sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size
= 13677374, upload-time = "2026-05-11T18:36:57.188Z" },
+ { url =
"https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",
hash =
"sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size
= 14055743, upload-time = "2026-05-11T18:35:18.361Z" },
+ { url =
"https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
hash =
"sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size
= 15020937, upload-time = "2026-05-11T18:34:59.618Z" },
+ { url =
"https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl",
hash =
"sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size
= 15253371, upload-time = "2026-05-11T18:36:41.081Z" },
+ { url =
"https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl",
hash =
"sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size
= 11326429, upload-time = "2026-05-11T18:34:13.526Z" },
+ { url =
"https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl",
hash =
"sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size
= 10218799, upload-time = "2026-05-11T18:32:23.491Z" },
+ { url =
"https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl",
hash =
"sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size
= 15923458, upload-time = "2026-05-11T18:35:28.64Z" },
+ { url =
"https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl",
hash =
"sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size
= 14757697, upload-time = "2026-05-11T18:36:14.208Z" },
+ { url =
"https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",
hash =
"sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size
= 15405638, upload-time = "2026-05-11T18:33:48.249Z" },
+ { url =
"https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
hash =
"sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size
= 16215852, upload-time = "2026-05-11T18:32:50.296Z" },
+ { url =
"https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl",
hash =
"sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size
= 16452695, upload-time = "2026-05-11T18:33:38.182Z" },
+ { url =
"https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl",
hash =
"sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size
= 12866622, upload-time = "2026-05-11T18:34:39.945Z" },
+ { url =
"https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl",
hash =
"sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size
= 10610798, upload-time = "2026-05-11T18:36:31.444Z" },
+ { url =
"https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl",
hash =
"sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size
= 2693302, upload-time = "2026-05-11T18:31:29.246Z" },
+]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz",
hash =
"sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size
= 6343, upload-time = "2025-04-22T14:54:24.164Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl",
hash =
"sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size
= 4963, upload-time = "2025-04-22T14:54:22.983Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz",
hash =
"sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size
= 228134, upload-time = "2026-04-24T20:15:23.917Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl",
hash =
"sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size
= 100195, upload-time = "2026-04-24T20:15:22.081Z" },
+]
+
+[[package]]
+name = "pathspec"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz",
hash =
"sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size
= 135180, upload-time = "2026-04-27T01:46:08.907Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl",
hash =
"sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size
= 57328, upload-time = "2026-04-27T01:46:07.06Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz",
hash =
"sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size
= 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl",
hash =
"sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size
= 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "preflight-audit"
+version = "0.1.0"
+source = { editable = "." }
+
+[package.dev-dependencies]
+dev = [
+ { name = "mypy" },
+ { name = "pytest" },
+ { name = "ruff" },
+]
+
+[package.metadata]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "mypy", specifier = ">=2.1.0" },
+ { name = "pytest", specifier = ">=8.0" },
+ { name = "ruff", specifier = ">=0.15.14" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz",
hash =
"sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size
= 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl",
hash =
"sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size
= 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url =
"https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz",
hash =
"sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size
= 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl",
hash =
"sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size
= 375249, upload-time = "2026-04-07T17:16:16.13Z" },
+]
+
+[[package]]
+name = "ruff"
+version = "0.15.14"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz",
hash =
"sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size
= 4700910, upload-time = "2026-05-21T14:34:55.177Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl",
hash =
"sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size
= 10739177, upload-time = "2026-05-21T14:34:37.332Z" },
+ { url =
"https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl",
hash =
"sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size
= 11144969, upload-time = "2026-05-21T14:34:43.978Z" },
+ { url =
"https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl",
hash =
"sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size
= 10478207, upload-time = "2026-05-21T14:34:48.378Z" },
+ { url =
"https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl",
hash =
"sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size
= 10818459, upload-time = "2026-05-21T14:34:22.318Z" },
+ { url =
"https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl",
hash =
"sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size
= 10541800, upload-time = "2026-05-21T14:34:20.209Z" },
+ { url =
"https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl",
hash =
"sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size
= 11342149, upload-time = "2026-05-21T14:34:46.365Z" },
+ { url =
"https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl",
hash =
"sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size
= 12212563, upload-time = "2026-05-21T14:34:28.579Z" },
+ { url =
"https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl",
hash =
"sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size
= 11493299, upload-time = "2026-05-21T14:34:41.836Z" },
+ { url =
"https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
hash =
"sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size
= 11455931, upload-time = "2026-05-21T14:34:57.276Z" },
+ { url =
"https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl",
hash =
"sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size
= 11400794, upload-time = "2026-05-21T14:34:39.773Z" },
+ { url =
"https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl",
hash =
"sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size
= 10804759, upload-time = "2026-05-21T14:34:31.045Z" },
+ { url =
"https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl",
hash =
"sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size
= 10539517, upload-time = "2026-05-21T14:34:53.064Z" },
+ { url =
"https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl",
hash =
"sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size
= 11065169, upload-time = "2026-05-21T14:34:24.484Z" },
+ { url =
"https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl",
hash =
"sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size
= 11560214, upload-time = "2026-05-21T14:34:50.975Z" },
+ { url =
"https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl",
hash =
"sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size
= 10805548, upload-time = "2026-05-21T14:34:33.453Z" },
+ { url =
"https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl",
hash =
"sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size
= 11939523, upload-time = "2026-05-21T14:34:18.077Z" },
+ { url =
"https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl",
hash =
"sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size
= 11208607, upload-time = "2026-05-21T14:34:26.525Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz",
hash =
"sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size
= 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl",
hash =
"sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size
= 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+]