You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airflow.apache.org by po...@apache.org on 2026/04/28 21:16:30 UTC

(airflow-steward) 01/01: tools(generate-cve-json): port project-agnostic Python implementation into framework

This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch port-generate-cve-json
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git

commit a25c36f104e8c39c79c7c70911f3b25ddf3fea6a
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Tue Apr 28 23:16:10 2026 +0200

    tools(generate-cve-json): port project-agnostic Python implementation into framework
    
    PR 2 of 3 in the generate-cve-json refactor (PR 1 landed at
    airflow-s/airflow-s — refactored the tool to load all project-specific
    values from a TOML config). This commit ports the now-project-agnostic
    Python implementation into the apache/airflow-steward framework so the
    framework can ship the implementation alongside the SKILL.md description.
    
    Files added:
    
    - tools/vulnogram/generate-cve-json/pyproject.toml — Python package metadata.
    - tools/vulnogram/generate-cve-json/src/generate_cve_json/{cve_json,__init__,__main__}.py
      — the project-agnostic implementation. Loads config at startup
      from --config CLI flag → $CVE_JSON_CONFIG → <cwd>/.apache-steward/tools/vulnogram/cve-json-config.toml.
    - tools/vulnogram/generate-cve-json/tests/{__init__,conftest,test_generate_cve_json}.py
      — full test suite (100 tests). Conftest points at a fixture
      config in tests/fixtures/.
    - tools/vulnogram/generate-cve-json/tests/fixtures/cve-json-config.toml
      — TEST FIXTURE config (clearly labeled as such). Mirrors one
      adopter's setup so the existing tests' assertions pass without
      rewriting; NOT shipped as a default for adopters.
    - tools/vulnogram/generate-cve-json/uv.lock — uv lockfile.
    
    Files updated:
    
    - .pre-commit-config.yaml — added the four generate-cve-json hooks
      (ruff-check, ruff-format, mypy, pytest) restored from the airflow-s
      pre-commit config.
    - tools/vulnogram/generate-cve-json/SKILL.md — preamble note
      clarifying that examples use Airflow's config as illustration; the
      tool itself is config-driven and emits CVE records against any
      adopter's product taxonomy.
    
    Test plan:
    
    - All 100 tests pass against the test-fixture config.
    - All four pre-commit hooks pass (ruff/mypy/pytest + the standard set).
    
    Known follow-ups:
    
    - The SKILL.md still has substantial Airflow-flavoured prose in the
      body (provider directory examples, `apache-airflow-providers-...`
      package names, etc.). The preamble note flags this; tightening
      passes can rephrase example-by-example without changing the
      contract.
    - The test fixture config is Airflow-shaped because the tests were
      written against that taxonomy. A future PR could replace it with a
      synthetic ("Acme Project") fixture and rewrite assertions to match.
    
    PR 3 (against airflow-s) will delete the local Python implementation
    (it lives in the framework now via submodule) and update skill
    references to invoke the framework copy.
    
    Generated-by: Claude Opus 4.7 (1M context) <no...@anthropic.com>
---
 .pre-commit-config.yaml                            |   32 +
 tools/vulnogram/generate-cve-json/SKILL.md         |   10 +
 tools/vulnogram/generate-cve-json/pyproject.toml   |   94 ++
 .../src/generate_cve_json/__init__.py              |   95 ++
 .../src/generate_cve_json/__main__.py              |   24 +
 .../src/generate_cve_json/cve_json.py              | 1757 ++++++++++++++++++++
 .../vulnogram/generate-cve-json/tests/__init__.py  |    0
 .../vulnogram/generate-cve-json/tests/conftest.py  |   42 +
 .../tests/fixtures/cve-json-config.toml            |  179 ++
 .../tests/test_generate_cve_json.py                |  961 +++++++++++
 tools/vulnogram/generate-cve-json/uv.lock          |  264 +++
 11 files changed, 3458 insertions(+)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 8e063ff..cb45c2a 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -53,3 +53,35 @@ repos:
         name: Detect if mixed line ending is used (\r vs. \r\n)
       - id: trailing-whitespace
         name: Remove trailing whitespace at end of line
+  # Project-local checks for the `generate-cve-json` Python project at
+  # `tools/vulnogram/generate-cve-json/`. Each hook sets the working
+  # directory via `uv run --directory` so ruff / mypy / pytest pick up
+  # config from the project's pyproject.toml without explicit paths.
+  # The `files:` pattern scopes each hook to changes inside the project
+  # directory.
+  - repo: local
+    hooks:
+      - id: generate-cve-json-ruff-check
+        name: ruff check (generate-cve-json)
+        language: system
+        entry: uv run --directory tools/vulnogram/generate-cve-json ruff check
+        files: ^tools/vulnogram/generate-cve-json/(src|tests|pyproject\.toml)
+        pass_filenames: false
+      - id: generate-cve-json-ruff-format
+        name: ruff format (generate-cve-json)
+        language: system
+        entry: uv run --directory tools/vulnogram/generate-cve-json ruff format --check
+        files: ^tools/vulnogram/generate-cve-json/(src|tests|pyproject\.toml)
+        pass_filenames: false
+      - id: generate-cve-json-mypy
+        name: mypy (generate-cve-json)
+        language: system
+        entry: uv run --directory tools/vulnogram/generate-cve-json mypy
+        files: ^tools/vulnogram/generate-cve-json/(src|tests|pyproject\.toml)
+        pass_filenames: false
+      - id: generate-cve-json-pytest
+        name: pytest (generate-cve-json)
+        language: system
+        entry: uv run --directory tools/vulnogram/generate-cve-json pytest
+        files: ^tools/vulnogram/generate-cve-json/(src|tests|pyproject\.toml)
+        pass_filenames: false
diff --git a/tools/vulnogram/generate-cve-json/SKILL.md b/tools/vulnogram/generate-cve-json/SKILL.md
index 18fb83e..c2765dd 100644
--- a/tools/vulnogram/generate-cve-json/SKILL.md
+++ b/tools/vulnogram/generate-cve-json/SKILL.md
@@ -27,6 +27,16 @@ paste into the Vulnogram **"#source"** tab of the ASF CVE tool. The goal is
 to eliminate the manual "copy each field from the issue into the right
 Vulnogram form input" step when you are preparing to publish an advisory.
 
+> **Project-agnostic by design.** All project-specific values
+> (vendor, top-level product / package name, provider display map,
+> CNA org id, generator tag, …) are loaded from a TOML config the
+> adopting project ships at `<project-config>/tools/vulnogram/cve-json-config.toml`.
+> Examples in this document use Apache Airflow's configuration as a
+> running illustration, since that's where the tool was originally
+> developed; any adopter writes their own config per the schema in
+> the package [README](README.md) and the tool emits CVE records
+> against their own product taxonomy.
+
 **Golden rule:** the script generates a *proposal* JSON document. It
 parses a handful of structured fields from the issue body, but it cannot
 read the security team member's mind. Always review the generated JSON
