)]}'
{
  "commit": "9361a1a4fbadd67659f3848936e1d909b594f9ff",
  "tree": "bea05393dcf162eb8d476d65afd12d144414ee59",
  "parents": [
    "36c342ab1f36a4cda09cd7b1baf8f38c4f1ba6d6"
  ],
  "author": {
    "name": "Muravev Vasilii",
    "email": "10913258+muravev-vasilii@users.noreply.github.com",
    "time": "Thu Aug 13 19:06:19 2026 +0200"
  },
  "committer": {
    "name": "GitHub",
    "email": "noreply@github.com",
    "time": "Thu Aug 13 10:06:19 2026 -0700"
  },
  "message": "fix(pypi): resolve self-referencing extras to a real fixed point (#4039)\n\n## Summary\n\n`_resolve_extras` decides that its fixed-point loop has converged by\ncomparing `num_extras_before` — the size of the extras set at the\n*start* of the round — against `len(new_extras)`, the number of extras\ndiscovered *during* that round:\n\n```python\nnum_extras_before \u003d len(extras)\nextras \u003d extras | new_extras\nnum_extras_after \u003d len(new_extras)   # the delta, not the merged size\n\nif num_extras_before \u003d\u003d num_extras_after:\n    break\n```\n\nThose are unrelated quantities, and the mismatch breaks in two separate\nways.\n\n### 1. Extras are silently dropped\n\nThe loop exits early whenever the two counts happen to coincide while\nthe set is still growing, so extras reachable only through a further\nround are never resolved —- and every dependency gated on them silently\ndisappears from the generated target.\n\nThe smallest reproducer is a two-hop self-extras chain:\n\n| `Requires-Dist` | requested | resolved today | expected |\n|---|---|---|---|\n| `foo[b]; extra \u003d\u003d \u0027all\u0027`, `foo[c]; extra \u003d\u003d \u0027b\u0027` | `foo[all]` | `{all,\nb}` | `{all, b, c}` |\n\nAnything behind `extra \u003d\u003d \u0027c\u0027` is lost, with no error. This is not\nlimited to the first round: a chain that branches before it deepens\n(`all → {p, q}`, `p → r`, `r → t`) hits the same equality on round two\nand drops `t`.\n\n### 2. The loop never terminates early for ordinary packages\n\nFor a package with no self-referencing extras — the overwhelmingly\ncommon case — `self_reqs` is empty, so `new_extras` is always `{}` while\n`extras` holds at least one entry. The condition can never hold, and the\nloop runs all 10000 rounds, allocating a dict each time, while\nevaluating the generated `BUILD` file of every wheel in the build.\n\nOn a ~52k-package repository this dominated loading-phase Starlark CPU:\n\n| Metric | Before | After |\n|---|---|---|\n| `_resolve_extras` self-time | 430–442 CPU-s | below profiler threshold\n|\n| Total Starlark user-function CPU | 747–759 CPU-s | 275–298 CPU-s |\n| Cold loading+analysis wall time (16 cores) | 82–84 s | 73–75 s |\n\n## The fix\n\nCompare the size of the *merged* set, which is what the `before`/`after`\nnaming already implied. The loop is monotonic, so the converged set is\nunchanged wherever it previously terminated correctly — this only stops\nit terminating too early, or not at all.\n\n## Tests\n\nThree regression tests are added to `tests/pypi/pep508/deps_tests.bzl`,\ncovering the three shapes that trigger the early exit: a two-hop chain,\nmultiple requested extras, and a chain where the counts only coincide\nafter the first round.\n\nAll three fail on `main` with exactly the dropped dependency, and pass\nwith the fix:\n\n```\n//tests/pypi/pep508:test_self_extras_chain_is_fully_resolved              FAILED   1 missing: c_dep\n//tests/pypi/pep508:test_self_extras_chain_with_multiple_requested_extras FAILED   1 missing: c_dep\n//tests/pypi/pep508:test_self_extras_chain_resolved_beyond_the_first_round FAILED  1 missing: t_dep\n```\n\nThe three existing tests that exercise self-extras chains\n(`test_self_is_ignored`, `test_self_dependencies_can_come_in_any_order`,\n`test_self_include_deps_from_previously_visited`) pass either way, which\nis why this went unnoticed.\n\n`bazel test //tests/pypi/...` is green (249/249) with the fix applied.\n\n## Notes\n\nThe condition was introduced in #3527, which replaced the previous\ndouble loop with this fixed-point loop.\n\nCo-authored-by: Claude Opus 5 (1M context) \u003cnoreply@anthropic.com\u003e",
  "tree_diff": [
    {
      "type": "add",
      "old_id": "0000000000000000000000000000000000000000",
      "old_mode": 0,
      "old_path": "/dev/null",
      "new_id": "d48ad91b24a0bdc233bef357416b72cfcde31cfd",
      "new_mode": 33188,
      "new_path": "news/4039.fixed.md"
    },
    {
      "type": "modify",
      "old_id": "fd6d961bf5e5e3acc2af82a205fa8aa110420f03",
      "old_mode": 33188,
      "old_path": "python/private/pypi/pep508_deps.bzl",
      "new_id": "e36ceadbd5dc3888c8294ce78e39ee575c857b56",
      "new_mode": 33188,
      "new_path": "python/private/pypi/pep508_deps.bzl"
    },
    {
      "type": "modify",
      "old_id": "e88acb8c568397203053077ed171c74a0a81ce27",
      "old_mode": 33188,
      "old_path": "tests/pypi/pep508/deps_tests.bzl",
      "new_id": "0d171d8e951af9ccb04d23ac15766b4eeedd9b93",
      "new_mode": 33188,
      "new_path": "tests/pypi/pep508/deps_tests.bzl"
    }
  ]
}