diff --git a/tools/vulnogram/generate-cve-json/pyproject.toml b/tools/vulnogram/generate-cve-json/pyproject.toml
new file mode 100644
index 0000000..7f363e7
--- /dev/null
+++ b/tools/vulnogram/generate-cve-json/pyproject.toml
@@ -0,0 +1,94 @@
+# 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 = "generate-cve-json"
+version = "0.1.0"
+description = "Generate a CVE 5.x JSON record from a tracking issue in the active project's security tracker (Vulnogram adapter)."
+readme = "README.md"
+requires-python = ">=3.11"
+license = { text = "Apache-2.0" }
+# Runtime deps are deliberately empty — the script is stdlib-only and shells
+# out to `gh` for GitHub access. Keeping the runtime closed means `uv run`
+# can resolve the environment in milliseconds.
+dependencies = []
+
+[project.scripts]
+generate-cve-json = "generate_cve_json:main"
+
+[dependency-groups]
+dev = [
+  "mypy>=1.11",
+  "pytest>=8.0",
+  "ruff>=0.6",
+]
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/generate_cve_json"]
+
+[tool.ruff]
+line-length = 110
+target-version = "py311"
+src = ["src", "tests"]
+
+[tool.ruff.lint]
+select = [
+  "E",     # pycodestyle errors
+  "W",     # pycodestyle warnings
+  "F",     # pyflakes
+  "I",     # isort
+  "B",     # flake8-bugbear
+  "UP",    # pyupgrade
+  "SIM",   # flake8-simplify
+  "C4",    # flake8-comprehensions
+  "RUF",   # ruff-specific
+]
+ignore = [
+  "E501",  # line-too-long — the 110-char limit above is already generous
+]
+
+[tool.ruff.lint.per-file-ignores]
+"tests/**" = ["B", "SIM"]  # test clarity beats these
+
+[tool.mypy]
+python_version = "3.11"
+files = ["src", "tests"]
+# The script manipulates untyped JSON-dict shapes extensively; bare
+# `dict` / `list` annotations are pragmatic. Keep the useful checks
+# (unreachable code, implicit Optional, return types) and turn off the
+# ones that would require a heavy TypedDict refactor.
+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.*"
+# Tests aren't expected to type-annotate everything and often mock freely.
+disallow_untyped_defs = false
+disallow_incomplete_defs = false
+
+[tool.pytest.ini_options]
+minversion = "8.0"
+addopts = "-ra -q"
+testpaths = ["tests"]
diff --git a/tools/vulnogram/generate-cve-json/src/generate_cve_json/__init__.py b/tools/vulnogram/generate-cve-json/src/generate_cve_json/__init__.py
new file mode 100644
index 0000000..f304b45
--- /dev/null
+++ b/tools/vulnogram/generate-cve-json/src/generate_cve_json/__init__.py
@@ -0,0 +1,95 @@
+# 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.
+"""Public entry points for the ``generate-cve-json`` project.
+
+The implementation lives in :mod:`generate_cve_json.cve_json`; this
+package re-exports the names that callers (``generate-cve-json`` console
+script, ``python -m generate_cve_json``, and the test suite) depend on
+so they can keep using ``from generate_cve_json import X``.
+"""
+
+from __future__ import annotations
+
+from generate_cve_json.cve_json import (
+    COLLECTION_URL_TO_PROJECT_URL_TEMPLATE,
+    NEXT_VERSION_TOKEN_RE,
+    PROVIDER_DISPLAY_MAP,
+    _build_attachment_body,
+    _has_vendor_advisory_reference,
+    _is_cna_ready_for_review,
+    _product_for_package,
+    attach_to_issue,
+    build_affected,
+    build_cna_container,
+    build_credits,
+    build_descriptions,
+    build_metrics,
+    build_problem_types,
+    build_references,
+    classify_reference,
+    combine_remediation_developers,
+    compute_cna_private_state,
+    compute_package_url,
+    emit_json,
+    extract_field,
+    fetch_issue,
+    format_version_range,
+    main,
+    parse_affected_versions,
+    parse_args,
+    parse_credits_from_field,
+    parse_cve_id,
+    parse_cwe,
+    parse_url_list,
+    resolve_title,
+    wrap_cve_record,
+)
+
+__all__ = [
+    "COLLECTION_URL_TO_PROJECT_URL_TEMPLATE",
+    "NEXT_VERSION_TOKEN_RE",
+    "PROVIDER_DISPLAY_MAP",
+    "_build_attachment_body",
+    "_has_vendor_advisory_reference",
+    "_is_cna_ready_for_review",
+    "_product_for_package",
+    "attach_to_issue",
+    "build_affected",
+    "build_cna_container",
+    "build_credits",
+    "build_descriptions",
+    "build_metrics",
+    "build_problem_types",
+    "build_references",
+    "classify_reference",
+    "combine_remediation_developers",
+    "compute_cna_private_state",
+    "compute_package_url",
+    "emit_json",
+    "extract_field",
+    "fetch_issue",
+    "format_version_range",
+    "main",
+    "parse_affected_versions",
+    "parse_args",
+    "parse_credits_from_field",
+    "parse_cve_id",
+    "parse_cwe",
+    "parse_url_list",
+    "resolve_title",
+    "wrap_cve_record",
+]
diff --git a/tools/vulnogram/generate-cve-json/src/generate_cve_json/__main__.py b/tools/vulnogram/generate-cve-json/src/generate_cve_json/__main__.py
new file mode 100644
index 0000000..f3a1d49
--- /dev/null
+++ b/tools/vulnogram/generate-cve-json/src/generate_cve_json/__main__.py
@@ -0,0 +1,24 @@
+# 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.
+"""Entry point for ``python -m generate_cve_json``."""
+
+from __future__ import annotations
+
+from generate_cve_json import main
+
+if __name__ == "__main__":
+    main()
diff --git a/tools/vulnogram/generate-cve-json/src/generate_cve_json/cve_json.py b/tools/vulnogram/generate-cve-json/src/generate_cve_json/cve_json.py
new file mode 100644
index 0000000..37a38f6
--- /dev/null
+++ b/tools/vulnogram/generate-cve-json/src/generate_cve_json/cve_json.py
@@ -0,0 +1,1757 @@
+# 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.
+"""Generate a CVE 5.x JSON record from an airflow-s/airflow-s issue.
+
+This script reads a tracking issue via ``gh issue view``, parses the
+standard template fields in the issue body, and emits a JSON document
+that can be pasted straight into the Vulnogram ``#source`` tab of the ASF
+CVE tool at
+
+    https://cveprocess.apache.org/cve5/<CVE-ID>#source
+
+The output shape is modelled on a real CVE record exported from
+Vulnogram so that a paste-and-save round-trip does not mangle the
+record. In particular, the script produces:
+
+* ``affected[]`` entries with ``vendor``, ``product``, ``collectionURL``
+  (PyPI), ``packageName`` (``apache-airflow``) and a semver range;
+* a ``descriptions[]`` entry with both a plain ``value`` and an HTML
+  ``supportingMedia`` alternative (Vulnogram's WYSIWYG mode expects
+  both);
+* ``metrics[].other`` with ``type`` = *"Textual description of severity"*
+  and ``content.text`` = the severity word;
+* ``problemTypes[].descriptions[]`` with ``cweId``, ``description`` and
+  ``type`` = *"CWE"*;
+* ``credits[]`` entries from the *Reporter credited as* field
+  (``type: "finder"``) and the *Remediation developer* field
+  (``type: "remediation developer"``) — both newline-separated, with
+  the ``Full Name, Affiliation`` pattern preserved as one credit; the
+  ``--remediation-developer`` CLI flag still works as an additional /
+  override mechanism on top of the body field;
+* ``references[]`` with automatic ``tags`` (``patch`` for
+  ``github.com/.../pull/...`` URLs, ``vendor-advisory`` for
+  ``lists.apache.org``/``security.apache.org``). URLs from the
+  tracking-issue's *Public advisory URL* body field are picked up
+  automatically and tagged as ``vendor-advisory``; URLs from the
+  *Security mailing list thread* field are **deliberately not** exported
+  because that field holds the internal ``security@airflow.apache.org``
+  thread URL, which 404s for anyone outside the security team;
+* ``providerMetadata.orgId`` and ``cveMetadata.assignerOrgId`` set to
+  the ASF org id;
+* an ``x_generator`` annotation so the origin of the JSON is visible.
+
+The output is **deterministic**: the same issue body and the same CLI
+flags always produce the same JSON bytes. Keys are sorted, references
+are sorted alphabetically, credits preserve the order of first
+occurrence, and no timestamps or machine-dependent data are included.
+
+Usage::
+
+    # Read the issue from GitHub and write the JSON to stdout:
+    uv run --project tools/vulnogram/generate-cve-json generate-cve-json 232
+
+    # Write to a file and show the Vulnogram paste URL:
+    uv run --project tools/vulnogram/generate-cve-json generate-cve-json 232 \\
+        --output /tmp/CVE-2026-40913.json
+
+    # Attach an explicit "remediation developer" credit:
+    uv run --project tools/vulnogram/generate-cve-json generate-cve-json 232 \\
+        --remediation-developer "Kevin Yang"
+
+    # Test mode: read a prepared issue body from stdin so you can
+    # iterate on the parser without touching the network:
+    cat /tmp/issue232-body.md | \\
+        uv run --project tools/vulnogram/generate-cve-json generate-cve-json \\
+            --stdin --cve-id CVE-2026-40913 \\
+            --title "Apache Airflow: ..."
+
+The runtime uses only the Python standard library; the ``pyproject.toml``
+declares no runtime dependencies, so ``uv run`` resolves the environment
+in milliseconds. Dev tooling (pytest, ruff, mypy) lives in the ``dev``
+dependency group.
+"""
+
+from __future__ import annotations
+
+import argparse
+import contextlib
+import html
+import json
+import os
+import re
+import subprocess
+import sys
+import tempfile
+
+# -----------------------------------------------------------------------------
+# Configuration loading.
+# -----------------------------------------------------------------------------
+#
+# All project-specific values (vendor, top-level product/package, provider
+# display map, package-name regex, CNA org id, generator tag, …) are read
+# at module load time from a TOML config file the adopting project ships
+# in its tracker repo at:
+#
+#   <project-config>/tools/vulnogram/cve-json-config.toml
+#
+# (where `<project-config>` is the adopting project's `.apache-steward/`
+# directory, per the apache/airflow-steward placeholder convention).
+#
+# Resolution order for the config path:
+#   1. The `--config <path>` CLI flag (or the `config_path=` argument to
+#      `_load_config()`).
+#   2. The `CVE_JSON_CONFIG` environment variable.
+#   3. `<cwd>/.apache-steward/tools/vulnogram/cve-json-config.toml`.
+#
+# Schema: see the README in this package.
+import tomllib
+from pathlib import Path
+
+_DEFAULT_CONFIG_RELPATH = ".apache-steward/tools/vulnogram/cve-json-config.toml"
+_CONFIG_PATH_ENV = "CVE_JSON_CONFIG"
+
+# Cached, lazily-loaded config. `_populate_constants()` reads this and
+# fills the module globals below; tests that want to use a different
+# config can call `_set_config_path(path)` before calling any
+# generator function.
+_CONFIG: dict | None = None
+
+
+def _resolve_config_path(config_path: Path | str | None = None) -> Path:
+    """Resolve the config file path following the documented order."""
+    if config_path is not None:
+        return Path(config_path)
+    env = os.environ.get(_CONFIG_PATH_ENV)
+    if env:
+        return Path(env)
+    return Path.cwd() / _DEFAULT_CONFIG_RELPATH
+
+
+def _load_config(config_path: Path | str | None = None) -> dict:
+    """Load TOML config, raising ``FileNotFoundError`` with usage hints
+    if it cannot be located.
+    """
+    path = _resolve_config_path(config_path)
+    if not path.exists():
+        raise FileNotFoundError(
+            f"generate-cve-json: config file not found at {path}.\n"
+            f"  Pass --config <path>, set ${_CONFIG_PATH_ENV}, or place a config\n"
+            f"  at {_DEFAULT_CONFIG_RELPATH} relative to the cwd.\n"
+            f"  Schema: see the generate-cve-json package README."
+        )
+    return tomllib.loads(path.read_text())
+
+
+def _set_config_path(config_path: Path | str) -> None:
+    """Set / override the config path (used by `--config` CLI and tests)."""
+    global _CONFIG
+    _CONFIG = _load_config(config_path)
+    _populate_constants()
+
+
+def _populate_constants() -> None:
+    """(Re)populate module globals from the loaded config."""
+    global _CONFIG
+    if _CONFIG is None:
+        _CONFIG = _load_config()
+    cfg = _CONFIG
+
+    global DEFAULT_REPO, DEFAULT_VENDOR, DEFAULT_PRODUCT
+    global DEFAULT_PACKAGE_NAME, DEFAULT_COLLECTION_URL
+    global DEFAULT_ASF_ORG_ID, GENERATOR_TAG, SKILL_SOURCE_URL
+    global PROVIDER_DISPLAY_MAP, PACKAGE_RE
+    global TOP_LEVEL_NAME, TOP_LEVEL_PRODUCT, PROVIDER_PRODUCT_TEMPLATE
+    global PROVIDER_PREFIX
+
+    DEFAULT_REPO = cfg["meta"]["tracker_repo"]
+    DEFAULT_VENDOR = cfg["product"]["vendor"]
+    DEFAULT_PRODUCT = cfg["product"]["default_product"]
+    DEFAULT_PACKAGE_NAME = cfg["product"]["default_package_name"]
+    DEFAULT_COLLECTION_URL = cfg["product"]["default_collection_url"]
+    DEFAULT_ASF_ORG_ID = cfg["cna"]["org_id"]
+    GENERATOR_TAG = cfg["meta"]["generator_tag"]
+    SKILL_SOURCE_URL = cfg["meta"].get(
+        "skill_source_url", f"https://github.com/{cfg['meta']['tracker_repo']}"
+    )
+    PROVIDER_DISPLAY_MAP = dict(cfg["packages"]["provider_display_map"])
+    PACKAGE_RE = re.compile(cfg["packages"]["package_pattern"])
+    TOP_LEVEL_NAME = cfg["packages"]["top_level_name"]
+    TOP_LEVEL_PRODUCT = cfg["packages"]["top_level_product"]
+    PROVIDER_PRODUCT_TEMPLATE = cfg["packages"]["provider_product_template"]
+    # Convenient derived constant: the prefix used to detect
+    # "provider" subpackages (e.g. ``apache-airflow-providers-``).
+    PROVIDER_PREFIX = f"{TOP_LEVEL_NAME}-providers-"
+
+
+# Module-level constants — populated by `_populate_constants()`. The
+# initial values are placeholders; the call below populates them at
+# import time. Tests can override by calling `_set_config_path()`
+# before importing any generator function.
+DEFAULT_REPO: str = ""
+DEFAULT_VENDOR: str = ""
+DEFAULT_PRODUCT: str = ""
+DEFAULT_PACKAGE_NAME: str = ""
+DEFAULT_COLLECTION_URL: str = ""
+DEFAULT_ASF_ORG_ID: str = ""
+GENERATOR_TAG: str = ""
+SKILL_SOURCE_URL: str = ""
+PROVIDER_DISPLAY_MAP: dict[str, str] = {}
+PACKAGE_RE: re.Pattern[str] = re.compile("")
+TOP_LEVEL_NAME: str = ""
+TOP_LEVEL_PRODUCT: str = ""
+PROVIDER_PRODUCT_TEMPLATE: str = ""
+PROVIDER_PREFIX: str = ""
+
+# CVE 5.x convention values that are not project-specific.
+DEFAULT_CREDIT_TYPE = "finder"
+DEFAULT_LANG = "en"
+DEFAULT_DISCOVERY = "UNKNOWN"
+
+# Populate at import time. If the config is not present, defer the
+# error to first actual use so test harnesses can call
+# `_set_config_path()` before exercising the module.
+with contextlib.suppress(FileNotFoundError):
+    _populate_constants()
+
+NO_RESPONSE = "_No response_"
+
+# Sentinel token for an as-yet-unreleased upper bound in
+# ``Affected versions`` entries. Used predominantly for providers
+# trackers where the wave milestone (date-based, e.g. ``Providers
+# 2026-04-21``) does not reveal which exact provider package version
+# will ship the fix. The token is stripped before parsing and the
+# resulting CVE 5.x ``versions[]`` entry omits ``lessThan`` —
+# downstream consumers (Vulnogram, cve.org) read that as
+# *"affected from <low> onwards, no fix released yet"*. Once the
+# release ships and the version is known the sync skill replaces the
+# token with the real upper bound (``< X.Y.Z``).
+NEXT_VERSION_TOKEN_RE = re.compile(r"<\s*NEXT\s+VERSION\b", re.IGNORECASE)
+
+# Map a CVE ``affected[].collectionURL`` to the canonical project-page
+# URL template used to render a clickable per-package link in the
+# attachment table. The PyPI ``collectionURL`` is the legacy
+# ``https://pypi.python.org`` host, but the canonical project page lives
+# on ``pypi.org/project/<name>/``; the table renders that.
+# Only collection URLs Airflow actually emits today need entries here —
+# unknown URLs fall through to ``None`` and the attachment table
+# renders ``—`` for them.
+COLLECTION_URL_TO_PROJECT_URL_TEMPLATE: dict[str, str] = {
+    "https://pypi.python.org": "https://pypi.org/project/{package}/",
+    "https://pypi.org": "https://pypi.org/project/{package}/",
+}
+
+# HTML-comment marker prefix used to identify the single "CVE JSON
+# attachment" comment on a tracking issue, so ``--attach`` can update
+# the existing comment in place instead of posting a duplicate on every
+# re-run. The full marker includes the CVE id and a version tag.
+ATTACHMENT_MARKER_BEGIN_PREFIX = "<!-- generate-cve-json:"
+ATTACHMENT_MARKER_END_PREFIX = "<!-- generate-cve-json:end"
+ATTACHMENT_MARKER_VERSION = "v1"
+# Back-compat alias — the `v1` comment-based attachments used a single
+# marker. New body-embedded attachments use a begin/end pair.
+ATTACHMENT_MARKER_PREFIX = ATTACHMENT_MARKER_BEGIN_PREFIX
+SKILL_SOURCE_URL = "https://github.com/airflow-s/airflow-s/tree/airflow-s/tools/vulnogram/generate-cve-json"
+
+
+# -----------------------------------------------------------------------------
+# Input: fetch the issue body from GitHub (or read from stdin in test mode).
+# -----------------------------------------------------------------------------
+
+
+def fetch_issue(issue_number: int | str, repo: str) -> tuple[str, str]:
+    """Return ``(title, body)`` for the given issue.
+
+    Calls ``gh issue view <N> --repo <repo> --json title,body``. Raises a
+    ``RuntimeError`` with the stderr output if ``gh`` is unavailable or
+    the issue does not exist.
+    """
+    try:
+        result = subprocess.run(
+            [
+                "gh",
+                "issue",
+                "view",
+                str(issue_number),
+                "--repo",
+                repo,
+                "--json",
+                "title,body",
+            ],
+            check=True,
+            capture_output=True,
+            text=True,
+        )
+    except FileNotFoundError as exc:
+        raise RuntimeError(
+            "`gh` CLI not found on PATH. Install it from https://cli.github.com/ "
+            "or run with --stdin to bypass the network call."
+        ) from exc
+    except subprocess.CalledProcessError as exc:
+        raise RuntimeError(f"`gh issue view {issue_number} --repo {repo}` failed:\n{exc.stderr}") from exc
+
+    data = json.loads(result.stdout)
+    return data.get("title", ""), data.get("body", "")
+
+
+# -----------------------------------------------------------------------------
+# Parse the issue body into individual template fields.
+# -----------------------------------------------------------------------------
+
+
+def extract_field(body: str, heading: str) -> str:
+    """Return the text under ``### <heading>`` up to the next ``###``.
+
+    Trailing whitespace is stripped. The literal placeholder
+    ``_No response_`` is normalised to an empty string so callers only
+    have to check truthiness.
+    """
+    pattern = rf"^###\s+{re.escape(heading)}\s*\n+(?P<value>.*?)(?=\n###\s|\Z)"
+    match = re.search(pattern, body, re.MULTILINE | re.DOTALL)
+    if not match:
+        return ""
+    value = match.group("value").strip()
+    if value == NO_RESPONSE:
+        return ""
+    return value
+
+
+def parse_credits_from_field(value: str) -> list[str]:
+    """Split the ``Reporter credited as`` field into a list of credit
+    names.
+
+    Credits are split on **newlines only**, deliberately *not* on commas:
+    the common ``Full Name, Affiliation`` pattern (for example
+    ``Jed Cunningham, Astronomer``) is one credit, not two. If you need
+    to credit multiple people, put each one on its own line. Bullets
+    (``-``, ``*``, ``+``, ``1.``) are stripped. Blank lines are ignored.
+    De-duplicated preserving order of first occurrence.
+    """
+    if not value:
+        return []
+    names: list[str] = []
+    seen: set[str] = set()
+    for raw_line in value.splitlines():
+        line = raw_line.strip()
+        if not line:
+            continue
+        line = re.sub(r"^(?:[-*+]\s+|\d+[.)]\s+)", "", line)
+        line = line.strip(" `\t")
+        if line and line not in seen:
+            seen.add(line)
+            names.append(line)
+    return names
+
+
+def combine_remediation_developers(field_value: str, cli_overrides: list[str]) -> list[str]:
+    """Merge body-field developers with CLI overrides; preserve order, drop dupes.
+
+    The body's *Remediation developer* field is the source of truth
+    in the normal flow (auto-populated by the sync skill from the
+    PR's author). The ``--remediation-developer`` CLI flag is
+    additive: any name passed there is appended after the body
+    entries unless it already appears in the body. Duplicates are
+    dropped silently so a triager can pass ``--remediation-developer
+    "Alice"`` without worrying about whether Alice is already in the
+    body.
+    """
+    combined: list[str] = []
+    seen: set[str] = set()
+    for name in [*parse_credits_from_field(field_value), *cli_overrides]:
+        cleaned = name.strip()
+        if cleaned and cleaned not in seen:
+            combined.append(cleaned)
+            seen.add(cleaned)
+    return combined
+
+
+def parse_url_list(value: str) -> list[str]:
+    """Return every URL mentioned in ``value`` in order of first
+    occurrence.
+
+    Handles bullet lists, plain-text lines and markdown-link syntax
+    (``[text](url)``) uniformly. Non-URL tokens are ignored, which lets
+    a reviewer annotate the field with notes without breaking parsing.
+    """
+    if not value:
+        return []
+    urls: list[str] = []
+    seen: set[str] = set()
+    for match in re.finditer(r"https?://[^\s)>\]]+", value):
+        url = match.group(0).rstrip(").,;>")
+        if url not in seen:
+            seen.add(url)
+            urls.append(url)
+    return urls
+
+
+def parse_cve_id(value: str) -> str:
+    """Extract the first ``CVE-YYYY-NNNN+`` token from ``value``."""
+    match = re.search(r"CVE-\d{4}-\d{4,}", value)
+    return match.group(0) if match else ""
+
+
+def parse_cwe(value: str) -> tuple[str, str]:
+    """Return ``(cweId, human-readable description)`` from a CWE field.
+
+    Accepts ``CWE-285``, ``CWE-285: Improper Authorization`` and
+    free-form text. If no ``CWE-\\d+`` token is found, ``cweId`` is an
+    empty string and the full value is returned as the description.
+    """
+    value = value.strip()
+    id_match = re.search(r"CWE-\d+", value)
+    cwe_id = id_match.group(0) if id_match else ""
+    if cwe_id:
+        # Use "CWE-285: Improper Authorization" as the description when
+        # a title follows the id; otherwise just the id.
+        title_match = re.match(r"^CWE-\d+\s*[:\-]?\s*(?P<rest>.*)", value)
+        rest = (title_match.group("rest") if title_match else "").strip()
+        description = f"{cwe_id}: {rest}" if rest else cwe_id
+    else:
+        description = value
+    return cwe_id, description
+
+
+def parse_affected_versions(value: str, version_start_override: str | None) -> list[dict]:
+    """Turn the ``Affected versions`` field into a CVE ``versions``
+    array.
+
+    Supports a few common shapes:
+
+    * ``< 3.2.2`` / ``<3.2.2`` → ``{version: "0", lessThan: "3.2.2", ...}``
+    * ``>= 2.0.0, < 3.2.2`` → ``{version: "2.0.0", lessThan: "3.2.2", ...}``
+    * ``<= 3.2.1`` / ``<=3.2.1`` → ``{version: "0", lessThanOrEqual: "3.2.1", ...}``
+    * A bare version like ``3.1.5`` → ``{version: "3.1.5", status: "affected"}``
+    * A bare lower bound like ``>=2.0.0`` → ``{version: "2.0.0",
+      status: "affected"}`` (no upper bound — useful for trackers
+      whose fix-shipped version is not yet known).
+
+    The ``< NEXT VERSION`` sentinel signals "fix not yet released,
+    upper bound unknown" — used predominantly on providers trackers
+    where the wave milestone is date-based and the package version
+    that will carry the fix is decided by the release manager during
+    the wave. The token is stripped before further parsing; the
+    resulting entry has no ``lessThan``. Once the release ships, the
+    sync skill replaces ``< NEXT VERSION`` with the actual ``< X.Y.Z``
+    upper bound and the next regen produces a fully-bounded entry.
+    See the ``NEXT VERSION`` rule in
+    ``.claude/skills/sync-security-issue/SKILL.md`` for the lifecycle.
+
+    ``--version-start`` overrides the low bound unconditionally when set,
+    because the issue body rarely specifies it and a reviewer usually
+    knows the first affected release by heart.
+
+    Unknown shapes fall back to a single ``version`` entry with the raw
+    string, leaving normalisation to the reviewer in Vulnogram.
+    """
+    cleaned = value.strip().strip("`").strip() if value else ""
+    low_bound = (version_start_override or "").strip() or "0"
+
+    if NEXT_VERSION_TOKEN_RE.search(cleaned):
+        cleaned = NEXT_VERSION_TOKEN_RE.sub("", cleaned).strip().rstrip(",").strip()
+
+    if not cleaned:
+        return [
+            {
+                "status": "affected",
+                "version": low_bound,
+                "versionType": "semver",
+            }
+        ]
+
+    range_match = re.search(
+        r">=?\s*(?P<low>[0-9][0-9A-Za-z.\-+]*)\s*,?\s*<\s*(?P<high>[0-9][0-9A-Za-z.\-+]*)",
+        cleaned,
+    )
+    if range_match:
+        return [
+            {
+                "status": "affected",
+                "version": version_start_override or range_match.group("low"),
+                "lessThan": range_match.group("high"),
+                "versionType": "semver",
+            }
+        ]
+
+    less_than_or_equal_match = re.match(r"<=\s*(?P<high>[0-9][0-9A-Za-z.\-+]*)", cleaned)
+    if less_than_or_equal_match:
+        return [
+            {
+                "status": "affected",
+                "version": low_bound,
+                "lessThanOrEqual": less_than_or_equal_match.group("high"),
+                "versionType": "semver",
+            }
+        ]
+
+    less_than_match = re.match(r"<\s*(?P<high>[0-9][0-9A-Za-z.\-+]*)", cleaned)
+    if less_than_match:
+        return [
+            {
+                "status": "affected",
+                "version": low_bound,
+                "lessThan": less_than_match.group("high"),
+                "versionType": "semver",
+            }
+        ]
+
+    single_match = re.match(r"^(?P<v>[0-9][0-9A-Za-z.\-+]*)$", cleaned)
+    if single_match:
+        return [
+            {
+                "status": "affected",
+                "version": single_match.group("v"),
+                "versionType": "semver",
+            }
+        ]
+
+    ge_only_match = re.match(r">=?\s*(?P<low>[0-9][0-9A-Za-z.\-+]*)\s*$", cleaned)
+    if ge_only_match:
+        return [
+            {
+                "status": "affected",
+                "version": version_start_override or ge_only_match.group("low"),
+                "versionType": "semver",
+            }
+        ]
+
+    return [{"status": "affected", "version": cleaned}]
+
+
+def normalise_severity(value: str) -> str:
+    """Return the severity as a lower-case word
+    (``none`` / ``low`` / ``medium`` / ``high`` / ``critical``) or the
+    original text if it doesn't match the standard set."""
+    lowered = value.strip().lower()
+    if lowered in {"none", "low", "medium", "high", "critical"}:
+        return lowered
+    return value.strip()
+
+
+def to_html(text: str) -> str:
+    """Convert a plain-text description to the minimal HTML shape that
+    Vulnogram's WYSIWYG mode stores in ``supportingMedia``.
+
+    The output is HTML-escaped and paragraph breaks (``\\n\\n``) are
+    rendered as ``<br><br>``, single newlines as ``<br>``. No inline
+    styles or span wrappers are emitted -- those are editor cruft that
+    appears after a save round-trip and is not needed on paste-in.
+    """
+    escaped = html.escape(text)
+    escaped = escaped.replace("\r\n", "\n").replace("\n\n", "<br><br>")
+    escaped = escaped.replace("\n", "<br>")
+    return escaped
+
+
+# -----------------------------------------------------------------------------
+# Reference tagging.
+# -----------------------------------------------------------------------------
+
+
+def classify_reference(url: str) -> list[str]:
+    """Return the CVE reference ``tags`` for ``url``.
+
+    * ``github.com/.../pull/N`` and ``github.com/.../commit/<sha>`` →
+      ``["patch"]``.
+    * ``lists.apache.org/...`` and ``security.apache.org/...`` →
+      ``["vendor-advisory"]``.
+    * Anything else → no tags (empty list).
+    """
+    if re.search(r"github\.com/[^/]+/[^/]+/(pull|commit)/", url):
+        return ["patch"]
+    if "lists.apache.org" in url or "security.apache.org" in url:
+        return ["vendor-advisory"]
+    return []
+
+
+def build_references(
+    mailing_list_field: str,
+    pr_field: str,
+    extra_urls: list[str] | None = None,
+) -> list[dict]:
+    """Collect every URL from the relevant fields and emit a sorted,
+    de-duplicated CVE ``references`` list with automatic tags.
+
+    The ASF CVE tool URL (``cveprocess.apache.org``) and any
+    ``airflow-s`` URLs are filtered out before serialising -- those are
+    private tracker links and must not appear in a public CVE record.
+
+    The ``mailing_list_field`` argument is **deliberately ignored** for
+    URL extraction. The tracking-issue's "Security mailing list thread"
+    field is an internal reference that, in our flow, almost always
+    points at a non-publicly archived ``security@airflow.apache.org``
+    thread -- those URLs 404 for anyone outside the security team.
+    The field is kept in the function signature for call-site
+    compatibility; callers that have a real public advisory URL to
+    include must pass it via ``extra_urls`` (typically the
+    ``--advisory-url`` CLI flag). See the "CVE references must never
+    point at non-public mailing-list threads" section of ``AGENTS.md``
+    for the full rationale.
+    """
+    _ = mailing_list_field  # intentionally unused; see docstring
+    gathered: list[str] = list(parse_url_list(pr_field))
+    if extra_urls:
+        gathered.extend(extra_urls)
+    filtered = [url for url in gathered if "cveprocess.apache.org" not in url and "airflow-s" not in url]
+    # De-duplicate preserving order, then sort alphabetically.
+    seen: set[str] = set()
+    ordered: list[str] = []
+    for url in filtered:
+        if url not in seen:
+            seen.add(url)
+            ordered.append(url)
+    ordered.sort()
+    references: list[dict] = []
+    for url in ordered:
+        entry: dict = {"url": url}
+        tags = classify_reference(url)
+        if tags:
+            entry["tags"] = tags
+        references.append(entry)
+    return references
+
+
+# -----------------------------------------------------------------------------
+# Build the CVE 5.x CNA container dict.
+# -----------------------------------------------------------------------------
+
+
+def _product_for_package(
+    package_name: str,
+    *,
+    product_overrides: dict[str, str] | None = None,
+) -> str:
+    """Return the display product name for a project package directory name.
+
+    The mapping is driven by the loaded config (`<project-config>/tools/
+    vulnogram/cve-json-config.toml`):
+
+    * `package_name == TOP_LEVEL_NAME` → `TOP_LEVEL_PRODUCT` (e.g.
+      `apache-airflow` → `Apache Airflow`).
+    * `package_name` matches `<TOP_LEVEL_NAME>-providers-<dir>` →
+      `PROVIDER_PRODUCT_TEMPLATE.format(display=...)`, where `display`
+      is `PROVIDER_DISPLAY_MAP[<dir>]` when the directory name is
+      known, or a title-cased dash-split fallback otherwise
+      (`foo-bar` → `Foo Bar`).
+    * `product_overrides` lets callers shadow either source by package
+      name — used by the `--product-for` CLI flag for unknown
+      subpackages or acronyms that don't round-trip through `title()`.
+
+    Unknown `package_name` (neither `TOP_LEVEL_NAME` nor a provider
+    subpackage) is returned unchanged — callers that pass a
+    non-project package name are opting into bring-your-own product
+    naming.
+    """
+    if not TOP_LEVEL_NAME:
+        _populate_constants()
+    overrides = product_overrides or {}
+    if package_name in overrides:
+        return overrides[package_name]
+    if package_name == TOP_LEVEL_NAME:
+        return TOP_LEVEL_PRODUCT
+    if package_name.startswith(PROVIDER_PREFIX):
+        provider_dir = package_name[len(PROVIDER_PREFIX) :]
+        if provider_dir in PROVIDER_DISPLAY_MAP:
+            display = PROVIDER_DISPLAY_MAP[provider_dir]
+        else:
+            display = " ".join(part.title() for part in provider_dir.split("-") if part)
+        return PROVIDER_PRODUCT_TEMPLATE.format(display=display)
+    return package_name
+
+
+def _split_affected_lines(affected_versions_value: str) -> list[tuple[str | None, str]]:
+    """Split the *Affected versions* body field into per-package entries.
+
+    Each non-empty line is inspected for a leading Airflow package
+    directory name (``apache-airflow`` or
+    ``apache-airflow-providers-<dir>``); when detected, the line splits
+    into ``(package_name, version_expression)``. Lines without a
+    recognisable package prefix yield ``(None, <whole line>)`` so the
+    caller can fall back to the default product/package. Bullets
+    (``-``, ``*``, ``+``, ``1.``) are stripped so a reviewer can write
+    the field as a bulleted list.
+
+    When the input is empty, returns ``[(None, "")]`` so callers emit a
+    single placeholder ``affected[]`` entry (the historical behaviour
+    for an empty field).
+    """
+    if not affected_versions_value.strip():
+        return [(None, "")]
+    entries: list[tuple[str | None, str]] = []
+    for raw_line in affected_versions_value.splitlines():
+        line = raw_line.strip()
+        if not line:
+            continue
+        line = re.sub(r"^(?:[-*+]\s+|\d+[.)]\s+)", "", line).strip()
+        if not line:
+            continue
+        if not PACKAGE_RE.pattern:
+            _populate_constants()
+        match = PACKAGE_RE.match(line)
+        if match:
+            package = match.group("package")
+            rest = (match.group("rest") or "").strip()
+            entries.append((package, rest))
+        else:
+            entries.append((None, line))
+    if not entries:
+        return [(None, "")]
+    return entries
+
+
+def build_affected(
+    affected_versions_value: str,
+    *,
+    vendor: str,
+    product: str,
+    package_name: str,
+    collection_url: str,
+    version_start: str | None,
+    product_overrides: dict[str, str] | None = None,
+) -> list[dict]:
+    """Return one ``affected[]`` entry per detected package.
+
+    The *Affected versions* body field is parsed line by line. Each line
+    that starts with an Airflow package directory name
+    (``apache-airflow`` or ``apache-airflow-providers-<dir>``) creates
+    an entry with that package's derived product / packageName. Lines
+    without a recognisable prefix — or an empty field — fall back to
+    the explicit ``product`` / ``package_name`` arguments, which is the
+    historical single-entry shape.
+
+    ``product_overrides`` is consulted *before*
+    :data:`PROVIDER_DISPLAY_MAP` and lets callers shadow the
+    resolved product name for any specific package. It is wired to the
+    ``--product-for PACKAGE=PRODUCT`` CLI flag.
+    """
+    entries: list[dict] = []
+    for detected_package, version_expr in _split_affected_lines(affected_versions_value):
+        if detected_package is not None:
+            entry_package = detected_package
+            entry_product = _product_for_package(
+                detected_package,
+                product_overrides=product_overrides,
+            )
+            version_source = version_expr
+        else:
+            entry_package = package_name
+            entry_product = product
+            version_source = version_expr or affected_versions_value
+        entries.append(
+            {
+                "collectionURL": collection_url,
+                "defaultStatus": "unaffected",
+                "packageName": entry_package,
+                "product": entry_product,
+                "vendor": vendor,
+                "versions": parse_affected_versions(version_source, version_start),
+            }
+        )
+    return entries
+
+
+def build_descriptions(plain_text: str) -> list[dict]:
+    if not plain_text:
+        return []
+    return [
+        {
+            "lang": DEFAULT_LANG,
+            "supportingMedia": [
+                {
+                    "base64": False,
+                    "type": "text/html",
+                    "value": to_html(plain_text),
+                }
+            ],
+            "value": plain_text,
+        }
+    ]
+
+
+def build_problem_types(cwe_value: str) -> list[dict]:
+    if not cwe_value:
+        return []
+    cwe_id, description = parse_cwe(cwe_value)
+    desc: dict = {
+        "lang": DEFAULT_LANG,
+        "description": description,
+    }
+    if cwe_id:
+        desc["cweId"] = cwe_id
+        desc["type"] = "CWE"
+    return [{"descriptions": [desc]}]
+
+
+def build_metrics(severity_value: str) -> list[dict]:
+    if not severity_value:
+        return []
+    return [
+        {
+            "other": {
+                "content": {"text": normalise_severity(severity_value)},
+                "type": "Textual description of severity",
+            }
+        }
+    ]
+
+
+def build_credits(
+    credited_as_value: str,
+    *,
+    remediation_developers: list[str],
+) -> list[dict]:
+    credits: list[dict] = []
+    for name in parse_credits_from_field(credited_as_value):
+        credits.append({"lang": DEFAULT_LANG, "type": DEFAULT_CREDIT_TYPE, "value": name})
+    for name in remediation_developers:
+        cleaned = name.strip()
+        if cleaned:
+            credits.append(
+                {
+                    "lang": DEFAULT_LANG,
+                    "type": "remediation developer",
+                    "value": cleaned,
+                }
+            )
+    return credits
+
+
+def build_cna_container(
+    *,
+    title: str,
+    description: str,
+    affected_versions_value: str,
+    cwe_value: str,
+    severity_value: str,
+    credits_value: str,
+    mailing_list_value: str,
+    pr_value: str,
+    vendor: str,
+    product: str,
+    package_name: str,
+    collection_url: str,
+    org_id: str,
+    version_start: str | None,
+    discovery: str,
+    remediation_developers: list[str],
+    advisory_urls: list[str] | None = None,
+    product_overrides: dict[str, str] | None = None,
+) -> dict:
+    cna: dict = {
+        "affected": build_affected(
+            affected_versions_value,
+            vendor=vendor,
+            product=product,
+            package_name=package_name,
+            collection_url=collection_url,
+            version_start=version_start,
+            product_overrides=product_overrides,
+        ),
+        "credits": build_credits(credits_value, remediation_developers=remediation_developers),
+        "descriptions": build_descriptions(description),
+        "metrics": build_metrics(severity_value),
+        "problemTypes": build_problem_types(cwe_value),
+        "providerMetadata": {"orgId": org_id},
+        "references": build_references(mailing_list_value, pr_value, extra_urls=advisory_urls),
+        "source": {"discovery": discovery},
+        "title": title,
+        "x_generator": {"engine": GENERATOR_TAG},
+    }
+    return cna
+
+
+def _has_vendor_advisory_reference(cna: dict) -> bool:
+    """Return ``True`` when the CNA container has at least one
+    ``references[]`` entry tagged ``vendor-advisory``.
+
+    :func:`classify_reference` tags ``lists.apache.org/...`` and
+    ``security.apache.org/...`` URLs as ``vendor-advisory``
+    automatically. When the tracking issue's *"Public advisory URL"*
+    body field has been populated with the archived advisory URL —
+    i.e. the advisory has actually shipped to users@ / announce@ and
+    been archived on the public ponymail archive — that URL flows
+    into ``references[]`` and flips this check to ``True``.
+
+    That is the cue the Vulnogram workflow state should advance from
+    ``REVIEW`` to ``PUBLIC``. The result lands in
+    ``CNA_private.state`` in the emitted envelope
+    (:func:`wrap_cve_record`); ``cveMetadata.state`` is the standard
+    CVE 5.x schema field and always holds ``"PUBLISHED"``.
+    """
+    references = cna.get("references") or []
+    for ref in references:
+        tags = ref.get("tags") or []
+        if "vendor-advisory" in tags:
+            return True
+    return False
+
+
+def _is_cna_ready_for_review(cna: dict, cve_id: str) -> bool:
+    """Return ``True`` when the CNA container has everything a release
+    manager needs for the ASF CVE tool's ``REVIEW`` state.
+
+    The fields checked here correspond to the minimum a release manager
+    needs to send the public advisory: a CVE ID, a title, a non-empty
+    description, at least one affected-versions entry, a CWE in
+    ``problemTypes``, a severity in ``metrics``, at least one credit,
+    and at least one reference (the fix PR). If any of those is
+    missing, the record is a ``DRAFT`` -- still useful as the
+    paste-ready scaffold, but not yet review-ready.
+    """
+    if not cve_id:
+        return False
+    if not cna.get("title") or not str(cna.get("title")).strip():
+        return False
+    descriptions = cna.get("descriptions") or []
+    if not any((d.get("value") or "").strip() for d in descriptions):
+        return False
+    affected = cna.get("affected") or []
+    if not any(a.get("versions") for a in affected):
+        return False
+    problem_types = cna.get("problemTypes") or []
+    has_cwe = any(
+        (desc.get("cweId") or "").strip() for pt in problem_types for desc in (pt.get("descriptions") or [])
+    )
+    if not has_cwe:
+        return False
+    metrics = cna.get("metrics") or []
+    has_severity = False
+    for metric in metrics:
+        other = metric.get("other") or {}
+        content = other.get("content") or {}
+        severity_text = (content.get("text") or "").strip().lower()
+        if severity_text and severity_text not in {"unknown", "_no response_"}:
+            has_severity = True
+            break
+    if not has_severity:
+        return False
+    credits = cna.get("credits") or []
+    if not any(c.get("value") for c in credits):
+        return False
+    references = cna.get("references") or []
+    return any(r.get("url") for r in references)
+
+
+def compute_cna_private_state(cna: dict, cve_id: str) -> str:
+    """Return the ``CNA_private.state`` value (DRAFT / REVIEW / PUBLIC).
+
+    Centralises the two-step decision that :func:`wrap_cve_record` makes
+    so other code paths (the issue-body attachment table) can display
+    the same state without duplicating the logic.
+    """
+    if not _is_cna_ready_for_review(cna, cve_id):
+        return "DRAFT"
+    if _has_vendor_advisory_reference(cna):
+        return "PUBLIC"
+    return "REVIEW"
+
+
+def compute_package_url(collection_url: str, package_name: str) -> str | None:
+    """Return the canonical project-page URL for a package.
+
+    Maps the CVE ``affected[].collectionURL`` (e.g. legacy
+    ``https://pypi.python.org``) to the public project page on the
+    same registry (``https://pypi.org/project/<package>/``). Returns
+    ``None`` when the collection URL has no mapping (e.g. the
+    Helm-chart entries that point at ``airflow.apache.org`` rather
+    than a package registry); the attachment table renders ``—`` in
+    that case.
+    """
+    if not collection_url or not package_name:
+        return None
+    template = COLLECTION_URL_TO_PROJECT_URL_TEMPLATE.get(collection_url.rstrip("/"))
+    if template is None:
+        return None
+    return template.format(package=package_name)
+
+
+def format_version_range(versions: list[dict]) -> str:
+    """Render ``affected[].versions[]`` as a human-readable range string.
+
+    Inverse of :func:`parse_affected_versions`: takes the structured
+    CVE 5.x version entries and reconstructs the ``>= X, < Y`` /
+    ``<= X`` / bare-version expressions a reviewer can recognise at a
+    glance. Multiple entries (rare) are joined with ``; ``.
+    """
+    if not versions:
+        return ""
+    parts: list[str] = []
+    for entry in versions:
+        version = (entry.get("version") or "").strip()
+        less_than = (entry.get("lessThan") or "").strip()
+        less_than_or_equal = (entry.get("lessThanOrEqual") or "").strip()
+        if less_than:
+            parts.append(f">= {version}, < {less_than}" if version and version != "0" else f"< {less_than}")
+        elif less_than_or_equal:
+            parts.append(
+                f">= {version}, <= {less_than_or_equal}"
+                if version and version != "0"
+                else f"<= {less_than_or_equal}"
+            )
+        elif version:
+            parts.append(version)
+    return "; ".join(parts)
+
+
+def wrap_cve_record(cna: dict, *, cve_id: str, org_id: str) -> dict:
+    """Wrap the ``cna`` container in a CVE 5.x record envelope.
+
+    The envelope matches the real Vulnogram export shape: ``dataType``,
+    ``dataVersion``, ``containers.cna`` and a ``cveMetadata`` block
+    containing ``cveId``, ``assignerOrgId``, ``serial`` and ``state``.
+
+    The envelope carries two different ``state`` fields at different
+    levels. They track different things and must not be confused:
+
+    - ``CNA_private.state`` is the **ASF Vulnogram workflow state** —
+      an internal-to-the-CNA scratchpad that describes where the
+      record is in the tool's own review workflow
+      (``DRAFT`` → ``REVIEW`` → ``PUBLIC``). It is what the ASF CVE
+      tool UI displays and what a release manager flips as they
+      shepherd the record from staging to publication. Its value is
+      derived here from the readiness of the CNA *and* whether the
+      public advisory has actually shipped:
+
+      * ``"DRAFT"`` when any required field is missing. The JSON is
+        a paste-ready scaffold; Vulnogram will not let a reviewer
+        move it further without the missing fields.
+      * ``"REVIEW"`` when every field a release manager needs is
+        present (CVE ID, title, description, affected versions,
+        CWE, non-``Unknown`` severity, at least one credit, at
+        least one reference) **but** no public advisory URL has
+        been captured yet. This is the state the RM pastes at
+        Step 13 when sending the advisory email.
+      * ``"PUBLIC"`` when the CNA is review-ready **and** at least
+        one ``references[]`` entry is tagged ``vendor-advisory`` —
+        which is the case as soon as the tracking issue's *"Public
+        advisory URL"* body field is populated with the archived
+        users@airflow.apache.org thread URL. At that point the
+        advisory is out, the CVE record is ready to go public, and
+        the next paste into Vulnogram should land the record at
+        PUBLIC. See Steps 14 and 15 of the handling process in
+        ``README.md``.
+
+    - ``cveMetadata.state`` is the **CVE 5.x schema state** per
+      `the CVE-List schema
+      <https://github.com/CVEProject/cve-schema>`_. The only valid
+      values are ``"PUBLISHED"`` and ``"REJECTED"``; any submitted
+      record is by definition published (the CVE List's earlier
+      ``"RESERVED"`` state applies to an allocated-but-unsubmitted
+      CVE ID, not to a record envelope). The generator therefore
+      hard-codes this to ``"PUBLISHED"`` — the intent-statement
+      that cve.org reads when the record eventually flows through
+      Vulnogram.
+    """
+    record: dict = {
+        "CNA_private": {
+            "emailed": None,
+            "projecturl": "https://airflow.apache.org/",
+            "owner": "airflow",
+            "userslist": "users@airflow.apache.org",
+            "state": compute_cna_private_state(cna, cve_id),
+            "todo": [],
+            "type": "unsure",
+        },
+        "containers": {"cna": cna},
+        "dataType": "CVE_RECORD",
+        "dataVersion": "5.1",
+    }
+    cve_metadata: dict = {
+        "assignerOrgId": org_id,
+        "serial": 1,
+        "state": "PUBLISHED",
+    }
+    if cve_id:
+        cve_metadata["cveId"] = cve_id
+    record["cveMetadata"] = cve_metadata
+    return record
+
+
+# -----------------------------------------------------------------------------
+# Attach / replace the JSON as an issue comment on the tracking issue.
+#
+# GitHub issues have no native "attachment" API for automation (only the
+# drag-and-drop `user-attachments` uploads in the web UI, which aren't
+# scriptable). The next best thing is to post an issue comment that
+# contains the full JSON in a fenced code block, with a stable HTML
+# marker comment so re-runs can find the previous attachment comment and
+# edit it in place instead of posting a duplicate every time.
+# -----------------------------------------------------------------------------
+
+
+def _attachment_marker(cve_id: str) -> str:
+    """Return the begin marker. Kept for back-compat with older call sites."""
+    return _attachment_marker_begin(cve_id)
+
+
+def _attachment_marker_begin(cve_id: str) -> str:
+    return (
+        f"{ATTACHMENT_MARKER_BEGIN_PREFIX} cve={cve_id or 'UNKNOWN'} version={ATTACHMENT_MARKER_VERSION} -->"
+    )
+
+
+def _attachment_marker_end(cve_id: str) -> str:
+    return f"{ATTACHMENT_MARKER_END_PREFIX} cve={cve_id or 'UNKNOWN'} version={ATTACHMENT_MARKER_VERSION} -->"
+
+
+def _escape_table_cell(value: str) -> str:
+    """Escape characters that would break a markdown table cell."""
+    if not value:
+        return ""
+    return value.replace("|", "\\|").replace("\n", " ").strip()
+
+
+def _build_affected_table(affected: list[dict]) -> str:
+    """Return the markdown table summarising ``affected[]`` for the
+    attachment block.
+
+    One row per package (so multi-product CVEs surface every entry the
+    JSON will land in Vulnogram). Columns: package name, product,
+    versions (rendered back to ``>= X, < Y`` shape via
+    :func:`format_version_range`), and a clickable PyPI project-page
+    URL derived from ``collectionURL`` + ``packageName`` so a reader
+    can jump straight to the package without reconstructing the URL.
+    """
+    if not affected:
+        return ""
+    header = "| # | Package | Product | Versions | PyPI URL |\n| :-- | :-- | :-- | :-- | :-- |\n"
+    rows: list[str] = []
+    for index, entry in enumerate(affected, start=1):
+        package = (entry.get("packageName") or "").strip()
+        product = (entry.get("product") or "").strip()
+        versions = format_version_range(entry.get("versions") or [])
+        package_url = compute_package_url(entry.get("collectionURL") or "", package)
+        package_cell = f"`{_escape_table_cell(package)}`" if package else "—"
+        product_cell = _escape_table_cell(product) or "—"
+        versions_cell = f"`{_escape_table_cell(versions)}`" if versions else "—"
+        url_cell = f"<{package_url}>" if package_url else "—"
+        rows.append(f"| {index} | {package_cell} | {product_cell} | {versions_cell} | {url_cell} |")
+    return header + "\n".join(rows) + "\n"
+
+
+def _build_attachment_body(
+    *,
+    cve_id: str,
+    json_text: str,
+    cna: dict,
+    cna_private_state: str | None,
+) -> str:
+    """Return the markdown fragment embedded into the issue body.
+
+    The fragment starts with a ``<!-- generate-cve-json: cve=… -->``
+    begin marker and ends with a matching ``:end …`` marker, so
+    subsequent re-runs can locate and replace this exact block without
+    guessing at boundaries. The JSON itself is wrapped in a
+    ``<details>`` disclosure so a long payload does not bloat the
+    issue view, and placed inside a four-backtick fenced block so any
+    three-backtick tokens inside the JSON (unlikely, but defensive)
+    don't break the markdown parse.
+
+    The summary table above the JSON surfaces the CVE id, title, the
+    Vulnogram workflow state (``cna_private_state``), credit /
+    reference counts, and a per-package sub-table so a reader can see
+    *what* the JSON will land in Vulnogram without expanding the
+    payload.
+    """
+    begin_marker = _attachment_marker_begin(cve_id)
+    end_marker = _attachment_marker_end(cve_id)
+    byte_count = len(json_text.encode("utf-8"))
+    cve_heading = "CVE JSON — paste-ready"
+    if cve_id:
+        cve_link = f"[`{cve_id}`](https://cveprocess.apache.org/cve5/{cve_id}#source)"
+        cve_heading = f"CVE JSON — paste-ready for {cve_link}"
+    script_link = f"[`generate_cve_json.py`]({SKILL_SOURCE_URL})"
+    title = (cna.get("title") or "").strip()
+    affected = cna.get("affected") or []
+    credit_count = len(cna.get("credits") or [])
+    reference_count = len(cna.get("references") or [])
+    title_cell = _escape_table_cell(title) or "—"
+    state_cell = f"`{cna_private_state}`" if cna_private_state else "— (`--no-envelope`)"
+    package_names = [_escape_table_cell((entry.get("packageName") or "").strip()) for entry in affected]
+    package_names = [name for name in package_names if name]
+    packages_cell = ", ".join(f"`{name}`" for name in package_names) if package_names else "—"
+    metric_table = (
+        f"| Metric | Value |\n"
+        f"| :-- | :-- |\n"
+        f"| CVE ID | `{cve_id or 'UNKNOWN'}` |\n"
+        f"| Title | {title_cell} |\n"
+        f"| Vulnogram state | {state_cell} |\n"
+        f"| Affected packages | {packages_cell} |\n"
+        f"| Credits | {credit_count} |\n"
+        f"| References | {reference_count} |\n"
+        f"| Size | {byte_count} bytes |\n"
+    )
+    package_block = ""
+    affected_table = _build_affected_table(affected)
+    if affected_table:
+        package_block = f"\n**Packages this JSON covers:**\n\n{affected_table}"
+    return (
+        f"{begin_marker}\n\n"
+        f"## {cve_heading}\n\n"
+        f"Generated by {script_link} from the current state of this "
+        f"issue body. The JSON is **deterministic**: re-running the "
+        f"skill after updating the body (for example once the reporter "
+        f"confirms a credit form, or the affected version range is "
+        f"narrowed) produces a byte-for-byte identical payload.\n\n"
+        f"**Paste into Vulnogram** via the `#source` tab at the CVE tool "
+        f"URL above and click save; the form view reflects the new "
+        f"values.\n\n"
+        f"{metric_table}"
+        f"{package_block}\n"
+        f"<details>\n"
+        f"<summary>CVE 5.x JSON ({byte_count} bytes) — click to "
+        f"expand</summary>\n\n"
+        f"````json\n"
+        f"{json_text}\n"
+        f"````\n\n"
+        f"</details>\n\n"
+        f"{end_marker}\n"
+    )
+
+
+def _gh_api_json(
+    args: list[str],
+    *,
+    body_payload: dict | None = None,
+) -> dict | list:
+    """Run ``gh api`` and return the parsed JSON stdout.
+
+    When ``body_payload`` is supplied, it is serialised to a temp file
+    and passed via ``--input`` so ``gh api`` uses it as the request
+    body (this is how ``gh api`` wants long JSON bodies for ``POST`` /
+    ``PATCH`` calls -- ``-f`` URL-encodes and mangles newlines).
+    """
+    tmp_path: str | None = None
+    try:
+        if body_payload is not None:
+            with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
+                json.dump(body_payload, handle)
+                tmp_path = handle.name
+            args = [*args, "--input", tmp_path]
+        result = subprocess.run(
+            ["gh", "api", *args],
+            check=True,
+            capture_output=True,
+            text=True,
+        )
+    except FileNotFoundError as exc:
+        raise RuntimeError("`gh` CLI not found on PATH. Install it from https://cli.github.com/.") from exc
+    except subprocess.CalledProcessError as exc:
+        raise RuntimeError(f"`gh api {' '.join(args)}` failed:\n{exc.stderr.strip()}") from exc
+    finally:
+        if tmp_path is not None:
+            with contextlib.suppress(OSError):
+                os.unlink(tmp_path)
+
+    if not result.stdout.strip():
+        return {}
+    return json.loads(result.stdout)
+
+
+def _fetch_issue(repo: str, issue_number: str) -> dict:
+    """Return the full issue dict from the REST API, including ``body`` and ``html_url``."""
+    response = _gh_api_json([f"repos/{repo}/issues/{issue_number}"])
+    if not isinstance(response, dict):
+        raise RuntimeError(f"Unexpected response shape for `gh api repos/{repo}/issues/{issue_number}`")
+    return response
+
+
+def _splice_attachment_into_body(issue_body: str, attachment: str, cve_id: str) -> str:
+    """Return ``issue_body`` with the CVE-JSON attachment spliced in place.
+
+    Behaviour, in order of preference:
+
+    - If the body already contains a ``generate-cve-json`` begin/end marker
+      pair for this CVE, replace the block between them with the new
+      attachment.
+    - Otherwise, if the body contains a ``### CVE tool link`` field,
+      append the attachment *after* that field's value, i.e. right at
+      the end of the template.
+    - Otherwise, append the attachment at the very end of the body.
+
+    The body is returned with a trailing newline.
+    """
+    begin_marker = _attachment_marker_begin(cve_id)
+    end_marker = _attachment_marker_end(cve_id)
+
+    # Regex that matches begin marker ... end marker, non-greedy, across lines.
+    # The marker text itself contains characters that need escaping.
+    begin_escaped = re.escape(begin_marker)
+    end_escaped = re.escape(end_marker)
+    pattern = rf"{begin_escaped}.*?{end_escaped}\n?"
+    if re.search(pattern, issue_body, re.DOTALL):
+        new_body = re.sub(pattern, attachment.rstrip("\n") + "\n", issue_body, count=1, flags=re.DOTALL)
+        return new_body.rstrip() + "\n"
+
+    # Also tolerate a legacy single-marker attachment that was embedded
+    # without the matching end marker (shouldn't happen for our content,
+    # but defensive): drop from the begin marker to end-of-body and
+    # re-append.
+    legacy_pattern = rf"{begin_escaped}.*\Z"
+    if re.search(legacy_pattern, issue_body, re.DOTALL):
+        stripped = re.sub(legacy_pattern, "", issue_body, count=1, flags=re.DOTALL).rstrip()
+        return f"{stripped}\n\n{attachment.rstrip()}\n"
+
+    # No existing block. Try to locate the CVE-tool-link field so we can
+    # append after the whole template rather than breaking it in half.
+    # The CVE-tool-link field is the last one in the standard template,
+    # so appending after it naturally puts the attachment at the end.
+    cve_tool_link_pattern = r"^###\s+CVE tool link\s*\n+.*?(?=\n###\s|\Z)"
+    match = re.search(cve_tool_link_pattern, issue_body, re.MULTILINE | re.DOTALL)
+    if match is not None:
+        insertion_point = match.end()
+        prefix = issue_body[:insertion_point].rstrip()
+        suffix = issue_body[insertion_point:].rstrip()
+        middle = f"\n\n{attachment.rstrip()}\n"
+        if suffix:
+            return f"{prefix}{middle}\n\n{suffix}\n"
+        return f"{prefix}{middle}"
+
+    return issue_body.rstrip() + f"\n\n{attachment.rstrip()}\n"
+
+
+def attach_to_issue(
+    *,
+    issue_number: str,
+    repo: str,
+    cve_id: str,
+    json_text: str,
+    cna: dict,
+    cna_private_state: str | None,
+) -> tuple[str, bool]:
+    """Embed the CVE-JSON attachment in the issue body; update idempotently.
+
+    The attachment used to be posted as a separate issue comment, but
+    GitHub has no pin-comment API, so the comment ended up buried below
+    every status-change comment and newcomers had no idea it existed.
+    Embedding in the issue body puts it above the entire comment
+    timeline permanently without needing any pin mechanism.
+
+    Returns ``(html_url, was_update)`` where ``html_url`` is a stable
+    anchor into the issue body at the CVE-JSON section heading (GitHub
+    auto-generates heading anchors from the ``## CVE JSON — paste-ready
+    …`` text), and ``was_update`` is ``True`` when an existing
+    attachment was replaced in place, ``False`` when the attachment was
+    appended to the body for the first time.
+    """
+    attachment = _build_attachment_body(
+        cve_id=cve_id,
+        json_text=json_text,
+        cna=cna,
+        cna_private_state=cna_private_state,
+    )
+    issue = _fetch_issue(repo, issue_number)
+    current_body = issue.get("body") or ""
+    had_existing = _attachment_marker_begin(cve_id) in current_body
+    new_body = _splice_attachment_into_body(current_body, attachment, cve_id)
+
+    if new_body.rstrip() == current_body.rstrip():
+        # Byte-for-byte identical — skip the PATCH to avoid a no-op update
+        # timestamp on the issue.
+        html_url = issue.get("html_url", "")
+        return (html_url, had_existing)
+
+    _gh_api_json(
+        [
+            "-X",
+            "PATCH",
+            f"repos/{repo}/issues/{issue_number}",
+        ],
+        body_payload={"body": new_body},
+    )
+    html_url = issue.get("html_url", "")
+    # Point the returned URL at the CVE-JSON heading anchor inside the
+    # body. GitHub renders the H2 as `#cve-json--paste-ready-for-cve-...`.
+    if cve_id:
+        anchor_slug = f"cve-json--paste-ready-for-{cve_id.lower()}"
+        html_url = f"{html_url}#{anchor_slug}" if html_url else ""
+    return (html_url, had_existing)
+
+
+# -----------------------------------------------------------------------------
+# Entry point.
+# -----------------------------------------------------------------------------
+
+
+def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
+    parser = argparse.ArgumentParser(
+        description=(
+            "Generate a CVE 5.x JSON record from a tracker issue, ready "
+            "to paste into the Vulnogram #source tab of the ASF CVE tool. "
+            "Project-specific defaults (vendor, product, package map, "
+            "CNA org id, …) come from a TOML config file the adopting "
+            "project ships at `<project-config>/tools/vulnogram/"
+            "cve-json-config.toml`."
+        ),
+    )
+    parser.add_argument(
+        "--config",
+        type=Path,
+        default=None,
+        help=(
+            f"Path to the TOML config file. Defaults to "
+            f"$CVE_JSON_CONFIG or {_DEFAULT_CONFIG_RELPATH} relative to cwd."
+        ),
+    )
+    parser.add_argument(
+        "issue",
+        nargs="?",
+        help="Issue number in the tracker repo (omit when --stdin is used).",
+    )
+    parser.add_argument(
+        "--repo",
+        default=None,
+        help="GitHub repo holding the tracking issue (default from config: tracker_repo).",
+    )
+    parser.add_argument(
+        "--output",
+        type=Path,
+        help=("Write the resulting JSON to this path. When omitted the JSON is printed to stdout."),
+    )
+    parser.add_argument(
+        "--stdin",
+        action="store_true",
+        help=(
+            "Read the issue body from stdin instead of calling `gh`. Useful "
+            "for offline iteration and for the skill's test mode."
+        ),
+    )
+    parser.add_argument(
+        "--cve-id",
+        help=("Override the CVE ID. Defaults to the id parsed from the issue's `CVE tool link` field."),
+    )
+    parser.add_argument(
+        "--title",
+        help=(
+            "Override the CVE title. Defaults to the GitHub issue title "
+            "with an `Apache Airflow: ` prefix when not already present."
+        ),
+    )
+    parser.add_argument(
+        "--vendor",
+        default=None,
+        help="CVE vendor name (default from config: product.vendor).",
+    )
+    parser.add_argument(
+        "--product",
+        default=None,
+        help="CVE product name (default from config: product.default_product).",
+    )
+    parser.add_argument(
+        "--package-name",
+        default=None,
+        help="Package name (default from config: product.default_package_name).",
+    )
+    parser.add_argument(
+        "--collection-url",
+        default=None,
+        help="Package collection URL (default from config: product.default_collection_url).",
+    )
+    parser.add_argument(
+        "--product-for",
+        action="append",
+        default=[],
+        metavar="PACKAGE=PRODUCT",
+        help=(
+            "Override the CVE product name for a specific packageName, "
+            "e.g. `--product-for apache-airflow-providers-foo='Apache "
+            "Airflow Foo Provider'`. Useful when a provider is not yet "
+            "in the built-in display-name map, or when the default "
+            "title-cased fallback needs a different casing. Repeat the "
+            "flag to override multiple packages."
+        ),
+    )
+    parser.add_argument(
+        "--org-id",
+        default=None,
+        help="CNA assigner org id (default from config: cna.org_id).",
+    )
+    parser.add_argument(
+        "--version-start",
+        help=(
+            "Override the version range start (the value put in "
+            "`affected[].versions[].version`). Defaults to the lower bound "
+            "parsed from the Affected versions field when it uses "
+            "`>= X, < Y` syntax, otherwise `0`."
+        ),
+    )
+    parser.add_argument(
+        "--discovery",
+        default=DEFAULT_DISCOVERY,
+        help=(
+            f"Value for `source.discovery` (default: {DEFAULT_DISCOVERY!r}). "
+            "Valid CVE 5.x values include UNKNOWN, INTERNAL, EXTERNAL, USER."
+        ),
+    )
+    parser.add_argument(
+        "--remediation-developer",
+        action="append",
+        default=[],
+        metavar="NAME",
+        help=(
+            "Name to add to `credits[]` with type 'remediation developer'. "
+            "In normal use, the developer name lives in the issue body's "
+            "*Remediation developer* field (auto-populated by the "
+            "`sync-security-issue` skill from the linked PR's author). "
+            "This flag is an additional / override mechanism: names "
+            "passed here are appended to whatever the body already "
+            "specifies, with duplicates silently dropped. Repeat the "
+            "flag to credit multiple people."
+        ),
+    )
+    parser.add_argument(
+        "--advisory-url",
+        action="append",
+        default=[],
+        metavar="URL",
+        help=(
+            "Public advisory URL to add to `references[]` as a "
+            "`vendor-advisory` reference. Repeat the flag to add multiple "
+            "URLs. Any URL already present in the tracking-issue's "
+            "'Public advisory URL' body field is picked up automatically, "
+            "so in the normal flow the release manager populates that "
+            "field once the advisory is archived on "
+            "`users@airflow.apache.org` / `announce@apache.org` and this "
+            "flag is only needed for ad-hoc overrides. The script never "
+            "pulls URLs from the 'Security mailing list thread' field — "
+            "that field is the internal `security@airflow.apache.org` "
+            "thread, which 404s for anyone outside the security team. See "
+            "the 'CVE references must never point at non-public mailing-"
+            "list threads' section of `AGENTS.md` for the full rationale."
+        ),
+    )
+    parser.add_argument(
+        "--no-envelope",
+        action="store_true",
+        help=(
+            "Emit only the `cna` container instead of the full CVE 5.x "
+            "record. Use this if Vulnogram's #source tab is in 'inner block "
+            "only' mode."
+        ),
+    )
+    parser.add_argument(
+        "--attach",
+        action="store_true",
+        help=(
+            "After generating the JSON, embed it at the end of the "
+            "tracking issue's **body** (wrapped in a collapsible "
+            "`<details>` block). The attachment lives *in the body*, "
+            "not as a separate comment, so it stays above every "
+            "status-change comment in the timeline — effectively "
+            "pinned without needing any pin mechanism. If a previous "
+            "attachment from this script already exists in the body "
+            "(matched by a `<!-- generate-cve-json: cve=... -->` begin "
+            "marker), the block between the begin/end markers is "
+            "replaced in place — no duplicates. Requires the positional "
+            "issue argument (cannot be combined with --stdin)."
+        ),
+    )
+    return parser.parse_args(argv)
+
+
+def resolve_title(
+    issue_title: str,
+    summary: str,
+    override: str | None,
+) -> str:
+    if override:
+        title = override.strip()
+    else:
+        title = issue_title.strip()
+        if not title:
+            title = summary.split(".", 1)[0].strip() if summary else ""
+    # The CNA container is already scoped to Apache/Airflow, so the
+    # repeated "Apache Airflow:" prefix on the title is noise. Strip it
+    # (along with any trailing punctuation/whitespace) if present.
+    title = re.sub(
+        r"^\s*apache\s+airflow\s*[:\-–—]?\s*",  # noqa: RUF001 — en-dash/em-dash are deliberate regex matches
+        "",
+        title,
+        flags=re.IGNORECASE,
+    )
+    return title
+
+
+def emit_json(obj: dict, output: Path | None) -> str:
+    text = json.dumps(obj, indent=4, sort_keys=True, ensure_ascii=False)
+    if output:
+        output.parent.mkdir(parents=True, exist_ok=True)
+        output.write_text(text + "\n", encoding="utf-8")
+    return text
+
+
+def main(argv: list[str] | None = None) -> int:
+    args = parse_args(argv)
+
+    # Load config (CLI flag overrides env var overrides default path).
+    try:
+        if args.config is not None:
+            _set_config_path(args.config)
+        else:
+            _populate_constants()
+    except FileNotFoundError as exc:
+        print(f"error: {exc}", file=sys.stderr)
+        return 2
+
+    # Apply config-derived defaults to args that defaulted to None.
+    if args.repo is None:
+        args.repo = DEFAULT_REPO
+    if args.vendor is None:
+        args.vendor = DEFAULT_VENDOR
+    if args.product is None:
+        args.product = DEFAULT_PRODUCT
+    if args.package_name is None:
+        args.package_name = DEFAULT_PACKAGE_NAME
+    if args.collection_url is None:
+        args.collection_url = DEFAULT_COLLECTION_URL
+    if args.org_id is None:
+        args.org_id = DEFAULT_ASF_ORG_ID
+
+    if args.attach and args.stdin:
+        print(
+            "error: --attach cannot be combined with --stdin (there is no real issue to attach to)",
+            file=sys.stderr,
+        )
+        return 2
+    if args.attach and not args.issue:
+        print(
+            "error: --attach requires the positional issue argument",
+            file=sys.stderr,
+        )
+        return 2
+
+    if args.stdin:
+        body = sys.stdin.read()
+        issue_title = args.title or ""
+    else:
+        if not args.issue:
+            print(
+                "error: issue number is required unless --stdin is used",
+                file=sys.stderr,
+            )
+            return 2
+        try:
+            issue_title, body = fetch_issue(args.issue, args.repo)
+        except RuntimeError as exc:
+            print(f"error: {exc}", file=sys.stderr)
+            return 1
+
+    summary = extract_field(body, "Short public summary for publish")
+    affected_field = extract_field(body, "Affected versions")
+    mailing_list = extract_field(body, "Security mailing list thread")
+    public_advisory_field = extract_field(body, "Public advisory URL")
+    credited_as = extract_field(body, "Reporter credited as")
+    pr_field = extract_field(body, "PR with the fix")
+    remediation_developer_field = extract_field(body, "Remediation developer")
+    cwe = extract_field(body, "CWE")
+    severity = extract_field(body, "Severity")
+    cve_tool = extract_field(body, "CVE tool link")
+
+    cve_id = args.cve_id or parse_cve_id(cve_tool)
+    title = resolve_title(issue_title, summary, args.title)
+    description = summary or title
+
+    # Advisory URLs come from two sources: the body's "Public advisory URL"
+    # field (populated by the release manager / sync-security-issue skill
+    # once the advisory is archived on users@airflow.apache.org) and any
+    # --advisory-url CLI overrides. Both are forwarded to the references
+    # builder; de-duplication happens there.
+    body_advisory_urls = parse_url_list(public_advisory_field)
+    combined_advisory_urls = [*body_advisory_urls, *args.advisory_url]
+
+    combined_remediation_developers = combine_remediation_developers(
+        remediation_developer_field,
+        args.remediation_developer,
+    )
+
+    product_overrides: dict[str, str] = {}
+    for item in args.product_for:
+        if "=" not in item:
+            print(
+                f"error: --product-for expects PACKAGE=PRODUCT, got {item!r}",
+                file=sys.stderr,
+            )
+            return 2
+        pkg, _, display = item.partition("=")
+        pkg = pkg.strip()
+        display = display.strip()
+        if not pkg or not display:
+            print(
+                f"error: --product-for expects non-empty PACKAGE and PRODUCT, got {item!r}",
+                file=sys.stderr,
+            )
+            return 2
+        product_overrides[pkg] = display
+
+    cna = build_cna_container(
+        title=title,
+        description=description,
+        affected_versions_value=affected_field,
+        cwe_value=cwe,
+        severity_value=severity,
+        credits_value=credited_as,
+        mailing_list_value=mailing_list,
+        pr_value=pr_field,
+        vendor=args.vendor,
+        product=args.product,
+        package_name=args.package_name,
+        collection_url=args.collection_url,
+        org_id=args.org_id,
+        version_start=args.version_start,
+        discovery=args.discovery,
+        remediation_developers=combined_remediation_developers,
+        advisory_urls=combined_advisory_urls,
+        product_overrides=product_overrides,
+    )
+
+    if args.no_envelope:
+        payload: dict = cna
+    else:
+        payload = wrap_cve_record(cna, cve_id=cve_id, org_id=args.org_id)
+
+    text = emit_json(payload, args.output)
+    if args.output is None:
+        sys.stdout.write(text + "\n")
+    else:
+        print(f"Wrote {len(text.encode('utf-8'))} bytes to {args.output}")
+        if cve_id:
+            print(
+                "Open the Vulnogram #source tab and paste the file:\n"
+                f"  https://cveprocess.apache.org/cve5/{cve_id}#source"
+            )
+
+    if args.attach:
+        try:
+            attachment_url, was_update = attach_to_issue(
+                issue_number=args.issue,
+                repo=args.repo,
+                cve_id=cve_id,
+                json_text=text,
+                cna=cna,
+                cna_private_state=None if args.no_envelope else compute_cna_private_state(cna, cve_id),
+            )
+        except RuntimeError as exc:
+            print(f"error: {exc}", file=sys.stderr)
+            return 1
+        action = "Replaced" if was_update else "Embedded"
+        print(f"{action} CVE JSON in issue body on {args.repo}#{args.issue}")
+        if attachment_url:
+            print(f"  {attachment_url}")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/tools/vulnogram/generate-cve-json/tests/__init__.py b/tools/vulnogram/generate-cve-json/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tools/vulnogram/generate-cve-json/tests/conftest.py b/tools/vulnogram/generate-cve-json/tests/conftest.py
new file mode 100644
index 0000000..e40c8d7
--- /dev/null
+++ b/tools/vulnogram/generate-cve-json/tests/conftest.py
@@ -0,0 +1,42 @@
+# 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.
+"""Pytest conftest for generate-cve-json.
+
+The Python tool loads all project-specific values from a TOML config
+the adopting project ships at:
+
+  <adopter-tracker>/.apache-steward/tools/vulnogram/cve-json-config.toml
+
+The tool reads that config relative to ``cwd`` (or via the
+``CVE_JSON_CONFIG`` environment variable / the ``--config`` CLI flag).
+
+For the framework's own pytest suite (run from this repository, no
+adopter present), we point ``CVE_JSON_CONFIG`` at the test fixture at
+``tests/fixtures/cve-json-config.toml``. The fixture mirrors one
+adopter's configuration so the existing tests keep their assertions;
+**it is not the framework's default configuration** — adopters write
+their own per the schema in the package README.
+"""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+_FIXTURE_CONFIG = Path(__file__).resolve().parent / "fixtures" / "cve-json-config.toml"
+assert _FIXTURE_CONFIG.exists(), f"missing test fixture: {_FIXTURE_CONFIG}"
+os.environ.setdefault("CVE_JSON_CONFIG", str(_FIXTURE_CONFIG))
diff --git a/tools/vulnogram/generate-cve-json/tests/fixtures/cve-json-config.toml b/tools/vulnogram/generate-cve-json/tests/fixtures/cve-json-config.toml
new file mode 100644
index 0000000..b804b94
--- /dev/null
+++ b/tools/vulnogram/generate-cve-json/tests/fixtures/cve-json-config.toml
@@ -0,0 +1,179 @@
+# 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.
+
+# generate-cve-json — TEST FIXTURE config.
+#
+# This file is a fixture for the tool's own pytest suite, NOT the
+# framework's default configuration. It carries values that match the
+# Apache Airflow security team's adopter-side config because the
+# tests in `test_generate_cve_json.py` were written against that
+# specific configuration shape (Airflow-specific package names,
+# provider display map, etc.).
+#
+# Adopting projects MUST NOT copy this file. Write your own
+# `cve-json-config.toml` from scratch using the schema documented in
+# the package README. The values here are illustrative of one
+# specific adopter's configuration; nothing in the framework treats
+# them as defaults.
+
+[product]
+# CVE 5.x `affected[].vendor` — the CNA vendor name.
+vendor = "Apache Software Foundation"
+
+# Top-level product display name and package name. Used as the default
+# `product` / `packageName` for *Affected versions* lines that don't
+# match a more specific entry in `[packages]` below.
+default_product = "Apache Airflow"
+default_package_name = "apache-airflow"
+
+# Where the package is distributed (`affected[].collectionURL`).
+default_collection_url = "https://pypi.python.org"
+
+[cna]
+# CNA assigner UUID (CVE 5.x `cveMetadata.assignerOrgId` /
+# `providerMetadata.orgId`). This is the ASF org id.
+org_id = "f0158376-9dc2-43b6-827c-5f631a4d8d09"
+
+[meta]
+# Tracker repo slug (org/name). Used for the `x_generator.engine`
+# tag in the CVE record and for the self-source-link below.
+tracker_repo = "airflow-s/airflow-s"
+
+# Stable identifier embedded in the CVE record's `x_generator.engine`
+# field so a reader can identify which tool produced the JSON.
+generator_tag = "airflow-s/generate_cve_json.py"
+
+# Self-source URL the script can self-document with (e.g. in error
+# messages, on the `_print_skill_link` line of the generated comment).
+# Optional: defaults to f"https://github.com/{tracker_repo}".
+skill_source_url = "https://github.com/airflow-s/airflow-s/tree/airflow-s/tools/vulnogram/generate-cve-json"
+
+[packages]
+# Regex matching this project's package names. Required named groups:
+#   `package`   — the full package name as shipped on PyPI (whatever
+#                 collection_url declares).
+#   `provider`  — optional; the subpackage / provider directory name
+#                 used to look up a display name in
+#                 `provider_display_map` below. May be empty when the
+#                 line names the top-level package only.
+#   `rest`      — optional; everything after the package name, used
+#                 by the script to consume the trailing version-range
+#                 expression.
+package_pattern = '^(?P<package>apache-airflow(?:-providers-(?P<provider>[a-z0-9][a-z0-9_-]*))?)(?:\s+(?P<rest>.*))?$'
+
+# Top-level package name + product. When the matched `package_pattern`
+# yields just the top-level name (no `provider` group), the product is
+# `top_level_product`. When `provider` is present, the product is
+# `provider_product_template` with `{display}` substituted from
+# `provider_display_map` (or a title-cased fallback).
+top_level_name = "apache-airflow"
+top_level_product = "Apache Airflow"
+provider_product_template = "Apache Airflow Providers {display}"
+
+# Provider directory-name → vendor-preferred display casing for the
+# CVE `product` field. Provider directory names are lowercase
+# (`elasticsearch`, `cncf-kubernetes`); CVE product names follow
+# vendor-preferred casing (`Elasticsearch`, `CNCF Kubernetes`).
+# Extend this table when a new provider appears in a CVE; unknown
+# providers fall back to a title-cased dash-split of the directory
+# name, which is correct for most single-word providers but may need
+# an entry here for acronyms.
+[packages.provider_display_map]
+"cncf-kubernetes" = "CNCF Kubernetes"
+"elasticsearch" = "Elasticsearch"
+"opensearch" = "OpenSearch"
+"smtp" = "SMTP"
+"ssh" = "SSH"
+"ftp" = "FTP"
+"http" = "HTTP"
+"imap" = "IMAP"
+"openfaas" = "OpenFaaS"
+"openlineage" = "OpenLineage"
+"mysql" = "MySQL"
+"postgres" = "PostgreSQL"
+"sqlite" = "SQLite"
+"odbc" = "ODBC"
+"jdbc" = "JDBC"
+"sftp" = "SFTP"
+"amazon" = "Amazon"
+"google" = "Google"
+"microsoft-azure" = "Microsoft Azure"
+"microsoft-mssql" = "Microsoft SQL Server"
+"microsoft-winrm" = "Microsoft WinRM"
+"apache-beam" = "Apache Beam"
+"apache-cassandra" = "Apache Cassandra"
+"apache-drill" = "Apache Drill"
+"apache-druid" = "Apache Druid"
+"apache-flink" = "Apache Flink"
+"apache-hdfs" = "Apache HDFS"
+"apache-hive" = "Apache Hive"
+"apache-impala" = "Apache Impala"
+"apache-kafka" = "Apache Kafka"
+"apache-kylin" = "Apache Kylin"
+"apache-livy" = "Apache Livy"
+"apache-pig" = "Apache Pig"
+"apache-pinot" = "Apache Pinot"
+"apache-spark" = "Apache Spark"
+"apache-tinkerpop" = "Apache TinkerPop"
+"celery" = "Celery"
+"cohere" = "Cohere"
+"common-compat" = "Common Compat"
+"common-io" = "Common IO"
+"common-messaging" = "Common Messaging"
+"common-sql" = "Common SQL"
+"databricks" = "Databricks"
+"datadog" = "Datadog"
+"dbt-cloud" = "dbt Cloud"
+"dingding" = "DingTalk"
+"discord" = "Discord"
+"docker" = "Docker"
+"edge3" = "Edge3"
+"exasol" = "Exasol"
+"fab" = "FAB"
+"facebook" = "Facebook"
+"git" = "Git"
+"github" = "GitHub"
+"grpc" = "gRPC"
+"hashicorp" = "HashiCorp"
+"jenkins" = "Jenkins"
+"mongo" = "MongoDB"
+"neo4j" = "Neo4j"
+"openai" = "OpenAI"
+"oracle" = "Oracle"
+"pagerduty" = "PagerDuty"
+"papermill" = "Papermill"
+"pgvector" = "pgvector"
+"pinecone" = "Pinecone"
+"qdrant" = "Qdrant"
+"redis" = "Redis"
+"salesforce" = "Salesforce"
+"samba" = "Samba"
+"segment" = "Segment"
+"sendgrid" = "SendGrid"
+"singularity" = "Singularity"
+"slack" = "Slack"
+"snowflake" = "Snowflake"
+"standard" = "Standard"
+"tableau" = "Tableau"
+"telegram" = "Telegram"
+"teradata" = "Teradata"
+"trino" = "Trino"
+"vertica" = "Vertica"
+"weaviate" = "Weaviate"
+"yandex" = "Yandex"
+"ydb" = "YDB"
+"zendesk" = "Zendesk"
diff --git a/tools/vulnogram/generate-cve-json/tests/test_generate_cve_json.py b/tools/vulnogram/generate-cve-json/tests/test_generate_cve_json.py
new file mode 100644
index 0000000..19116aa
--- /dev/null
+++ b/tools/vulnogram/generate-cve-json/tests/test_generate_cve_json.py
@@ -0,0 +1,961 @@
+# 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.
+"""Unit tests for pure helpers in ``generate_cve_json``.
+
+Everything that shells out to ``gh`` or touches the network lives in
+``fetch_issue``, ``attach_to_issue``, ``_gh_api_json``, and
+``_find_existing_attachment_comment_id``. Those are excluded from this
+test suite; they can be exercised against a live issue by running the
+CLI end-to-end.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+from generate_cve_json import (
+    _build_attachment_body,
+    _is_cna_ready_for_review,
+    _product_for_package,
+    build_affected,
+    build_credits,
+    build_metrics,
+    build_problem_types,
+    build_references,
+    classify_reference,
+    combine_remediation_developers,
+    compute_cna_private_state,
+    compute_package_url,
+    extract_field,
+    format_version_range,
+    parse_affected_versions,
+    parse_credits_from_field,
+    parse_cve_id,
+    parse_cwe,
+    parse_url_list,
+    resolve_title,
+    wrap_cve_record,
+)
+
+DEFAULT_AFFECTED_ARGS: dict[str, Any] = {
+    "vendor": "Apache Software Foundation",
+    "product": "Apache Airflow",
+    "package_name": "apache-airflow",
+    "collection_url": "https://pypi.python.org",
+    "version_start": None,
+}
+
+# ---------------------------------------------------------------------------
+# Issue body field extraction
+# ---------------------------------------------------------------------------
+
+
+class TestExtractField:
+    def test_returns_value_up_to_next_field(self):
+        body = (
+            "### The issue description\nSummary text.\n\n### Affected versions\n>=3.0.0\n\n### CWE\nCWE-352\n"
+        )
+        assert extract_field(body, "Affected versions") == ">=3.0.0"
+        assert extract_field(body, "CWE") == "CWE-352"
+
+    def test_missing_field_returns_empty_string(self):
+        body = "### Short public summary for publish\n\nHello.\n"
+        assert extract_field(body, "CVE tool link") == ""
+
+    def test_no_response_placeholder_is_normalised_to_empty(self):
+        body = "### Severity\n\n_No response_\n"
+        assert extract_field(body, "Severity") == ""
+
+    def test_trailing_whitespace_is_stripped(self):
+        body = "### Short public summary for publish\n\nHello.   \n\n### Affected versions\n\n\n"
+        assert extract_field(body, "Short public summary for publish") == "Hello."
+
+    def test_public_advisory_url_field_is_separate_from_security_mailing_list_thread(self):
+        body = (
+            "### Security mailing list thread\n\n"
+            "https://lists.apache.org/thread/fake-security-private-hash\n\n"
+            "### Public advisory URL\n\n"
+            "https://lists.apache.org/thread/real-users-archive-id?users@airflow.apache.org\n\n"
+            "### Reporter credited as\n\nAlice\n"
+        )
+        # The two fields are addressed independently by name; the private
+        # security@ URL stays in the body but is never exported.
+        assert (
+            extract_field(body, "Security mailing list thread")
+            == "https://lists.apache.org/thread/fake-security-private-hash"
+        )
+        assert (
+            extract_field(body, "Public advisory URL")
+            == "https://lists.apache.org/thread/real-users-archive-id?users@airflow.apache.org"
+        )
+
+
+# ---------------------------------------------------------------------------
+# Credit parsing
+# ---------------------------------------------------------------------------
+
+
+class TestParseCreditsFromField:
+    def test_newline_separated_credits_produce_multiple_entries(self):
+        value = "Alice Smith\nBob Jones (Acme Corp)"
+        assert parse_credits_from_field(value) == ["Alice Smith", "Bob Jones (Acme Corp)"]
+
+    def test_comma_inside_a_credit_stays_in_the_same_credit(self):
+        value = "Jane Doe, Acme Security"
+        # Comma-splitting is deliberately off: "Name, Affiliation" is ONE credit.
+        assert parse_credits_from_field(value) == ["Jane Doe, Acme Security"]
+
+    def test_bullets_are_stripped(self):
+        value = "- Alice\n* Bob\n+ Charlie\n1. Diana"
+        assert parse_credits_from_field(value) == ["Alice", "Bob", "Charlie", "Diana"]
+
+    def test_duplicates_are_removed_preserving_order(self):
+        value = "Alice\nBob\nAlice"
+        assert parse_credits_from_field(value) == ["Alice", "Bob"]
+
+    def test_empty_field_returns_empty_list(self):
+        assert parse_credits_from_field("") == []
+
+
+# ---------------------------------------------------------------------------
+# URL list parsing
+# ---------------------------------------------------------------------------
+
+
+class TestParseUrlList:
+    def test_multiple_urls_extracted(self):
+        value = (
+            "First:\nhttps://github.com/apache/airflow/pull/64114\n"
+            "Second: https://github.com/apache/airflow/pull/65346"
+        )
+        urls = parse_url_list(value)
+        assert urls == [
+            "https://github.com/apache/airflow/pull/64114",
+            "https://github.com/apache/airflow/pull/65346",
+        ]
+
+    def test_bare_text_with_no_urls_returns_empty(self):
+        assert parse_url_list("No links here.") == []
+
+
+# ---------------------------------------------------------------------------
+# CVE ID / CWE parsing
+# ---------------------------------------------------------------------------
+
+
+class TestParseCveId:
+    def test_extracts_cve_from_asf_tool_link(self):
+        assert parse_cve_id("https://cveprocess.apache.org/cve5/CVE-2026-40948") == "CVE-2026-40948"
+
+    def test_extracts_cve_from_plain_id(self):
+        assert parse_cve_id("CVE-2026-40948") == "CVE-2026-40948"
+
+    def test_missing_returns_empty(self):
+        assert parse_cve_id("") == ""
+
+
+class TestParseCwe:
+    def test_cwe_with_title(self):
+        cwe_id, description = parse_cwe("CWE-352: Cross-Site Request Forgery (CSRF)")
+        assert cwe_id == "CWE-352"
+        assert description == "CWE-352: Cross-Site Request Forgery (CSRF)"
+
+    def test_cwe_without_title_emits_bare_id_as_description(self):
+        cwe_id, description = parse_cwe("CWE-614")
+        assert cwe_id == "CWE-614"
+        assert description == "CWE-614"
+
+    def test_no_cwe_keeps_original_text_as_description(self):
+        cwe_id, description = parse_cwe("_No response_")
+        assert cwe_id == ""
+        assert description == "_No response_"
+
+
+# ---------------------------------------------------------------------------
+# Affected-versions parsing
+# ---------------------------------------------------------------------------
+
+
+class TestParseAffectedVersions:
+    def test_bare_less_than_creates_range(self):
+        versions = parse_affected_versions("<3.2.0", None)
+        assert versions == [
+            {"lessThan": "3.2.0", "status": "affected", "version": "0", "versionType": "semver"},
+        ]
+
+    def test_inclusive_range(self):
+        versions = parse_affected_versions(">=3.0.0, <3.2.0", None)
+        assert versions == [
+            {"lessThan": "3.2.0", "status": "affected", "version": "3.0.0", "versionType": "semver"},
+        ]
+
+    def test_less_or_equal_upper_bound(self):
+        versions = parse_affected_versions("<=6.5.0", None)
+        assert versions == [
+            {
+                "lessThanOrEqual": "6.5.0",
+                "status": "affected",
+                "version": "0",
+                "versionType": "semver",
+            },
+        ]
+
+    def test_version_start_override_sets_low_bound(self):
+        versions = parse_affected_versions(">=3.0.0, <3.2.0", "2.10.5")
+        assert versions[0]["version"] == "2.10.5"
+
+    def test_unknown_string_falls_back_to_single_entry(self):
+        versions = parse_affected_versions("all versions", None)
+        assert versions
+        assert versions[0]["status"] == "affected"
+
+    def test_lower_bound_only_emits_open_ended_entry(self):
+        # `>=2.0.0` (no upper) — useful for trackers whose fix-shipped
+        # version isn't known yet (typically providers).
+        versions = parse_affected_versions(">=2.0.0", None)
+        assert versions == [
+            {"status": "affected", "version": "2.0.0", "versionType": "semver"},
+        ]
+
+    def test_lower_bound_only_with_space(self):
+        versions = parse_affected_versions(">= 2.0.0", None)
+        assert versions == [
+            {"status": "affected", "version": "2.0.0", "versionType": "semver"},
+        ]
+
+
+class TestParseAffectedVersionsNextVersionPlaceholder:
+    """The `< NEXT VERSION` sentinel: fix not yet released, upper bound unknown.
+
+    Stripped before further parsing; resulting entry has no `lessThan`.
+    Used predominantly for providers trackers where the wave milestone
+    is date-based and the package version that ships the fix is decided
+    by the release manager during the wave.
+    """
+
+    def test_just_next_version_placeholder(self):
+        versions = parse_affected_versions("< NEXT VERSION", None)
+        assert versions == [
+            {"status": "affected", "version": "0", "versionType": "semver"},
+        ]
+
+    def test_lowercase_token_also_accepted(self):
+        versions = parse_affected_versions("< next version", None)
+        assert versions == [
+            {"status": "affected", "version": "0", "versionType": "semver"},
+        ]
+
+    def test_lower_bound_with_next_version_upper(self):
+        versions = parse_affected_versions(">= 2.0.0 < NEXT VERSION", None)
+        assert versions == [
+            {"status": "affected", "version": "2.0.0", "versionType": "semver"},
+        ]
+
+    def test_lower_bound_with_comma_and_next_version_upper(self):
+        versions = parse_affected_versions(">= 2.0.0, < NEXT VERSION", None)
+        assert versions == [
+            {"status": "affected", "version": "2.0.0", "versionType": "semver"},
+        ]
+
+    def test_real_version_replacing_placeholder_round_trips(self):
+        # Once the release manager knows the fix-shipped version, the
+        # sync skill replaces `< NEXT VERSION` with `< X.Y.Z`. The
+        # parser must produce the standard fully-bounded shape.
+        versions = parse_affected_versions(">= 2.0.0, < 5.6.0", None)
+        assert versions == [
+            {"status": "affected", "version": "2.0.0", "lessThan": "5.6.0", "versionType": "semver"},
+        ]
+
+
+# ---------------------------------------------------------------------------
+# Product-name resolution for Airflow packages
+# ---------------------------------------------------------------------------
+
+
+class TestProductForPackage:
+    def test_core_package_resolves_to_apache_airflow(self):
+        assert _product_for_package("apache-airflow") == "Apache Airflow"
+
+    def test_known_provider_uses_display_map_casing(self):
+        assert (
+            _product_for_package("apache-airflow-providers-elasticsearch")
+            == "Apache Airflow Providers Elasticsearch"
+        )
+        assert (
+            _product_for_package("apache-airflow-providers-opensearch")
+            == "Apache Airflow Providers OpenSearch"
+        )
+        assert (
+            _product_for_package("apache-airflow-providers-cncf-kubernetes")
+            == "Apache Airflow Providers CNCF Kubernetes"
+        )
+        # Confirms the user-cited mapping example from the Vulnogram form:
+        #   apache-airflow-providers-snowflake → "Apache Airflow Providers Snowflake".
+        assert (
+            _product_for_package("apache-airflow-providers-snowflake") == "Apache Airflow Providers Snowflake"
+        )
+
+    def test_unknown_provider_falls_back_to_title_case(self):
+        assert (
+            _product_for_package("apache-airflow-providers-madeup-widget")
+            == "Apache Airflow Providers Madeup Widget"
+        )
+
+    def test_overrides_win_over_display_map(self):
+        assert (
+            _product_for_package(
+                "apache-airflow-providers-elasticsearch",
+                product_overrides={
+                    "apache-airflow-providers-elasticsearch": "Custom ES Display",
+                },
+            )
+            == "Custom ES Display"
+        )
+
+    def test_overrides_win_over_title_case_fallback(self):
+        assert (
+            _product_for_package(
+                "apache-airflow-providers-madeup-widget",
+                product_overrides={
+                    "apache-airflow-providers-madeup-widget": "Apache Airflow Providers WIDGET",
+                },
+            )
+            == "Apache Airflow Providers WIDGET"
+        )
+
+
+# ---------------------------------------------------------------------------
+# Multi-product `build_affected`
+# ---------------------------------------------------------------------------
+
+
+class TestBuildAffectedSingleProduct:
+    def test_empty_field_emits_one_placeholder_entry(self):
+        entries = build_affected("", **DEFAULT_AFFECTED_ARGS)
+        assert len(entries) == 1
+        assert entries[0]["packageName"] == "apache-airflow"
+        assert entries[0]["product"] == "Apache Airflow"
+
+    def test_bare_version_range_uses_defaults(self):
+        entries = build_affected("<3.2.2", **DEFAULT_AFFECTED_ARGS)
+        assert len(entries) == 1
+        assert entries[0]["packageName"] == "apache-airflow"
+        assert entries[0]["product"] == "Apache Airflow"
+        assert entries[0]["versions"][0]["lessThan"] == "3.2.2"
+
+    def test_single_line_with_package_prefix_detects_provider(self):
+        entries = build_affected(
+            "apache-airflow-providers-elasticsearch <=6.5.0",
+            **DEFAULT_AFFECTED_ARGS,
+        )
+        assert len(entries) == 1
+        assert entries[0]["packageName"] == "apache-airflow-providers-elasticsearch"
+        assert entries[0]["product"] == "Apache Airflow Providers Elasticsearch"
+        assert entries[0]["versions"][0]["lessThanOrEqual"] == "6.5.0"
+
+    def test_single_line_with_core_package_prefix_detects_core(self):
+        entries = build_affected(
+            "apache-airflow <3.2.2",
+            vendor="Apache Software Foundation",
+            product="IGNORED",
+            package_name="ignored",
+            collection_url="https://pypi.python.org",
+            version_start=None,
+        )
+        assert len(entries) == 1
+        # Package prefix wins over the default argument because the body
+        # has said the package name explicitly.
+        assert entries[0]["packageName"] == "apache-airflow"
+        assert entries[0]["product"] == "Apache Airflow"
+
+
+class TestBuildAffectedMultiProduct:
+    def test_two_providers_produce_two_entries(self):
+        entries = build_affected(
+            "apache-airflow-providers-elasticsearch <=6.5.0\napache-airflow-providers-opensearch <=1.9.0",
+            **DEFAULT_AFFECTED_ARGS,
+        )
+        assert len(entries) == 2
+        es, os_ = entries
+        assert es["packageName"] == "apache-airflow-providers-elasticsearch"
+        assert es["product"] == "Apache Airflow Providers Elasticsearch"
+        assert es["versions"][0]["lessThanOrEqual"] == "6.5.0"
+        assert os_["packageName"] == "apache-airflow-providers-opensearch"
+        assert os_["product"] == "Apache Airflow Providers OpenSearch"
+        assert os_["versions"][0]["lessThanOrEqual"] == "1.9.0"
+
+    def test_bullet_prefixes_are_stripped(self):
+        entries = build_affected(
+            "- apache-airflow-providers-elasticsearch <=6.5.0\n* apache-airflow-providers-opensearch <=1.9.0",
+            **DEFAULT_AFFECTED_ARGS,
+        )
+        assert [e["packageName"] for e in entries] == [
+            "apache-airflow-providers-elasticsearch",
+            "apache-airflow-providers-opensearch",
+        ]
+
+    def test_blank_lines_between_entries_are_ignored(self):
+        entries = build_affected(
+            "apache-airflow-providers-elasticsearch <=6.5.0\n\napache-airflow-providers-opensearch <=1.9.0\n",
+            **DEFAULT_AFFECTED_ARGS,
+        )
+        assert len(entries) == 2
+
+    def test_mixed_known_and_unknown_provider(self):
+        entries = build_affected(
+            "apache-airflow-providers-elasticsearch <=6.5.0\napache-airflow-providers-brand-new <=0.1.0",
+            **DEFAULT_AFFECTED_ARGS,
+        )
+        assert entries[0]["product"] == "Apache Airflow Providers Elasticsearch"
+        assert entries[1]["product"] == "Apache Airflow Providers Brand New"
+
+    def test_product_overrides_applied_per_entry(self):
+        entries = build_affected(
+            "apache-airflow-providers-brand-new <=0.1.0",
+            product_overrides={
+                "apache-airflow-providers-brand-new": "Apache Airflow Providers BRAND",
+            },
+            **DEFAULT_AFFECTED_ARGS,
+        )
+        assert entries[0]["product"] == "Apache Airflow Providers BRAND"
+
+    def test_line_without_prefix_falls_back_to_defaults(self):
+        # A single line that is not a version range and doesn't carry a
+        # recognisable package prefix stays in the legacy single-entry
+        # path and takes the explicit product / packageName args.
+        entries = build_affected(
+            "all versions",
+            vendor="Apache Software Foundation",
+            product="Apache Airflow Helm Chart",
+            package_name="apache-airflow-helm-chart",
+            collection_url="https://airflow.apache.org/",
+            version_start=None,
+        )
+        assert len(entries) == 1
+        assert entries[0]["packageName"] == "apache-airflow-helm-chart"
+        assert entries[0]["product"] == "Apache Airflow Helm Chart"
+
+    def test_multi_line_is_deterministic(self):
+        # Re-generating must produce byte-identical output, which is
+        # what keeps the `--attach` idempotence guarantee intact.
+        first = build_affected(
+            "apache-airflow-providers-elasticsearch <=6.5.0\napache-airflow-providers-opensearch <=1.9.0",
+            **DEFAULT_AFFECTED_ARGS,
+        )
+        second = build_affected(
+            "apache-airflow-providers-elasticsearch <=6.5.0\napache-airflow-providers-opensearch <=1.9.0",
+            **DEFAULT_AFFECTED_ARGS,
+        )
+        assert first == second
+
+    def test_per_line_next_version_placeholder(self):
+        # The providers-tracker pattern: one line per affected package,
+        # all with `< NEXT VERSION` until the wave ships.
+        entries = build_affected(
+            "apache-airflow-providers-elasticsearch < NEXT VERSION\n"
+            "apache-airflow-providers-opensearch < NEXT VERSION",
+            **DEFAULT_AFFECTED_ARGS,
+        )
+        assert len(entries) == 2
+        es, os_ = entries
+        assert es["packageName"] == "apache-airflow-providers-elasticsearch"
+        assert es["versions"] == [
+            {"status": "affected", "version": "0", "versionType": "semver"},
+        ]
+        assert os_["packageName"] == "apache-airflow-providers-opensearch"
+        assert os_["versions"] == [
+            {"status": "affected", "version": "0", "versionType": "semver"},
+        ]
+
+    def test_per_line_lower_bound_only(self):
+        # Lower-bound-only line (e.g. `apache-airflow-providers-smtp >=2.0.0`)
+        # — useful when affected versions are known to start at X but the
+        # fix-shipped version isn't known yet.
+        entries = build_affected(
+            "apache-airflow-providers-smtp >=2.0.0",
+            **DEFAULT_AFFECTED_ARGS,
+        )
+        assert len(entries) == 1
+        assert entries[0]["packageName"] == "apache-airflow-providers-smtp"
+        assert entries[0]["versions"] == [
+            {"status": "affected", "version": "2.0.0", "versionType": "semver"},
+        ]
+
+    def test_per_line_lower_bound_with_next_version_upper(self):
+        entries = build_affected(
+            "apache-airflow-providers-smtp >= 2.0.0, < NEXT VERSION",
+            **DEFAULT_AFFECTED_ARGS,
+        )
+        assert len(entries) == 1
+        assert entries[0]["versions"] == [
+            {"status": "affected", "version": "2.0.0", "versionType": "semver"},
+        ]
+
+
+# ---------------------------------------------------------------------------
+# Reference tagging
+# ---------------------------------------------------------------------------
+
+
+class TestClassifyReference:
+    def test_github_pr_tagged_as_patch(self):
+        assert classify_reference("https://github.com/apache/airflow/pull/64114") == ["patch"]
+
+    def test_github_commit_tagged_as_patch(self):
+        assert classify_reference("https://github.com/apache/airflow/commit/abc123") == ["patch"]
+
+    def test_lists_apache_tagged_as_vendor_advisory(self):
+        assert classify_reference("https://lists.apache.org/thread/abc") == ["vendor-advisory"]
+
+    def test_plain_doc_url_has_no_tags(self):
+        assert classify_reference("https://airflow.apache.org/docs/") == []
+
+
+class TestBuildReferences:
+    def test_mailing_list_field_urls_are_not_auto_included(self):
+        refs = build_references(
+            mailing_list_field="https://lists.apache.org/thread/fake-security-thread",
+            pr_field="https://github.com/apache/airflow/pull/64114",
+        )
+        urls = [r["url"] for r in refs]
+        assert urls == ["https://github.com/apache/airflow/pull/64114"]
+
+    def test_explicit_advisory_url_is_included(self):
+        refs = build_references(
+            mailing_list_field="",
+            pr_field="https://github.com/apache/airflow/pull/64114",
+            extra_urls=["https://lists.apache.org/thread/real-users-archive-url"],
+        )
+        urls = {r["url"] for r in refs}
+        assert "https://lists.apache.org/thread/real-users-archive-url" in urls
+        # And it gets tagged correctly.
+        by_url = {r["url"]: r for r in refs}
+        advisory = by_url["https://lists.apache.org/thread/real-users-archive-url"]
+        assert advisory.get("tags") == ["vendor-advisory"]
+
+    def test_airflow_s_and_cveprocess_urls_are_filtered_out(self):
+        refs = build_references(
+            mailing_list_field="",
+            pr_field="",
+            extra_urls=[
+                "https://github.com/airflow-s/airflow-s/issues/256",
+                "https://cveprocess.apache.org/cve5/CVE-2026-40948",
+                "https://github.com/apache/airflow/pull/64114",
+            ],
+        )
+        urls = [r["url"] for r in refs]
+        assert urls == ["https://github.com/apache/airflow/pull/64114"]
+
+
+# ---------------------------------------------------------------------------
+# Title handling
+# ---------------------------------------------------------------------------
+
+
+class TestResolveTitle:
+    def test_strips_apache_airflow_prefix_from_issue_title(self):
+        assert resolve_title("Apache Airflow: DAG auth bypass", "", None) == "DAG auth bypass"
+
+    def test_override_wins(self):
+        assert resolve_title("from-issue", "summary", "my override") == "my override"
+
+    def test_falls_back_to_summary_when_empty(self):
+        assert resolve_title("", "A summary. With more text.", None) == "A summary"
+
+
+# ---------------------------------------------------------------------------
+# Readiness helper + envelope state
+# ---------------------------------------------------------------------------
+
+
+def _ready_cna() -> dict:
+    """Build a minimal CNA container that satisfies every readiness rule."""
+    return {
+        "title": "Example vulnerability",
+        "descriptions": [{"lang": "en", "value": "A description."}],
+        "affected": build_affected(
+            ">=3.0.0, <3.2.0",
+            vendor="Apache Software Foundation",
+            product="Apache Airflow",
+            package_name="apache-airflow",
+            collection_url="https://pypi.python.org",
+            version_start=None,
+        ),
+        "problemTypes": build_problem_types("CWE-352: CSRF"),
+        "metrics": build_metrics("Low"),
+        "credits": build_credits("Alice Smith", remediation_developers=["Bob"]),
+        "references": build_references(
+            mailing_list_field="",
+            pr_field="https://github.com/apache/airflow/pull/123",
+        ),
+    }
+
+
+class TestIsCnaReadyForReview:
+    def test_fully_populated_cna_is_ready(self):
+        assert _is_cna_ready_for_review(_ready_cna(), "CVE-2026-00001") is True
+
+    def test_missing_cve_id_blocks_review(self):
+        assert _is_cna_ready_for_review(_ready_cna(), "") is False
+
+    def test_missing_title_blocks_review(self):
+        cna = _ready_cna()
+        cna["title"] = ""
+        assert _is_cna_ready_for_review(cna, "CVE-2026-00001") is False
+
+    def test_missing_credit_blocks_review(self):
+        cna = _ready_cna()
+        cna["credits"] = []
+        assert _is_cna_ready_for_review(cna, "CVE-2026-00001") is False
+
+    def test_unknown_severity_blocks_review(self):
+        cna = _ready_cna()
+        cna["metrics"] = build_metrics("Unknown")
+        assert _is_cna_ready_for_review(cna, "CVE-2026-00001") is False
+
+    def test_missing_cwe_blocks_review(self):
+        cna = _ready_cna()
+        cna["problemTypes"] = []
+        assert _is_cna_ready_for_review(cna, "CVE-2026-00001") is False
+
+    def test_missing_reference_blocks_review(self):
+        cna = _ready_cna()
+        cna["references"] = []
+        assert _is_cna_ready_for_review(cna, "CVE-2026-00001") is False
+
+
+class TestWrapCveRecord:
+    def test_cve_metadata_state_is_always_published(self):
+        # `cveMetadata.state` is the CVE 5.x schema field — only valid
+        # values are PUBLISHED / REJECTED for a submitted record.
+        # The generator hard-codes PUBLISHED regardless of workflow.
+        record = wrap_cve_record(_ready_cna(), cve_id="CVE-2026-00001", org_id="org")
+        assert record["cveMetadata"]["state"] == "PUBLISHED"
+        assert record["cveMetadata"]["cveId"] == "CVE-2026-00001"
+
+    def test_cve_metadata_state_published_even_when_draft(self):
+        cna = _ready_cna()
+        cna["credits"] = []  # make the CNA incomplete
+        record = wrap_cve_record(cna, cve_id="CVE-2026-00001", org_id="org")
+        # Workflow state went to DRAFT, but the CVE 5.x schema state
+        # stays PUBLISHED — these are different fields.
+        assert record["cveMetadata"]["state"] == "PUBLISHED"
+
+    def test_ready_record_emits_review_workflow_state(self):
+        cna = _ready_cna()
+        record = wrap_cve_record(cna, cve_id="CVE-2026-00001", org_id="org")
+        assert record["CNA_private"]["state"] == "REVIEW"
+
+    def test_incomplete_record_emits_draft_workflow_state(self):
+        cna = _ready_cna()
+        cna["credits"] = []
+        record = wrap_cve_record(cna, cve_id="CVE-2026-00001", org_id="org")
+        assert record["CNA_private"]["state"] == "DRAFT"
+
+    def test_vendor_advisory_reference_emits_public_workflow_state(self):
+        cna = _ready_cna()
+        cna["references"] = build_references(
+            mailing_list_field="",
+            pr_field="https://github.com/apache/airflow/pull/123",
+            extra_urls=[
+                "https://lists.apache.org/thread/abc123xyz789",
+            ],
+        )
+        record = wrap_cve_record(cna, cve_id="CVE-2026-00001", org_id="org")
+        assert record["CNA_private"]["state"] == "PUBLIC"
+
+    def test_vendor_advisory_with_incomplete_fields_still_draft(self):
+        # Even when the advisory URL is captured, an incomplete CNA
+        # stays at DRAFT — PUBLIC requires the full review-ready set.
+        cna = _ready_cna()
+        cna["credits"] = []
+        cna["references"] = build_references(
+            mailing_list_field="",
+            pr_field="https://github.com/apache/airflow/pull/123",
+            extra_urls=[
+                "https://lists.apache.org/thread/abc123xyz789",
+            ],
+        )
+        record = wrap_cve_record(cna, cve_id="CVE-2026-00001", org_id="org")
+        assert record["CNA_private"]["state"] == "DRAFT"
+
+    def test_envelope_carries_cna_private_block(self):
+        record = wrap_cve_record(_ready_cna(), cve_id="CVE-2026-00001", org_id="org")
+        assert record["CNA_private"]["owner"] == "airflow"
+        assert record["CNA_private"]["userslist"] == "users@airflow.apache.org"
+
+
+# ---------------------------------------------------------------------------
+# compute_cna_private_state
+# ---------------------------------------------------------------------------
+
+
+class TestComputeCnaPrivateState:
+    def test_ready_without_advisory_is_review(self):
+        assert compute_cna_private_state(_ready_cna(), "CVE-2026-00001") == "REVIEW"
+
+    def test_ready_with_advisory_is_public(self):
+        cna = _ready_cna()
+        cna["references"] = build_references(
+            mailing_list_field="",
+            pr_field="https://github.com/apache/airflow/pull/123",
+            extra_urls=["https://lists.apache.org/thread/abc123xyz789"],
+        )
+        assert compute_cna_private_state(cna, "CVE-2026-00001") == "PUBLIC"
+
+    def test_incomplete_is_draft(self):
+        cna = _ready_cna()
+        cna["credits"] = []
+        assert compute_cna_private_state(cna, "CVE-2026-00001") == "DRAFT"
+
+
+# ---------------------------------------------------------------------------
+# compute_package_url
+# ---------------------------------------------------------------------------
+
+
+class TestComputePackageUrl:
+    def test_pypi_python_org_returns_canonical_pypi_project_url(self):
+        assert (
+            compute_package_url("https://pypi.python.org", "apache-airflow")
+            == "https://pypi.org/project/apache-airflow/"
+        )
+
+    def test_pypi_org_alias_also_supported(self):
+        assert (
+            compute_package_url("https://pypi.org", "apache-airflow")
+            == "https://pypi.org/project/apache-airflow/"
+        )
+
+    def test_trailing_slash_is_tolerated(self):
+        assert (
+            compute_package_url("https://pypi.python.org/", "apache-airflow-providers-elasticsearch")
+            == "https://pypi.org/project/apache-airflow-providers-elasticsearch/"
+        )
+
+    def test_unknown_collection_url_returns_none(self):
+        assert compute_package_url("https://airflow.apache.org/", "apache-airflow-helm-chart") is None
+
+    def test_empty_inputs_return_none(self):
+        assert compute_package_url("", "apache-airflow") is None
+        assert compute_package_url("https://pypi.python.org", "") is None
+
+
+# ---------------------------------------------------------------------------
+# format_version_range
+# ---------------------------------------------------------------------------
+
+
+class TestFormatVersionRange:
+    def test_range_with_low_and_high(self):
+        versions = [{"version": "2.0.0", "lessThan": "3.2.2", "status": "affected", "versionType": "semver"}]
+        assert format_version_range(versions) == ">= 2.0.0, < 3.2.2"
+
+    def test_open_lower_bound_uses_less_than_only(self):
+        versions = [{"version": "0", "lessThan": "3.2.2", "status": "affected", "versionType": "semver"}]
+        assert format_version_range(versions) == "< 3.2.2"
+
+    def test_less_than_or_equal(self):
+        versions = [
+            {"version": "0", "lessThanOrEqual": "3.2.1", "status": "affected", "versionType": "semver"}
+        ]
+        assert format_version_range(versions) == "<= 3.2.1"
+
+    def test_bare_single_version(self):
+        versions = [{"version": "3.1.5", "status": "affected", "versionType": "semver"}]
+        assert format_version_range(versions) == "3.1.5"
+
+    def test_round_trip_through_parser(self):
+        # parse_affected_versions(...) → format_version_range(...) should
+        # reconstruct the original human-readable shape.
+        for raw, expected in [
+            (">= 2.0.0, < 3.2.2", ">= 2.0.0, < 3.2.2"),
+            ("< 3.2.2", "< 3.2.2"),
+            ("<= 3.2.1", "<= 3.2.1"),
+            ("3.1.5", "3.1.5"),
+        ]:
+            parsed = parse_affected_versions(raw, version_start_override=None)
+            assert format_version_range(parsed) == expected, raw
+
+    def test_empty_input_returns_empty_string(self):
+        assert format_version_range([]) == ""
+
+
+# ---------------------------------------------------------------------------
+# _build_attachment_body — the issue-body table above the embedded JSON
+# ---------------------------------------------------------------------------
+
+
+def _ready_cna_for_attachment() -> dict:
+    """Same shape as _ready_cna() but with explicit packageName /
+    product / collectionURL on every affected[] entry — what
+    build_affected() actually emits in practice."""
+    return _ready_cna()
+
+
+class TestBuildAttachmentBody:
+    def test_metric_table_includes_title_state_and_size(self):
+        cna = _ready_cna_for_attachment()
+        body = _build_attachment_body(
+            cve_id="CVE-2026-00001",
+            json_text='{"x": 1}',
+            cna=cna,
+            cna_private_state="REVIEW",
+        )
+        assert "| CVE ID | `CVE-2026-00001` |" in body
+        assert "| Title | Example vulnerability |" in body
+        assert "| Vulnogram state | `REVIEW` |" in body
+        assert "| Affected packages | `apache-airflow` |" in body
+        assert "| Size | 8 bytes |" in body
+
+    def test_no_envelope_renders_state_placeholder(self):
+        body = _build_attachment_body(
+            cve_id="CVE-2026-00001",
+            json_text='{"x": 1}',
+            cna=_ready_cna_for_attachment(),
+            cna_private_state=None,
+        )
+        assert "| Vulnogram state | — (`--no-envelope`) |" in body
+
+    def test_per_package_table_includes_pypi_url_and_versions(self):
+        body = _build_attachment_body(
+            cve_id="CVE-2026-00001",
+            json_text="{}",
+            cna=_ready_cna_for_attachment(),
+            cna_private_state="REVIEW",
+        )
+        assert "**Packages this JSON covers:**" in body
+        assert "| # | Package | Product | Versions | PyPI URL |" in body
+        assert (
+            "| 1 | `apache-airflow` | Apache Airflow | `>= 3.0.0, < 3.2.0` | <https://pypi.org/project/apache-airflow/> |"
+            in body
+        )
+
+    def test_per_package_table_renders_one_row_per_affected_entry(self):
+        cna = _ready_cna_for_attachment()
+        cna["affected"] = build_affected(
+            "apache-airflow-providers-elasticsearch <=6.5.0\napache-airflow-providers-opensearch <=1.9.0",
+            vendor="Apache Software Foundation",
+            product="Apache Airflow",
+            package_name="apache-airflow",
+            collection_url="https://pypi.python.org",
+            version_start=None,
+        )
+        body = _build_attachment_body(
+            cve_id="CVE-2026-00001",
+            json_text="{}",
+            cna=cna,
+            cna_private_state="REVIEW",
+        )
+        assert (
+            "| 1 | `apache-airflow-providers-elasticsearch` | Apache Airflow Providers Elasticsearch "
+            "| `<= 6.5.0` | <https://pypi.org/project/apache-airflow-providers-elasticsearch/> |"
+        ) in body
+        assert (
+            "| 2 | `apache-airflow-providers-opensearch` | Apache Airflow Providers OpenSearch "
+            "| `<= 1.9.0` | <https://pypi.org/project/apache-airflow-providers-opensearch/> |"
+        ) in body
+        assert (
+            "| Affected packages | `apache-airflow-providers-elasticsearch`, "
+            "`apache-airflow-providers-opensearch` |"
+        ) in body
+
+    def test_unknown_collection_url_renders_dash_in_url_column(self):
+        cna = _ready_cna_for_attachment()
+        cna["affected"] = [
+            {
+                "vendor": "Apache Software Foundation",
+                "product": "Apache Airflow Helm Chart",
+                "packageName": "apache-airflow-helm-chart",
+                "collectionURL": "https://airflow.apache.org/",
+                "versions": [
+                    {"version": "0", "lessThan": "1.18.0", "status": "affected", "versionType": "semver"},
+                ],
+                "defaultStatus": "unaffected",
+            }
+        ]
+        body = _build_attachment_body(
+            cve_id="CVE-2026-00001",
+            json_text="{}",
+            cna=cna,
+            cna_private_state="REVIEW",
+        )
+        assert "| 1 | `apache-airflow-helm-chart` | Apache Airflow Helm Chart | `< 1.18.0` | — |" in body
+
+    def test_pipe_in_title_is_escaped(self):
+        cna = _ready_cna_for_attachment()
+        cna["title"] = "Pipe | in | title"
+        body = _build_attachment_body(
+            cve_id="CVE-2026-00001",
+            json_text="{}",
+            cna=cna,
+            cna_private_state="REVIEW",
+        )
+        assert "| Title | Pipe \\| in \\| title |" in body
+
+    def test_output_is_deterministic(self):
+        first = _build_attachment_body(
+            cve_id="CVE-2026-00001",
+            json_text='{"x": 1}',
+            cna=_ready_cna_for_attachment(),
+            cna_private_state="REVIEW",
+        )
+        second = _build_attachment_body(
+            cve_id="CVE-2026-00001",
+            json_text='{"x": 1}',
+            cna=_ready_cna_for_attachment(),
+            cna_private_state="REVIEW",
+        )
+        assert first == second
+
+
+# ---------------------------------------------------------------------------
+# combine_remediation_developers
+# ---------------------------------------------------------------------------
+
+
+class TestCombineRemediationDevelopers:
+    def test_body_field_only(self):
+        assert combine_remediation_developers("Alice Smith\nBob Jones", []) == [
+            "Alice Smith",
+            "Bob Jones",
+        ]
+
+    def test_cli_only(self):
+        assert combine_remediation_developers("", ["Alice Smith"]) == ["Alice Smith"]
+
+    def test_body_first_then_cli(self):
+        assert combine_remediation_developers("Alice", ["Bob"]) == ["Alice", "Bob"]
+
+    def test_duplicates_dropped_silently(self):
+        # Body says Alice, CLI also says Alice — single credit, body order wins.
+        assert combine_remediation_developers("Alice Smith\nBob Jones", ["Alice Smith", "Carol"]) == [
+            "Alice Smith",
+            "Bob Jones",
+            "Carol",
+        ]
+
+    def test_no_response_field_treated_as_empty(self):
+        # extract_field already collapses _No response_ to "" upstream; this
+        # asserts the helper doesn't choke if a caller passes "" directly.
+        assert combine_remediation_developers("", []) == []
+
+    def test_full_name_affiliation_pattern_preserved(self):
+        # Same parsing rule as Reporter credited as: "Name, Affiliation" is one credit.
+        assert combine_remediation_developers("Jed Cunningham, Astronomer", []) == [
+            "Jed Cunningham, Astronomer",
+        ]
diff --git a/tools/vulnogram/generate-cve-json/uv.lock b/tools/vulnogram/generate-cve-json/uv.lock
new file mode 100644
index 0000000..9177d6b
--- /dev/null
+++ b/tools/vulnogram/generate-cve-json/uv.lock
@@ -0,0 +1,264 @@
+version = 1
+revision = 3
+requires-python = ">=3.11"
+resolution-markers = [
+    "python_full_version >= '3.15'",
+    "python_full_version < '3.15'",
+]
+
+[[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 = "generate-cve-json"
+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 = ">=1.11" },
+    { name = "pytest", specifier = ">=8.0" },
+    { name = "ruff", specifier = ">=0.6" },
+]
+
+[[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.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/eb/6b/3d5c13fb3e3c4f43206c8f9dfed13778c2ed4f000bacaa0b7ce3c402a265/librt-0.9.0.tar.gz", hash = "sha256:a0951822531e7aee6e0dfb556b30d5ee36bbe234faf60c20a16c01be3530869d", size = 184368, upload-time = "2026-04-09T16:06:26.173Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e2/1e/2ec7afcebcf3efea593d13aee18bbcfdd3a243043d848ebf385055e9f636/librt-0.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:90904fac73c478f4b83f4ed96c99c8208b75e6f9a8a1910548f69a00f1eaa671", size = 67155, upload-time = "2026-04-09T16:04:42.933Z" },
+    { url = "https://files.pythonhosted.org/packages/18/77/72b85afd4435268338ad4ec6231b3da8c77363f212a0227c1ff3b45e4d35/librt-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:789fff71757facc0738e8d89e3b84e4f0251c1c975e85e81b152cdaca927cc2d", size = 69916, upload-time = "2026-04-09T16:04:44.042Z" },
+    { url = "https://files.pythonhosted.org/packages/27/fb/948ea0204fbe2e78add6d46b48330e58d39897e425560674aee302dca81c/librt-0.9.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1bf465d1e5b0a27713862441f6467b5ab76385f4ecf8f1f3a44f8aa3c695b4b6", size = 199635, upload-time = "2026-04-09T16:04:45.5Z" },
+    { url = "https://files.pythonhosted.org/packages/ac/cd/894a29e251b296a27957856804cfd21e93c194aa131de8bb8032021be07e/librt-0.9.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f819e0c6413e259a17a7c0d49f97f405abadd3c2a316a3b46c6440b7dbbedbb1", size = 211051, upload-time = "2026-04-09T16:04:47.016Z" },
+    { url = "https://files.pythonhosted.org/packages/18/8f/dcaed0bc084a35f3721ff2d081158db569d2c57ea07d35623ddaca5cfc8e/librt-0.9.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0785c2fb4a81e1aece366aa3e2e039f4a4d7d21aaaded5227d7f3c703427882", size = 224031, upload-time = "2026-04-09T16:04:48.207Z" },
+    { url = "https://files.pythonhosted.org/packages/03/44/88f6c1ed1132cd418601cc041fbd92fed28b3a09f39de81978e0822d13ff/librt-0.9.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80b25c7b570a86c03b5da69e665809deb39265476e8e21d96a9328f9762f9990", size = 218069, upload-time = "2026-04-09T16:04:50.025Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/90/7d02e981c2db12188d82b4410ff3e35bfdb844b26aecd02233626f46af2b/librt-0.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d4d16b608a1c43d7e33142099a75cd93af482dadce0bf82421e91cad077157f4", size = 224857, upload-time = "2026-04-09T16:04:51.684Z" },
+    { url = "https://files.pythonhosted.org/packages/ef/c3/c77e706b7215ca32e928d47535cf13dbc3d25f096f84ddf8fbc06693e229/librt-0.9.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:194fc1a32e1e21fe809d38b5faea66cc65eaa00217c8901fbdb99866938adbdb", size = 219865, upload-time = "2026-04-09T16:04:52.949Z" },
+    { url = "https://files.pythonhosted.org/packages/52/d1/32b0c1a0eb8461c70c11656c46a29f760b7c7edf3c36d6f102470c17170f/librt-0.9.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8c6bc1384d9738781cfd41d09ad7f6e8af13cfea2c75ece6bd6d2566cdea2076", size = 218451, upload-time = "2026-04-09T16:04:54.174Z" },
+    { url = "https://files.pythonhosted.org/packages/74/d1/adfd0f9c44761b1d49b1bec66173389834c33ee2bd3c7fd2e2367f1942d4/librt-0.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:15cb151e52a044f06e54ac7f7b47adbfc89b5c8e2b63e1175a9d587c43e8942a", size = 241300, upload-time = "2026-04-09T16:04:55.452Z" },
+    { url = "https://files.pythonhosted.org/packages/09/b0/9074b64407712f0003c27f5b1d7655d1438979155f049720e8a1abd9b1a1/librt-0.9.0-cp311-cp311-win32.whl", hash = "sha256:f100bfe2acf8a3689af9d0cc660d89f17286c9c795f9f18f7b62dd1a6b247ae6", size = 55668, upload-time = "2026-04-09T16:04:56.689Z" },
+    { url = "https://files.pythonhosted.org/packages/24/19/40b77b77ce80b9389fb03971431b09b6b913911c38d412059e0b3e2a9ef2/librt-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:0b73e4266307e51c95e09c0750b7ec383c561d2e97d58e473f6f6a209952fbb8", size = 62976, upload-time = "2026-04-09T16:04:57.733Z" },
+    { url = "https://files.pythonhosted.org/packages/70/9d/9fa7a64041e29035cb8c575af5f0e3840be1b97b4c4d9061e0713f171849/librt-0.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:bc5518873822d2faa8ebdd2c1a4d7c8ef47b01a058495ab7924cb65bdbf5fc9a", size = 53502, upload-time = "2026-04-09T16:04:58.806Z" },
+    { url = "https://files.pythonhosted.org/packages/bf/90/89ddba8e1c20b0922783cd93ed8e64f34dc05ab59c38a9c7e313632e20ff/librt-0.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b3e3bc363f71bda1639a4ee593cb78f7fbfeacc73411ec0d4c92f00730010a4", size = 68332, upload-time = "2026-04-09T16:05:00.09Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/40/7aa4da1fb08bdeeb540cb07bfc8207cb32c5c41642f2594dbd0098a0662d/librt-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a09c2f5869649101738653a9b7ab70cf045a1105ac66cbb8f4055e61df78f2d", size = 70581, upload-time = "2026-04-09T16:05:01.213Z" },
+    { url = "https://files.pythonhosted.org/packages/48/ac/73a2187e1031041e93b7e3a25aae37aa6f13b838c550f7e0f06f66766212/librt-0.9.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ca8e133d799c948db2ab1afc081c333a825b5540475164726dcbf73537e5c2f", size = 203984, upload-time = "2026-04-09T16:05:02.542Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/3d/23460d571e9cbddb405b017681df04c142fb1b04cbfce77c54b08e28b108/librt-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:603138ee838ee1583f1b960b62d5d0007845c5c423feb68e44648b1359014e27", size = 215762, upload-time = "2026-04-09T16:05:04.127Z" },
+    { url = "https://files.pythonhosted.org/packages/de/1e/42dc7f8ab63e65b20640d058e63e97fd3e482c1edbda3570d813b4d0b927/librt-0.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4003f70c56a5addd6aa0897f200dd59afd3bf7bcd5b3cce46dd21f925743bc2", size = 230288, upload-time = "2026-04-09T16:05:05.883Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/08/ca812b6d8259ad9ece703397f8ad5c03af5b5fedfce64279693d3ce4087c/librt-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78042f6facfd98ecb25e9829c7e37cce23363d9d7c83bc5f72702c5059eb082b", size = 224103, upload-time = "2026-04-09T16:05:07.148Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/3f/620490fb2fa66ffd44e7f900254bc110ebec8dac6c1b7514d64662570e6f/librt-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a361c9434a64d70a7dbb771d1de302c0cc9f13c0bffe1cf7e642152814b35265", size = 232122, upload-time = "2026-04-09T16:05:08.386Z" },
+    { url = "https://files.pythonhosted.org/packages/e9/83/12864700a1b6a8be458cf5d05db209b0d8e94ae281e7ec261dbe616597b4/librt-0.9.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:dd2c7e082b0b92e1baa4da28163a808672485617bc855cc22a2fd06978fa9084", size = 225045, upload-time = "2026-04-09T16:05:09.707Z" },
+    { url = "https://files.pythonhosted.org/packages/fd/1b/845d339c29dc7dbc87a2e992a1ba8d28d25d0e0372f9a0a2ecebde298186/librt-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7e6274fd33fc5b2a14d41c9119629d3ff395849d8bcbc80cf637d9e8d2034da8", size = 227372, upload-time = "2026-04-09T16:05:10.942Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/fe/277985610269d926a64c606f761d58d3db67b956dbbf40024921e95e7fcb/librt-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5093043afb226ecfa1400120d1ebd4442b4f99977783e4f4f7248879009b227f", size = 248224, upload-time = "2026-04-09T16:05:12.254Z" },
+    { url = "https://files.pythonhosted.org/packages/92/1b/ee486d244b8de6b8b5dbaefabe6bfdd4a72e08f6353edf7d16d27114da8d/librt-0.9.0-cp312-cp312-win32.whl", hash = "sha256:9edcc35d1cae9fd5320171b1a838c7da8a5c968af31e82ecc3dff30b4be0957f", size = 55986, upload-time = "2026-04-09T16:05:13.529Z" },
+    { url = "https://files.pythonhosted.org/packages/89/7a/ba1737012308c17dc6d5516143b5dce9a2c7ba3474afd54e11f44a4d1ef3/librt-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc2917258e131ae5f958a4d872e07555b51cb7466a43433218061c74ef33745", size = 63260, upload-time = "2026-04-09T16:05:14.68Z" },
+    { url = "https://files.pythonhosted.org/packages/36/e4/01752c113da15127f18f7bf11142f5640038f062407a611c059d0036c6aa/librt-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:90e6d5420fc8a300518d4d2288154ff45005e920425c22cbbfe8330f3f754bd9", size = 53694, upload-time = "2026-04-09T16:05:16.095Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/d7/1b3e26fffde1452d82f5666164858a81c26ebe808e7ae8c9c88628981540/librt-0.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29b68cd9714531672db62cc54f6e8ff981900f824d13fa0e00749189e13778e", size = 68367, upload-time = "2026-04-09T16:05:17.243Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/5b/c61b043ad2e091fbe1f2d35d14795e545d0b56b03edaa390fa1dcee3d160/librt-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d5c8a5929ac325729f6119802070b561f4db793dffc45e9ac750992a4ed4d22", size = 70595, upload-time = "2026-04-09T16:05:18.471Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/22/2448471196d8a73370aa2f23445455dc42712c21404081fcd7a03b9e0749/librt-0.9.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:756775d25ec8345b837ab52effee3ad2f3b2dfd6bbee3e3f029c517bd5d8f05a", size = 204354, upload-time = "2026-04-09T16:05:19.593Z" },
+    { url = "https://files.pythonhosted.org/packages/ac/5e/39fc4b153c78cfd2c8a2dcb32700f2d41d2312aa1050513183be4540930d/librt-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8f5d00b49818f4e2b1667db994488b045835e0ac16fe2f924f3871bd2b8ac5", size = 216238, upload-time = "2026-04-09T16:05:20.868Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/42/bc2d02d0fa7badfa63aa8d6dcd8793a9f7ef5a94396801684a51ed8d8287/librt-0.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c81aef782380f0f13ead670aae01825eb653b44b046aa0e5ebbb79f76ed4aa11", size = 230589, upload-time = "2026-04-09T16:05:22.305Z" },
+    { url = "https://files.pythonhosted.org/packages/c8/7b/e2d95cc513866373692aa5edf98080d5602dd07cabfb9e5d2f70df2f25f7/librt-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66b58fed90a545328e80d575467244de3741e088c1af928f0b489ebec3ef3858", size = 224610, upload-time = "2026-04-09T16:05:23.647Z" },
+    { url = "https://files.pythonhosted.org/packages/31/d5/6cec4607e998eaba57564d06a1295c21b0a0c8de76e4e74d699e627bd98c/librt-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e78fb7419e07d98c2af4b8567b72b3eaf8cb05caad642e9963465569c8b2d87e", size = 232558, upload-time = "2026-04-09T16:05:25.025Z" },
+    { url = "https://files.pythonhosted.org/packages/95/8c/27f1d8d3aaf079d3eb26439bf0b32f1482340c3552e324f7db9dca858671/librt-0.9.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c3786f0f4490a5cd87f1ed6cefae833ad6b1060d52044ce0434a2e85893afd0", size = 225521, upload-time = "2026-04-09T16:05:26.311Z" },
+    { url = "https://files.pythonhosted.org/packages/6b/d8/1e0d43b1c329b416017619469b3c3801a25a6a4ef4a1c68332aeaa6f72ca/librt-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8494cfc61e03542f2d381e71804990b3931175a29b9278fdb4a5459948778dc2", size = 227789, upload-time = "2026-04-09T16:05:27.624Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/b4/d3d842e88610fcd4c8eec7067b0c23ef2d7d3bff31496eded6a83b0f99be/librt-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07cf11f769831186eeac424376e6189f20ace4f7263e2134bdb9757340d84d4d", size = 248616, upload-time = "2026-04-09T16:05:29.181Z" },
+    { url = "https://files.pythonhosted.org/packages/ec/28/527df8ad0d1eb6c8bdfa82fc190f1f7c4cca5a1b6d7b36aeabf95b52d74d/librt-0.9.0-cp313-cp313-win32.whl", hash = "sha256:850d6d03177e52700af605fd60db7f37dcb89782049a149674d1a9649c2138fd", size = 56039, upload-time = "2026-04-09T16:05:30.709Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/a7/413652ad0d92273ee5e30c000fc494b361171177c83e57c060ecd3c21538/librt-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:a5af136bfba820d592f86c67affcef9b3ff4d4360ac3255e341e964489b48519", size = 63264, upload-time = "2026-04-09T16:05:31.881Z" },
+    { url = "https://files.pythonhosted.org/packages/a4/0a/92c244309b774e290ddb15e93363846ae7aa753d9586b8aad511c5e6145b/librt-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:4c4d0440a3a8e31d962340c3e1cc3fc9ee7febd34c8d8f770d06adb947779ea5", size = 53728, upload-time = "2026-04-09T16:05:33.31Z" },
+    { url = "https://files.pythonhosted.org/packages/cd/c1/184e539543f06ea2912f4b92a5ffaede4f9b392689e3f00acbf8134bee92/librt-0.9.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:3f05d145df35dca5056a8bc3838e940efebd893a54b3e19b2dda39ceaa299bcb", size = 67830, upload-time = "2026-04-09T16:05:34.517Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/ad/23399bdcb7afca819acacdef31b37ee59de261bd66b503a7995c03c4b0dc/librt-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1c587494461ebd42229d0f1739f3aa34237dd9980623ecf1be8d3bcba79f4499", size = 70280, upload-time = "2026-04-09T16:05:35.649Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/0b/4542dc5a2b8772dbf92cafb9194701230157e73c14b017b6961a23598b03/librt-0.9.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0a2040f801406b93657a70b72fa12311063a319fee72ce98e1524da7200171f", size = 201925, upload-time = "2026-04-09T16:05:36.739Z" },
+    { url = "https://files.pythonhosted.org/packages/31/d4/8ee7358b08fd0cfce051ef96695380f09b3c2c11b77c9bfbc367c921cce5/librt-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f38bc489037eca88d6ebefc9c4d41a4e07c8e8b4de5188a9e6d290273ad7ebb1", size = 212381, upload-time = "2026-04-09T16:05:38.043Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/94/a2025fe442abedf8b038038dab3dba942009ad42b38ea064a1a9e6094241/librt-0.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3fd278f5e6bf7c75ccd6d12344eb686cc020712683363b66f46ac79d37c799f", size = 227065, upload-time = "2026-04-09T16:05:39.394Z" },
+    { url = "https://files.pythonhosted.org/packages/7c/e9/b9fcf6afa909f957cfbbf918802f9dada1bd5d3c1da43d722fd6a310dc3f/librt-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fcbdf2a9ca24e87bbebb47f1fe34e531ef06f104f98c9ccfc953a3f3344c567a", size = 221333, upload-time = "2026-04-09T16:05:40.999Z" },
+    { url = "https://files.pythonhosted.org/packages/ac/7c/ba54cd6aa6a3c8cd12757a6870e0c79a64b1e6327f5248dcff98423f4d43/librt-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e306d956cfa027fe041585f02a1602c32bfa6bb8ebea4899d373383295a6c62f", size = 229051, upload-time = "2026-04-09T16:05:42.605Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/4b/8cfdbad314c8677a0148bf0b70591d6d18587f9884d930276098a235461b/librt-0.9.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:465814ab157986acb9dfa5ccd7df944be5eefc0d08d31ec6e8d88bc71251d845", size = 222492, upload-time = "2026-04-09T16:05:43.842Z" },
+    { url = "https://files.pythonhosted.org/packages/1f/d1/2eda69563a1a88706808decdce035e4b32755dbfbb0d05e1a65db9547ed1/librt-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:703f4ae36d6240bfe24f542bac784c7e4194ec49c3ba5a994d02891649e2d85b", size = 223849, upload-time = "2026-04-09T16:05:45.054Z" },
+    { url = "https://files.pythonhosted.org/packages/04/44/b2ed37df6be5b3d42cfe36318e0598e80843d5c6308dd63d0bf4e0ce5028/librt-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3be322a15ee5e70b93b7a59cfd074614f22cc8c9ff18bd27f474e79137ea8d3b", size = 245001, upload-time = "2026-04-09T16:05:46.34Z" },
+    { url = "https://files.pythonhosted.org/packages/47/e7/617e412426df89169dd2a9ed0cc8752d5763336252c65dbf945199915119/librt-0.9.0-cp314-cp314-win32.whl", hash = "sha256:b8da9f8035bb417770b1e1610526d87ad4fc58a2804dc4d79c53f6d2cf5a6eb9", size = 51799, upload-time = "2026-04-09T16:05:47.738Z" },
+    { url = "https://files.pythonhosted.org/packages/24/ed/c22ca4db0ca3cbc285e4d9206108746beda561a9792289c3c31281d7e9df/librt-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:b8bd70d5d816566a580d193326912f4a76ec2d28a97dc4cd4cc831c0af8e330e", size = 59165, upload-time = "2026-04-09T16:05:49.198Z" },
+    { url = "https://files.pythonhosted.org/packages/24/56/875398fafa4cbc8f15b89366fc3287304ddd3314d861f182a4b87595ace0/librt-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:fc5758e2b7a56532dc33e3c544d78cbaa9ecf0a0f2a2da2df882c1d6b99a317f", size = 49292, upload-time = "2026-04-09T16:05:50.362Z" },
+    { url = "https://files.pythonhosted.org/packages/4c/61/bc448ecbf9b2d69c5cff88fe41496b19ab2a1cbda0065e47d4d0d51c0867/librt-0.9.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f24b90b0e0c8cc9491fb1693ae91fe17cb7963153a1946395acdbdd5818429a4", size = 70175, upload-time = "2026-04-09T16:05:51.564Z" },
+    { url = "https://files.pythonhosted.org/packages/60/f2/c47bb71069a73e2f04e70acbd196c1e5cc411578ac99039a224b98920fd4/librt-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fe56e80badb66fdcde06bef81bbaa5bfcf6fbd7aefb86222d9e369c38c6b228", size = 72951, upload-time = "2026-04-09T16:05:52.699Z" },
+    { url = "https://files.pythonhosted.org/packages/29/19/0549df59060631732df758e8886d92088da5fdbedb35b80e4643664e8412/librt-0.9.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:527b5b820b47a09e09829051452bb0d1dd2122261254e2a6f674d12f1d793d54", size = 225864, upload-time = "2026-04-09T16:05:53.895Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/f8/3b144396d302ac08e50f89e64452c38db84bc7b23f6c60479c5d3abd303c/librt-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d429bdd4ac0ab17c8e4a8af0ed2a7440b16eba474909ab357131018fe8c7e71", size = 241155, upload-time = "2026-04-09T16:05:55.191Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/ce/ee67ec14581de4043e61d05786d2aed6c9b5338816b7859bcf07455c6a9f/librt-0.9.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7202bdcac47d3a708271c4304a474a8605a4a9a4a709e954bf2d3241140aa938", size = 252235, upload-time = "2026-04-09T16:05:56.549Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/fa/0ead15daa2b293a54101550b08d4bafe387b7d4a9fc6d2b985602bae69b6/librt-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0d620e74897f8c2613b3c4e2e9c1e422eb46d2ddd07df540784d44117836af3", size = 244963, upload-time = "2026-04-09T16:05:57.858Z" },
+    { url = "https://files.pythonhosted.org/packages/29/68/9fbf9a9aa704ba87689e40017e720aced8d9a4d2b46b82451d8142f91ec9/librt-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d69fc39e627908f4c03297d5a88d9284b73f4d90b424461e32e8c2485e21c283", size = 257364, upload-time = "2026-04-09T16:05:59.686Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/8d/9d60869f1b6716c762e45f66ed945b1e5dd649f7377684c3b176ae424648/librt-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c2640e23d2b7c98796f123ffd95cf2022c7777aa8a4a3b98b36c570d37e85eee", size = 247661, upload-time = "2026-04-09T16:06:00.938Z" },
+    { url = "https://files.pythonhosted.org/packages/70/ff/a5c365093962310bfdb4f6af256f191085078ffb529b3f0cbebb5b33ebe2/librt-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:451daa98463b7695b0a30aa56bf637831ea559e7b8101ac2ef6382e8eb15e29c", size = 248238, upload-time = "2026-04-09T16:06:02.537Z" },
+    { url = "https://files.pythonhosted.org/packages/a0/3c/2d34365177f412c9e19c0a29f969d70f5343f27634b76b765a54d8b27705/librt-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:928bd06eca2c2bbf4349e5b817f837509b0604342e65a502de1d50a7570afd15", size = 269457, upload-time = "2026-04-09T16:06:03.833Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/cd/de45b239ea3bdf626f982a00c14bfcf2e12d261c510ba7db62c5969a27cd/librt-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:a9c63e04d003bc0fb6a03b348018b9a3002f98268200e22cc80f146beac5dc40", size = 52453, upload-time = "2026-04-09T16:06:05.229Z" },
+    { url = "https://files.pythonhosted.org/packages/7f/f9/bfb32ae428aa75c0c533915622176f0a17d6da7b72b5a3c6363685914f70/librt-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f162af66a2ed3f7d1d161a82ca584efd15acd9c1cff190a373458c32f7d42118", size = 60044, upload-time = "2026-04-09T16:06:06.398Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/47/7d70414bcdbb3bc1f458a8d10558f00bbfdb24e5a11740fc8197e12c3255/librt-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a4b25c6c25cac5d0d9d6d6da855195b254e0021e513e0249f0e3b444dc6e0e61", size = 50009, upload-time = "2026-04-09T16:06:07.995Z" },
+]
+
+[[package]]
+name = "mypy"
+version = "1.20.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "librt", marker = "platform_python_implementation != 'PyPy'" },
+    { name = "mypy-extensions" },
+    { name = "pathspec" },
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307, upload-time = "2026-04-21T17:08:56.442Z" },
+    { url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917, upload-time = "2026-04-21T17:05:50.978Z" },
+    { url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516, upload-time = "2026-04-21T17:11:33.161Z" },
+    { url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889, upload-time = "2026-04-21T17:05:27.674Z" },
+    { url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844, upload-time = "2026-04-21T17:10:06.2Z" },
+    { url = "https://files.pythonhosted.org/packages/37/52/10a1ddf91b40f843943a3c6db51e2df59c9e237f29d355e95eaab427461f/mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67", size = 10846300, upload-time = "2026-04-21T17:12:23.886Z" },
+    { url = "https://files.pythonhosted.org/packages/20/02/f9a4415b664c53bd34d6709be59da303abcae986dc4ac847b402edb6fa1e/mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100", size = 9779498, upload-time = "2026-04-21T17:09:23.695Z" },
+    { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" },
+    { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" },
+    { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" },
+    { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" },
+    { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" },
+    { url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" },
+    { url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" },
+    { url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" },
+    { url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" },
+    { url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" },
+    { url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" },
+    { url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" },
+    { url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" },
+    { url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" },
+    { url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" },
+    { url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" },
+    { url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" },
+    { url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" },
+    { url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" },
+    { url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" },
+    { url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" },
+    { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" },
+]
+
+[[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 = "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.12"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" },
+    { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" },
+    { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" },
+    { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" },
+    { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" },
+    { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" },
+    { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" },
+    { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" },
+    { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" },
+    { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" },
+    { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
+]
+
+[[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" },
+]