diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e0bf8ba1..91c0a35d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -35,7 +35,7 @@ effect. State the numbers, not "benchmarked". - [ ] **Behaviour changed?** If a wrong change here could pass silently, pin it with a test whose name is the claim and whose docstring opens `INVARIANT:` and says what breaks it. Do **not** write prose about mechanism — there is no page for - it. See [`planning/README.md`](../planning/README.md#where-a-fact-goes). + it. See the "Where a fact goes" section of [`CLAUDE.md`](../CLAUDE.md). - [ ] **Adding a fact anywhere?** Run the admission check: derivable from `modern_di/` → don't write it; enforceable → a test; a user needs it → `docs/`; otherwise it does not get written. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7282d60a..0d0134b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,25 +25,6 @@ jobs: - uses: extractions/setup-just@v4 - uses: astral-sh/setup-uv@v7 - # Curated release notes are MANDATORY for a stable tag. This runs BEFORE - # `just publish` (which is irreversible) so a missing notes file aborts - # the release before anything reaches PyPI — rather than silently shipping - # with GitHub's auto-generated notes. Pre-release tags (a letter in the - # name, e.g. 2.0.0rc1) are exempt and keep the auto-generated fallback. - - name: Require curated release notes (stable tags) - run: | - set -euo pipefail - if [[ "$GITHUB_REF_NAME" =~ [a-z] ]]; then - echo "Pre-release ${GITHUB_REF_NAME}: curated notes not required." - exit 0 - fi - notes="planning/releases/${GITHUB_REF_NAME}.md" - if [ ! -f "$notes" ]; then - echo "::error::Stable tag ${GITHUB_REF_NAME} has no curated release notes at ${notes}. Write the notes, commit to main, and re-tag." >&2 - exit 1 - fi - echo "Found curated release notes: ${notes}" - # PyPI is irreversible, so it runs FIRST: if it fails the job stops and no # GitHub Release is created advertising a version that never reached PyPI. # `just publish` derives the version from $GITHUB_REF_NAME (the tag name). @@ -51,22 +32,15 @@ jobs: # Publisher on the modern-di PyPI project (env: pypi, workflow: release.yml). - run: just publish - # Description source: planning/releases/.md if present (verbatim, no - # auto-changelog appended); otherwise GitHub's generated notes. The guard - # above makes the file mandatory for stable tags, so the generated-notes - # fallback only ever fires for pre-releases. A tag with a letter (2.0.0rc1) - # is a pre-release -> flagged so GitHub won't mark it "Latest". + # The Release body is GitHub's generated notes, rendered from the squashed + # PR titles since the previous tag — so a conventional-commit title is what + # a reader gets. A release wanting prose is edited after the fact with + # `gh release edit --notes-file`. A tag with a letter (2.0.0rc1) is a + # pre-release -> flagged so GitHub won't mark it "Latest". - name: Resolve release metadata id: meta run: | set -euo pipefail - notes="planning/releases/${GITHUB_REF_NAME}.md" - if [ -f "$notes" ]; then - echo "body_path=$notes" >> "$GITHUB_OUTPUT" - echo "generate_notes=false" >> "$GITHUB_OUTPUT" - else - echo "generate_notes=true" >> "$GITHUB_OUTPUT" - fi if [[ "$GITHUB_REF_NAME" =~ [a-z] ]]; then echo "prerelease=true" >> "$GITHUB_OUTPUT" else @@ -76,7 +50,6 @@ jobs: - name: Publish GitHub Release uses: softprops/action-gh-release@v3 with: - body_path: ${{ steps.meta.outputs.body_path }} - generate_release_notes: ${{ steps.meta.outputs.generate_notes }} + generate_release_notes: true prerelease: ${{ steps.meta.outputs.prerelease }} draft: false diff --git a/CLAUDE.md b/CLAUDE.md index 694951f8..2c7661dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,8 +15,9 @@ or read it for every recipe and its intent. The non-obvious essentials: - `just test [args]` — pytest, **no coverage**; targeted runs won't trip the gate. Passes args through: `just test tests/providers/test_factory.py -k `. - `just test-ci` — the **gated** full run (100% line coverage); this is what CI runs. -- `just lint` (autofix) / `just lint-ci` (no autofix; also runs the repo-wide link check). -- `just check-links` validates every relative Markdown link and heading anchor in the repo, including the trees `mkdocs --strict` never sees. +- `just lint` (autofix) / `just lint-ci` (no autofix). +- `just docs-build` builds the site with `mkdocs --strict`, which fails on a broken link or nav + entry within `docs/`. Nothing validates links in root Markdown, `.github/`, or `docs/agents/`. ## Architecture @@ -25,7 +26,7 @@ or read it for every recipe and its intent. The non-obvious essentials: There is no separate capability-page home for behavior detail — it lives in the code and its `INVARIANT:`-marked tests. Before writing prose about a capability, run the admission check in -[`planning/README.md`](planning/README.md#where-a-fact-goes). +**Where a fact goes** below. ### Key files @@ -75,26 +76,64 @@ scheduled** becomes a GitHub issue (see [`docs/agents/issue-tracker.md`](docs/agents/issue-tracker.md)). There is no third state. There is no separate truth-home directory either — the living truth about behaviour is the code and its `INVARIANT:`-marked tests, and a behaviour -change is reviewed with the diff, not promoted to a page. See -[`planning/README.md`](planning/README.md) for the admission check that decides -where a given fact belongs. - -- **Cutting a release (maintainers)** is tag-driven via - [`.github/workflows/release.yml`](.github/workflows/release.yml): write the - notes at `planning/releases/.md` from - [`planning/_templates/release.md`](planning/_templates/release.md) (used - verbatim as the GitHub Release body; `docs/changelog.md` links to the - directory rather than republishing it), then push a bare-semver-**named** tag - off green `main` — - `git tag -m "modern-di 2.19.2" 2.19.2 && git push origin 2.19.2`. Only the tag - *name* must be bare semver (that is what the workflow matches); the tag object - itself may be annotated or signed, and `-m` is required whenever - `tag.gpgsign`/`tag.forceSignAnnotated` is set — without it `git tag` aborts - with `fatal: no tag message?`. The workflow runs `just publish` - (the tag sets the version via `uv version`; no `pyproject.toml` bump) to PyPI, - then creates the GitHub Release — PyPI first, so a failed publish creates no - Release. Pre-releases use the PEP 440 form (`2.0.0rc1`, not `2.0.0-alpha.5`). - PyPI is irreversible; there is no CI gate (a tag is the commitment point). +change is reviewed with the diff, not promoted to a page. **Where a fact goes** +below is the admission check that decides where a given fact belongs. + +### Where a fact goes + +Four homes, one owner each: + +| Home | Holds | +|---|---| +| `modern_di/` | anything readable from the module — the default | +| a named test | an **invariant**: must stay true, and a change could silently break it | +| `docs/adr/` | a rejected alternative, with the reasoning that would otherwise be re-litigated | +| `docs/` | anything a user needs | + +Before writing a line anywhere: + +> Can an agent get this by reading `modern_di/`? → **don't write it.** +> Would a wrong change here fail a test? → it belongs **in the test**, not in prose. +> Does a user need it? → **`docs/`**. +> Otherwise it does not get written. + +**Prose about mechanism has no home. There is no file to add a paragraph to.** + +Both ADRs and `INVARIANT:` docstrings ratchet in the other direction: nothing +prunes a record once its call is settled, or a docstring once its claim stops +mattering. Keeping either lean is a standing habit, not a one-time fix. + +An invariant is written as a test whose name is the claim, with a docstring opening +`INVARIANT:` and a second paragraph naming **what breaks it**. That second paragraph +is where an anti-refactor warning lives — design rationale, not a report of what this +one test happens to catch. It does not have to describe a regression that *this* +test alone would fail on; a sibling test may be the one that actually trips. The unit +of truth is the invariant plus the whole suite, not the docstring plus its single +test — the accepted cost is that a reader cannot tell, from one docstring alone, +whether that test or a sibling one catches a given regression. +`tests/test_invariant_census.py` enforces that shape. + +### Cutting a release (maintainers) + +Tag-driven via [`.github/workflows/release.yml`](.github/workflows/release.yml): +push a bare-semver-**named** tag off green `main` — +`git tag -m "modern-di 3.4.0" 3.4.0 && git push origin 3.4.0`. Only the tag +*name* must be bare semver (that is what the workflow matches); the tag object +itself may be annotated or signed, and `-m` is required whenever +`tag.gpgsign`/`tag.forceSignAnnotated` is set — without it `git tag` aborts +with `fatal: no tag message?`. The workflow runs `just publish` +(the tag sets the version via `uv version`; no `pyproject.toml` bump) to PyPI, +then creates the GitHub Release — PyPI first, so a failed publish creates no +Release. Pre-releases use the PEP 440 form (`2.0.0rc1`, not `2.0.0-alpha.5`). +PyPI is irreversible; there is no CI gate (a tag is the commitment point). + +The Release body is GitHub's generated notes, built from the squashed PR titles +since the previous tag. A conventional-commit PR title is therefore the changelog +entry a reader gets, and that is where the care goes. A release wanting prose gets +it after the fact with `gh release edit --notes-file `. There is no +committed notes file and no template. Releases 2.15.0 through 3.4.0 have curated +bodies, which live on the +[Releases page](https://github.com/modern-python/modern-di/releases) and nowhere else. ## Code Style diff --git a/docs/changelog.md b/docs/changelog.md deleted file mode 100644 index 424466ae..00000000 --- a/docs/changelog.md +++ /dev/null @@ -1,9 +0,0 @@ -# Changelog - -Curated release notes live in the repository, one file per version, and are used verbatim as the -body of the matching [GitHub Release](https://github.com/modern-python/modern-di/releases). - -[**Browse the release notes**](https://github.com/modern-python/modern-di/tree/main/planning/releases) - -Notes are written per release from **2.15.0** onward. Earlier versions were released without curated -notes; their auto-generated changelogs are on the GitHub releases page linked above. diff --git a/docs/integrations/writing-integrations.md b/docs/integrations/writing-integrations.md index af14b4ff..683955c8 100644 --- a/docs/integrations/writing-integrations.md +++ b/docs/integrations/writing-integrations.md @@ -433,13 +433,8 @@ Each official integration is its own repository and PyPI package, mirroring the [Lifecycle](../providers/lifecycle.md), [Scopes](../providers/scopes.md), and the most relevant recipe, and the `## API` table last. Integrations do not ship their own docs site. -- **Release.** Tag-driven, mirroring `modern-di`: write release notes and push a - bare semver tag off green `main`. - -!!! tip "Planning convention" - For the planning/change-management setup, following the - [planning-convention](https://github.com/lesnik512/planning-convention) is - recommended — the same two-axis convention the `modern-di` repo uses. +- **Release.** Tag-driven, mirroring `modern-di`: push a bare semver tag off + green `main` and let the workflow publish. ## Checklist @@ -470,6 +465,4 @@ Each official integration is its own repository and PyPI package, mirroring the - [ ] `examples/app.py` (+ smoke test asserting real injected output, 100% coverage, no `omit`) and a README `Usage example: [examples/](./examples)` line. -- [ ] `CLAUDE.md` and `Justfile` mirrored; invariants pinned by named tests; - [planning-convention](https://github.com/lesnik512/planning-convention) - followed. +- [ ] `CLAUDE.md` and `Justfile` mirrored; invariants pinned by named tests. diff --git a/justfile b/justfile index b2bf2276..7976f3f2 100644 --- a/justfile +++ b/justfile @@ -12,18 +12,12 @@ lint: uv run ruff check --fix uv run ty check -# CI lint (no autofix) — same checks as `lint` plus the repo-wide link check. +# CI lint (no autofix) — the same checks as `lint`. lint-ci: uv run eof-fixer . --check uv run ruff format --check uv run ruff check --no-fix uv run ty check - uv run python planning/links.py - -# Check every relative Markdown link and heading anchor. `mkdocs --strict` only sees -# docs/; planning/ lives outside docs_dir and is read on GitHub. -check-links: - uv run python planning/links.py # Run pytest with NO coverage (targeted runs won't trip the gate). Passes args through. test *args: diff --git a/mkdocs.yml b/mkdocs.yml index 72f57d76..bdb8fd09 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -79,7 +79,6 @@ nav: - To 3.x: migration/to-3.x.md - From that-depends: migration/from-that-depends.md - From dependency-injector: migration/from-dependency-injector.md - - Changelog: changelog.md - Development: - Contributing: dev/contributing.md diff --git a/planning/README.md b/planning/README.md deleted file mode 100644 index df166db2..00000000 --- a/planning/README.md +++ /dev/null @@ -1,84 +0,0 @@ -# Planning - -The standing record for `modern-di`. The living truth about *what the system -does now* lives in the code itself and in its tests — an enforceable claim is an -`INVARIANT:`-marked test, not a prose page. This directory holds what code and -tests cannot. - -## Quick path (start here) - -**1. Write the spec in the PR body.** `.github/PULL_REQUEST_TEMPLATE.md` carries -the shape — why, design, trade-offs, and the non-goals. There is no change file -to write and nothing to commit: the PR body *is* the spec, reviewed inline with -the diff. A trivial PR (typo, dep bump, formatter, mechanical rename) may delete -the template and ship a conventional-commit title. - -**2. File what outlives the PR:** - -- an alternative you **rejected** with reasoning → an ADR in - [`docs/adr/`](../docs/adr/), numbered `NNNN-slug.md` -- work that is real but **not scheduled** → a GitHub issue (see - [`docs/agents/issue-tracker.md`](../docs/agents/issue-tracker.md)) - -**3. Run `just check-links` before pushing.** - -## Where a fact goes - -Four homes, one owner each: - -| Home | Holds | -|---|---| -| `modern_di/` | anything readable from the module — the default | -| a named test | an **invariant**: must stay true, and a change could silently break it | -| `docs/adr/` | a rejected alternative, with the reasoning that would otherwise be re-litigated | -| `docs/` | anything a user needs | - -Before writing a line anywhere: - -> Can an agent get this by reading `modern_di/`? → **don't write it.** -> Would a wrong change here fail a test? → it belongs **in the test**, not in prose. -> Does a user need it? → **`docs/`**. -> Otherwise it does not get written. - -**Prose about mechanism has no home. There is no file to add a paragraph to.** - -This is deliberate, and it is the second lesson rather than the first. A capability -directory (`architecture/`) was kept for four months and cut to invariants twice — -`b2404c4` (#282, 2026-07-07: 433 deletions against 110 insertions across six pages) -and `047b6ea` (#395, 2026-07-29: 292 against 147 across four) — and regrew both -times. Promotion discipline was not the problem: 72% of commits touching -`modern_di/` also touched it. Every PR added a paragraph that felt load-bearing and -none removed one, so the pages ratcheted toward restating code, and restatement is -what goes stale. The absence of the directory is the mechanism. - -ADRs and `INVARIANT:` docstrings inherit the same risk from the other direction: -nothing prunes a record once its call is settled or a docstring once its claim stops -mattering, so keeping either lean is a standing habit, not a one-time fix. - -An invariant is written as a test whose name is the claim, with a docstring opening -`INVARIANT:` and a second paragraph naming **what breaks it**. That second paragraph -is where an anti-refactor warning lives — design rationale, not a report of what this -one test happens to catch. It does not have to describe a regression that *this* -test alone would fail on; a sibling test may be the one that actually trips. The unit -of truth is the invariant plus the whole suite, not the docstring plus its single -test — the accepted cost is that a reader cannot tell, from one docstring alone, -whether that test or a sibling one catches a given regression. -`tests/test_invariant_census.py` enforces that shape. - -## Artifacts - -- **[`releases/.md`](releases/)** — one file per curated release, from - `_templates/release.md`. Used **verbatim** as the GitHub Release body by - [`release.yml`](../.github/workflows/release.yml), which fails a stable tag - that has no matching file. No frontmatter; the file name is the version. -- **[`_templates/`](_templates/)** — `release.md`. -- **[`scripts/`](scripts/)** — reusable multi-agent audit harnesses. A sweep's - durable output is a PR plus an issue or an ADR; the report itself is transient - and is not committed. -- **`links.py`** — repo-wide Markdown link and heading-anchor check, run by - `just check-links` and by `just lint-ci`. It covers the trees a site builder - never sees. - -This directory holds no work queue. Open work is a GitHub issue and refused work -is an ADR under [`docs/adr/`](../docs/adr/); there is no third state and nothing -here to add a file to. diff --git a/planning/_templates/release.md b/planning/_templates/release.md deleted file mode 100644 index 7372d7e2..00000000 --- a/planning/_templates/release.md +++ /dev/null @@ -1,39 +0,0 @@ -# modern-di - - - - - -## Feature - -- **.** What it adds and how to use it. - -## Fix - -- **.** What was broken, now fixed (reference the issue/regression). - -## Internal refactors - -- **.** What changed under the hood, stated as no behavior change. - -## Packaging - -- Metadata / build / dependency changes visible to installers. - -## Why - -Context a reader needs for the headline change. Omit for small releases. - -## Downstream - -What integrations (FastAPI, Litestar, FastStream, Typer, `modern-di-pytest`) -must do — e.g. bump their `modern-di` floor — or "No action needed" when there -is no API change. - -## Internals - -- Coverage / tooling notes (e.g. 100% line coverage across Python 3.10–3.14). diff --git a/planning/links.py b/planning/links.py deleted file mode 100644 index c11f8618..00000000 --- a/planning/links.py +++ /dev/null @@ -1,148 +0,0 @@ -# ruff: noqa: INP001 # planning/ is not a Python package (this file is vendored into consumers' planning/) -"""Check every relative Markdown link and heading anchor in the repository. - -Run via ``just check-links``. Exists because a site builder only validates the -directory it publishes: a repo's ``architecture/`` and ``planning/`` trees usually -sit outside it, are read on GitHub, and rot silently. In the repo this convention -came from, anchors in ``architecture/`` broke three times in one week, each caught -only by a human re-deriving slugs by hand. - -Slugs follow **GitHub's** algorithm, because that is where these files are read — -including the ones a site builder also publishes. Where the two disagree, the fix -is to change the heading rather than to teach this checker both dialects: a heading -containing an em dash yields ``a--b`` on GitHub (the dash is dropped, both spaces -become hyphens) and ``a-b`` under python-markdown (the whitespace run collapses). - -External links are not fetched; this checks the repository's internal consistency. -A relative link that resolves outside the repository is reported rather than followed: -it is a 404 on GitHub, and whether it resolves on disk depends on what the author -happens to have cloned next to the repo — a verdict a lint gate must never depend on. -""" - -import argparse -import collections -import pathlib -import re -import sys - - -SKIP_DIRS = frozenset({".git", ".venv", ".tox", "site", "node_modules", "__pycache__", ".ruff_cache", ".superpowers"}) -FENCE = re.compile(r"^\s*(```|~~~)") -INLINE_CODE = re.compile(r"(`+).+?\1") # any run of backticks delimits a span: `x`, ``a`b`` -HEADING = re.compile(r"^(#{1,6})\s+(.+?)\s*$") -LINK = re.compile(r"\[[^\]]*\]\(\s*([^)\s]+)(?:\s+\"[^\"]*\")?\s*\)") -EXTERNAL = re.compile(r"^(?:[a-z][a-z0-9+.-]*:|//)", re.IGNORECASE) - - -def repo_root(start: pathlib.Path) -> pathlib.Path: - """Nearest ancestor holding ``.git``, else ``start``. - - Found rather than computed because this file has two homes: the canonical repo's - root, and a consumer's ``planning/`` — a fixed relative depth is wrong in one of them. - """ - for candidate in [start, *start.parents]: - if (candidate / ".git").exists(): - return candidate - return start - - -def strip_fences(text: str) -> str: - """Blank out fenced blocks, keeping line count, so code is never read as a heading.""" - out, fenced = [], False - for line in text.splitlines(): - if FENCE.match(line): - fenced = not fenced - out.append("") - continue - out.append("" if fenced else line) - return "\n".join(out) - - -def link_lines(text: str) -> list[str]: - """Lines with fenced blocks and inline spans removed — what to scan for real links. - - Only link scanning strips inline spans. A page documenting the markup an author should - copy is not linking anywhere, while a heading's backticked content is part of its slug. - """ - return [INLINE_CODE.sub("", line) for line in strip_fences(text).splitlines()] - - -def slugify(heading: str) -> str: - """GitHub's heading slug: drop formatting and punctuation, lowercase, spaces to hyphens.""" - text = re.sub(r"`([^`]*)`", r"\1", heading) - text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text) - # `*` and `~` are emphasis; `_` is kept because GitHub keeps it and headings name - # identifiers (`bound_type`) far more often than they use underscore-italics. - text = re.sub(r"[*~]", "", text) - text = "".join(ch for ch in text.lower() if ch.isalnum() or ch in " -_") - return text.strip().replace(" ", "-") - - -def anchors(text: str) -> set[str]: - """Every anchor a reader can target, including GitHub's ``-1``/``-2`` duplicate suffixes.""" - seen: collections.Counter[str] = collections.Counter() - found: set[str] = set() - for line in strip_fences(text).splitlines(): - match = HEADING.match(line) - if not match: - continue - base = slugify(match.group(2)) - found.add(base if not seen[base] else f"{base}-{seen[base]}") - seen[base] += 1 - return found - - -def check(root: pathlib.Path) -> list[str]: - """Return one message per broken link; empty means every internal link resolves.""" - root = root.resolve() - files = sorted(p for p in root.rglob("*.md") if not SKIP_DIRS & set(p.relative_to(root).parts)) - cache: dict[pathlib.Path, set[str]] = {} - violations: list[str] = [] - for path in files: - text = path.read_text(encoding="utf-8") - for line_no, line in enumerate(link_lines(text), 1): - for target in LINK.findall(line): - if EXTERNAL.match(target): - continue - rel, _, fragment = target.partition("#") - # A bare `#frag` targets this same file — the anchor is still checkable, - # and a same-page link rots exactly like a cross-page one. - dest = (path.parent / rel).resolve() if rel else path - where = f"{path.relative_to(root)}:{line_no}" - if dest != root and root not in dest.parents: - # Judged before existence: a sibling repo cloned alongside this one makes - # ../../../other-repo/… resolve on one machine and nowhere else, and it is - # a 404 on GitHub either way. The verdict must not depend on the checkout layout. - violations.append(f"{where}: leaves the repository -> {rel}") - continue - if not dest.exists(): - violations.append(f"{where}: no such file -> {rel}") - continue - if not fragment or dest.suffix != ".md": - continue - if dest not in cache: - cache[dest] = anchors(dest.read_text(encoding="utf-8")) - if fragment.lower() not in cache[dest]: - violations.append(f"{where}: no such anchor -> {target}") - return violations - - -def main(argv: list[str] | None = None, root: pathlib.Path | None = None) -> int: - """Report every broken link; return 1 if any, else 0.""" - parser = argparse.ArgumentParser(description="Check Markdown links and heading anchors.") - parser.add_argument("--root", type=pathlib.Path, default=None) - args = parser.parse_args(sys.argv[1:] if argv is None else argv) - - target = args.root or root or repo_root(pathlib.Path(__file__).resolve().parent) - violations = check(target) - if violations: - sys.stderr.write(f"links: {len(violations)} broken\n") - for violation in violations: - sys.stderr.write(f" - {violation}\n") - return 1 - sys.stdout.write("links: OK\n") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/planning/releases/2.15.0.md b/planning/releases/2.15.0.md deleted file mode 100644 index cf34f75e..00000000 --- a/planning/releases/2.15.0.md +++ /dev/null @@ -1,66 +0,0 @@ -# modern-di 2.15.0 — Audit-driven correctness pass - -**2.15.0 is mostly additive. One behavior change in `Container.validate()` is called out in [Behavior changes](#behavior-changes).** Code that does not catch `CircularDependencyError` from `validate()` directly continues to work unchanged. - -This release ships ten focused PRs (#188–#197) driven by a single bug-hunt audit of the codebase. Eighteen audit findings — 2 must-fix, 3 should-fix, 13 nice-to-have — are addressed. The remaining 22 audit items were intentional-behavior entries and were not actioned. - -## New features - -- **`Container.validate()` now works across scopes.** Previously, `validate()` (and `validate=True` at construction time) raised `ScopeNotInitializedError` for any provider whose scope was deeper than the root container — making the feature unusable for any realistic web app with `REQUEST`-scoped providers. `Factory.get_dependencies` was refactored into a pure type→provider lookup that no longer requires `find_container`. (#189) -- **`validate()` now catches two new wiring-bug classes at startup.** Inverted-scope dependencies (an `APP`-scoped provider depending on a `REQUEST`-scoped one) raise a new `InvalidScopeDependencyError`. Missing required dependencies (parameter with no provider, no default, no static kwarg) raise the same `ArgumentResolutionError` you'd see at resolve time, just earlier. All issues are accumulated and reported together. (#189) -- **`Group` inheritance is now supported.** Subclassing a `Group` to add providers ("`class TestDeps(AppDeps):`") now correctly registers both the parent's and the child's providers. `Group.get_providers` walks `cls.__mro__` and tracks attribute names so subclass overrides and non-provider masks work the way Python's normal attribute lookup does. (#197) -- **`Factory` validates static `kwargs` against the creator signature at construction time.** A typo like `kwargs={'connetion_string': ...}` previously surfaced at instantiation as a raw `TypeError` with no provider identity. Now `Factory.__init__` raises `UnknownFactoryKwargError(RegistrationError)` immediately, naming the offending keys and using `difflib.get_close_matches` to suggest the closest valid parameter. Validation is skipped when the creator accepts `**kwargs` or when its signature is uninspectable. (#194) -- **`Container(use_lock=False)` is honored across child containers.** `build_child_container` now propagates the parent's `use_lock` setting; the documented single-threaded opt-out no longer silently re-acquires locking at `REQUEST` scope. (#193) -- **`container_provider` works on `Container` subclasses.** Auto-injection of a `Container` parameter (the documented pattern in `docs/providers/container.md`) now resolves correctly even when the user subclasses `Container` — the provider is now registered under the base `Container` class instead of `type(self)`. (#193) -- **`Container.__init__` rejects non-IntEnum `scope` values up front.** A new `InvalidScopeTypeError(ContainerError)` raises at construction with a clear message instead of exploding later from an unrelated `__repr__` call with a bare `AttributeError`. (#193) - -## Correctness fixes - -- **Singleton resolution is now re-entrant.** `Container.lock` switched from `threading.Lock` to `threading.RLock`. The documented `container_provider` auto-injection pattern (where a creator's `__init__` accepts `container: Container` and calls `container.resolve(other_singleton)`) no longer deadlocks the calling thread. (#188) -- **`build_child_container(scope=ZERO_VALUED_INTENUM)` raises instead of silently auto-incrementing.** Custom `IntEnum` members with value 0 are falsy in Python; the previous truthiness check misclassified them as "scope omitted" and fell through to the auto-increment branch. Now uses `is None`. (#190) -- **Self-reference in union-typed parameters falls through to the default instead of deadlocking.** A creator typed `def make(x: int | SelfType = 1)` previously triggered `RecursionError` at resolve time because the union-lookup branch did not check `provider is self`. Now mirrors the single-type guard. (#191) -- **Default parameter is used when its `ContextProvider`'s value is unset.** Previously, `_compile_kwargs` raised `ArgumentResolutionError` immediately on finding an unset `ContextProvider` — without checking whether the parameter had a default. The check is now symmetric with the no-provider branch directly below it. (#192) -- **`Factory` correctly handles `default` values whose `__eq__` returns `True` for all comparands** (e.g., `unittest.mock.ANY`). The UNSET sentinel comparison is now `is` instead of `==`, matching the rest of the codebase. (#194) -- **`CacheItem.close_sync`/`close_async` no longer re-run the finalizer when `clear_cache=False`.** Repeated container shutdowns — or the canonical Resource-replacement pattern from `docs/migration/to-2.x.md` — no longer double-close DB connections. A new `finalized` flag guards the finalizer; `_clear()` resets it when actually clearing the cache so re-resolved values can be finalized again on the next close. (#195) - -## Behavior changes - -**`Container.validate()` now raises `ValidationFailedError` (aggregate) instead of `CircularDependencyError` directly.** (#189) - -The new exception carries an `.errors: list[Exception]` attribute containing every issue found during the walk — cycles, inverted-scope deps, and missing required deps. Single-error runs still go through `ValidationFailedError`; the inner exceptions are typed (`CircularDependencyError`, `InvalidScopeDependencyError`, `ArgumentResolutionError`). Callers who previously caught `CircularDependencyError` directly need to update to either catch the aggregate or unwrap `.errors`: - -```python -# Before -try: - container.validate() -except CircularDependencyError as e: - log.error("cycle: %s", e.cycle_path) - -# After -try: - container.validate() -except ValidationFailedError as e: - for issue in e.errors: - if isinstance(issue, CircularDependencyError): - log.error("cycle: %s", issue.cycle_path) - elif isinstance(issue, InvalidScopeDependencyError): - log.error("inverted scope: %s -> %s", issue.provider, issue.dep_provider) - elif isinstance(issue, ArgumentResolutionError): - log.error("missing dep: %s", issue.arg_name) -``` - -If you only ever called `validate()` and let exceptions propagate at startup, no change is needed. - -## New exceptions - -- `ValidationFailedError(ContainerError)` — aggregate raised by `Container.validate()` when any issues are found. -- `InvalidScopeDependencyError(RegistrationError)` — a provider depends on another with a strictly deeper scope. -- `InvalidScopeTypeError(ContainerError)` — `Container.__init__` received a non-IntEnum `scope` value. -- `UnknownFactoryKwargError(RegistrationError)` — `Factory(kwargs=...)` contains a key not present in the creator's signature. - -## Internals - -- `AbstractProvider` gained an `iter_validation_issues(container) -> Iterable[Exception]` method with a default no-op implementation. `Factory` overrides it to yield missing-dep `ArgumentResolutionError`s; `Container.validate()` collects them. Custom `AbstractProvider` subclasses (if any) continue to work unchanged. -- `Factory` gained an internal `_find_dep_provider(container, v)` helper shared between `get_dependencies` and `_compile_kwargs`. -- `CacheItem` gained a `finalized: bool` field. -- Test suite grew from 125 to 150 tests over this release; 100% coverage maintained throughout. diff --git a/planning/releases/2.16.0.md b/planning/releases/2.16.0.md deleted file mode 100644 index 18f1f9e2..00000000 --- a/planning/releases/2.16.0.md +++ /dev/null @@ -1,60 +0,0 @@ -# modern-di 2.16.0 — Second audit pass: bug fixes, optional-dependency injection, docs - -**2.16.0 is mostly additive. One deliberate behavior change — optional (`X | None`) parameters now inject `None` instead of raising — is called out in [Behavior changes](#behavior-changes).** Code that already registered providers for all its dependencies is unaffected. - -This release ships two PRs (#202, #203) driven by a full code-and-docs audit of the codebase. All audit findings are now resolved; none remain deferred for the main repo. The audit covered 57 findings across bugs, doc/code drift, internals, public-API DX, and documentation gaps. - -## New features - -- **Optional parameters resolve to `None` when unregistered.** A creator parameter annotated `X | None` (or `Optional[X]`) now injects a registered provider for `X` if one exists, and otherwise injects `None` — no provider and no default required. `container.validate()` no longer flags such parameters. The previously-dead `SignatureItem.is_nullable` field is now wired into resolution. Also applies to multi-member optional unions (`A | B | None`). See [Behavior changes](#behavior-changes) for the trade-off. (#203) -- **Closed containers are reusable via the context manager.** Resolving from — or building a child of — a closed container raises the new `ContainerClosedError`. Re-entering the container as a context manager (`with container:` / `async with container:`) reopens it. Instances cached with `CacheSettings(clear_cache=False)` survive the close→reopen cycle and are returned again (same object); `clear_cache=True` instances are finalized at close and rebuilt on the next resolve. This supports patterns like a test broker whose mocks are bound to one instance reused across tests. (#202) -- **Aliases appear in resolution-error chains.** A `ResolutionError` raised while resolving through an `Alias` now includes the alias hop in the rendered dependency chain, instead of jumping straight to the underlying factory. (#203) -- **`validate()` no longer false-positives on decorative Alias scope.** An `Alias` whose (decorative) scope is shallower than its source's scope is no longer flagged with `InvalidScopeDependencyError`, since alias scope never affects resolution. Implemented via the new `AbstractProvider.enforces_dependency_scope` class flag (`Alias` sets it `False`). (#203) *(A residual transitive-scope limitation was recorded as deferred work.)* - -## Correctness fixes - -- **Finalizers run in reverse creation order (true LIFO teardown).** Cached instances are now finalized in the reverse of the order they were created, matching the documented contract. Previously they ran in cache-insertion order, so the docs-recommended warm-up pattern could finalize a dependency before its dependents. (#202) -- **Sync finalizers that return an awaitable are awaited.** A finalizer that is a plain callable returning a coroutine/awaitable is now awaited in `close_async` (and rejected in `close_sync`), instead of being called and the coroutine silently dropped (a resource leak). (#202) -- **`set_context` is honored after a dependent factory has resolved.** Calling `set_context` on a container now invalidates compiled kwargs so subsequent resolves of non-cached factories pick up the new value, instead of permanently baking out an unset `ContextProvider`. (#202) -- **Parameterized generics and positional-only creator params fail clearly at declaration.** `list[Svc]`-style parameterized generics and positional-only parameters now raise `UnsupportedCreatorParameterError` at `Factory(...)` construction (unless supplied via `kwargs` or given a default), instead of silently degrading to the origin type or dying with a raw `TypeError` at resolve. (#202) -- **`get_type_hints` `TypeError` is warn-and-skipped.** A creator whose hints can't be introspected (e.g. `functools.partial` on Python < 3.14) now emits a `UserWarning` and skips wiring with a workaround hint, instead of crashing at declaration. (#202) -- **Honest messages for unannotated and union parameters.** An unannotated parameter now reports "has no usable type annotation" and a union parameter names its members, instead of the misleading "of type None". (#202) -- **`validate()` aggregates dangling-Alias errors.** A dangling `Alias` (bound under a type different from its unregistered source) is now collected into `ValidationFailedError` along with other issues, instead of aborting the whole validation with a bare `AliasSourceNotRegisteredError`. (#202) -- **`Container(parent_container=...)` enforces scope ordering.** The public constructor now raises `InvalidChildScopeError` for a non-increasing scope, the same check `build_child_container` already applied — preventing a silently-shadowed parent and duplicated singletons. (#202) -- **Atomic group registration.** Registering `groups=` is now all-or-nothing: a duplicate-type collision raises `DuplicateProviderTypeError` without leaving earlier providers from the failed batch in the shared registry. (#202) -- **`ProvidersRegistry` is safe under concurrent registration.** Registry mutations are lock-guarded and iteration is snapshot-based, eliminating "dictionary changed size during iteration" crashes when one thread registers while another resolves/validates. (#202) -- **Creator-call `TypeError` carries DI context.** An argument-binding `TypeError` from a `skip_creator_parsing=True` factory with missing kwargs is wrapped in the new `CreatorCallError` (a `ResolutionError`) with the creator name and resolution chain. A `TypeError` raised inside the creator body still propagates unchanged — only genuine wiring failures are wrapped. (#203) - -## Behavior changes - -**Optional (`X | None`) parameters now inject `None` instead of raising.** (#203) - -Previously, a parameter annotated `X | None` (or `Optional[X]`) with no registered provider for `X` and no default raised `ArgumentResolutionError` at resolve, and `validate()` flagged it. Now `None` is injected and `validate()` does not flag it. - -This is a convenience, but it removes a safety net: if you *intended* to register a provider for an optional dependency and forgot, neither `resolve()` nor `validate()` will report it — the parameter silently receives `None`. For dependencies that must always be present, use a non-optional annotation (`dep: X`), which still raises `ArgumentResolutionError` when unregistered and is flagged by `validate()`. - -```python -class Service: - def __init__(self, cache: Cache | None) -> None: # optional - self.cache = cache - - -# Before 2.16.0: resolving Service with no Cache provider -> ArgumentResolutionError -# 2.16.0+: resolving Service with no Cache provider -> Service(cache=None) -``` - -## New exceptions - -- `ContainerClosedError(ContainerError)` — resolving from / building a child of a closed container (re-enter the context manager to reopen). (#202) -- `UnsupportedCreatorParameterError(RegistrationError)` — a creator declares a parameterized-generic or positional-only parameter that cannot be injected. (#202) -- `CreatorCallError(ResolutionError)` — an argument-binding `TypeError` when calling the creator (e.g. `skip_creator_parsing=True` with missing required kwargs). (#203) - -## Internals - -- `AbstractProvider` now declares real `__slots__`; subclasses declare only their own fields, so provider instances no longer carry `__dict__`. -- `AbstractProvider.enforces_dependency_scope: ClassVar[bool] = True` — set `False` on a subclass whose scope is decorative (as `Alias` does) so `validate()` skips the scope-ordering check on its dependency edges. -- `CacheRegistry` tracks creation-completion order (`mark_created`) and finalizes in reverse; `close_sync` retains items whose finalizer is async so a later `close_async` can recover them. -- Coverage flags moved out of pytest `addopts` into `just` recipes: `just test` (no coverage, for targeted runs), `just test-ci` (the gated 100% full run, used by CI), `just test-branch`, and `just bench`. Benchmarks renamed `benchmarks/test_bench_*.py` so they collect; `testpaths = ["tests"]` keeps them out of the default run. -- New docs: `docs/providers/errors-and-exceptions.md` (exception taxonomy) and an open/close/reopen + close-failure expansion in `docs/providers/lifecycle.md`. Numerous drift fixes (real error texts in troubleshooting pages, duplicate-type-raises-not-shadows, context scope rule, creator-signature matrix). -- Two static exception messages moved to `errors.py` templates; dead `TypeVar`s / sentinel / a no-op parse pass removed. -- Test suite grew from 150 to 192 tests; 100% line coverage maintained across Python 3.10–3.14. diff --git a/planning/releases/2.16.1.md b/planning/releases/2.16.1.md deleted file mode 100644 index 615da5ca..00000000 --- a/planning/releases/2.16.1.md +++ /dev/null @@ -1,16 +0,0 @@ -# modern-di 2.16.1 — Restore zero-dependency install - -Patch release. No API or behavior changes. - -## Fix - -- **`import modern_di` no longer requires `typing_extensions` at runtime.** `modern_di/container.py` imported `typing_extensions` unconditionally at module load, even though it is used only for `typing_extensions.Self` in (non-evaluated) type annotations and is not declared as a runtime dependency. On a clean install with no transitively-present `typing_extensions`, `import modern_di` raised `ModuleNotFoundError: No module named 'typing_extensions'`. The import is now guarded by `if typing.TYPE_CHECKING:` (matching `modern_di/group.py`), restoring the documented zero-dependency install. Surfaced by checking the sibling integration repos against 2.16.0 (`modern-di-typer` / `modern-di-pytest` failed on a clean `uv sync`). Pre-existing since the import was introduced; not a 2.16.0 regression. - -## Internals - -- Regression guard: `tests/test_packaging.py` imports `modern_di` and builds a child container in a subprocess with `typing_extensions` blocked, so any future unconditional runtime import of it fails CI. -- 193 tests; 100% line coverage on Python 3.10–3.14. - -## References - -- Release notes for the audit work this patches: [2.16.0](2.16.0.md) diff --git a/planning/releases/2.17.0.md b/planning/releases/2.17.0.md deleted file mode 100644 index b0951d88..00000000 --- a/planning/releases/2.17.0.md +++ /dev/null @@ -1,20 +0,0 @@ -# modern-di 2.17.0 — Alias scope transparency - -Mostly additive. **One behavior change in `Container.validate()`** is called out below. - -## Fix - -- **`validate()` now checks scope ordering transitively through aliases (X-4).** An alias is a transparent redirect — at resolution its source's scope governs where the instance lives. Validation now matches that: a shallow-scoped caller depending *through* an alias on a deeper-scoped source is flagged with `InvalidScopeDependencyError` at validation time, instead of passing `validate()` and failing only at runtime with `ScopeNotInitializedError`. Implemented via a new `AbstractProvider.effective_scope(container)` hook (default `self.scope`; `Alias` follows its source chain). This replaces the internal `enforces_dependency_scope` flag introduced in 2.16.0. - -## Behavior changes - -- **`validate()` may newly raise** `ValidationFailedError(InvalidScopeDependencyError)` for graphs of the shape `Factory(shallow) → Alias → Factory(deeper)` that previously passed validation. These graphs were already broken (they raised `ScopeNotInitializedError` at resolve time); `validate()` now surfaces them up front. No change for correctly-scoped graphs. - -## Deprecations - -- **`Alias(scope=...)` is deprecated.** The parameter never affected resolution and (as of this release) no longer affects validation — an alias's effective scope is derived from its source. Passing `scope=` emits a `DeprecationWarning`; the parameter will be removed in 3.0. - -## Internals - -- Removed the `AbstractProvider.enforces_dependency_scope` ClassVar (superseded by `effective_scope`). Custom transparent providers should override `effective_scope(container)` instead. -- 100% line coverage maintained across Python 3.10–3.14. diff --git a/planning/releases/2.18.0.md b/planning/releases/2.18.0.md deleted file mode 100644 index e61d1e0b..00000000 --- a/planning/releases/2.18.0.md +++ /dev/null @@ -1,68 +0,0 @@ -# modern-di 2.18.0 — Deep-audit fixes: live context resolution, new public API - -Mostly additive and backward-compatible. The headline is a **correctness fix**: `set_context` now -propagates across scopes (the 2.16.0 fix only covered the same-scope case). This release ships the -2026-06-14 deep multi-dimension audit (PRs #216–#220); all actionable findings are resolved. See the -2026-06-14 deep audit. - -## Fixes - -- **`set_context` is honored across scopes, not just the same container (B-1).** A `Factory` bound to - a deeper scope (e.g. `REQUEST`) that reads a shallower-scoped `ContextProvider` (e.g. `APP`) now - picks up a late `set_context` on subsequent resolves. Previously, if the factory first resolved - while the context was unset, the absence was baked into that child container's compiled kwargs and - never refreshed — so the dependency stayed `None`/default forever, with no error. The 2.16.0 fix - invalidated only the calling container's compiled kwargs and so missed this cross-scope case. - ContextProvider values are now resolved **live on every resolve**. (#216) -- **Child-scope auto-derivation works for non-contiguous custom `IntEnum` scopes (B-3).** - `build_child_container()` with no `scope=` now derives the smallest enum member greater than the - current scope, instead of `current + 1`. A gapped custom enum (e.g. `TENANT=6, JOB=10`) no longer - raises a spurious `MaxScopeReachedError`. The stock `Scope` enum is unaffected. (#218) -- **Override of a context-backed parameter applied after first resolve now takes effect.** A - consequence of live context resolution: `container.override(ctx_provider, value)` is honored on the - next resolve even if the consuming factory already resolved once. (#216) - -## New public API - -- **`ContextProvider.fetch_context_value(container)`** — public accessor returning the live context - value or `types.UNSET` when none is set (replaces the former private `_find_context_value`). (#219) -- **`AbstractProvider.display_name`** — the bound type's name, else the provider repr (`Factory` - falls back to the creator name). Used in error messages and resolution steps; useful for custom - providers. (#219) -- **`exceptions` is exported from `modern_di`** and listed in `__all__`, so `from modern_di import - exceptions` is an advertised import and the exception hierarchy surfaces in autocomplete. (#220) - -## Performance - -- **No per-resolve `CacheItem` allocation.** `CacheRegistry.fetch_cache_item` now returns an existing - item without constructing a throwaway one on every cache hit (it previously relied on - `dict.setdefault`, whose default is evaluated eagerly). Creation stays atomic via `setdefault`, so - concurrent first-resolvers of a singleton still share one `CacheItem`. (#218, P-1) - -## Behavior changes - -- **Context-backed parameters resolve live on every resolve.** This is what makes the cross-scope - `set_context` fix work. The only observable effects are corrections (late context, late overrides, - and required context that becomes available after a first failure now resolve correctly). A - **cached** provider (`Factory(cache_settings=...)`) is still built once — a late `set_context` - does not rebuild its instance; set the context before its first resolve. (#216) - -## DX - -- **Every concrete exception now carries a class docstring** naming its public inspection attributes - (`.provider_type`, `.suggestions`, `.original_error`, `.finalizer_errors`, `.is_async`, …) for IDE - hover (X-3). `ResolutionStep` is documented as the `dependency_path` element type (X-5). (#220) -- **Clearer error messages where providers are rendered**, via the shared `display_name` (#219). - -## Internals - -- `CacheRegistry.invalidate_compiled_kwargs` was **removed** (internal API): with context resolved - live, the compiled-kwargs memo is type-matching only and never goes stale, so `set_context` no - longer needs to invalidate anything. Kwargs compilation now sorts parameters into three memoized - buckets — `provider_kwargs`, `static_kwargs`, and a new `context_kwargs` resolved live. (#216) -- The private `ContextProvider._find_context_value` was renamed to the public `fetch_context_value` - (#219). The `bound-type-or-repr` display idiom was de-duplicated onto `display_name` across ~5 - sites (#219, R-1). -- Test suite hardened: a compile-once memoization guard, a behavioral (rather than white-box) - singleton-reopen assertion, and structured-field assertions for suggestion/dependency-path - messages (P-6/R-3/X-2). 100% line coverage maintained across Python 3.10–3.14. (#220) diff --git a/planning/releases/2.19.0.md b/planning/releases/2.19.0.md deleted file mode 100644 index ff272fc2..00000000 --- a/planning/releases/2.19.0.md +++ /dev/null @@ -1,26 +0,0 @@ -# modern-di 2.19.0 — Public `Container.open()` - -Purely additive. One new public method; no behavior changes to existing code. - -## Feature - -- **`Container.open()` reopens a closed container.** Sets `closed = False` so a closed container can resolve and build children again. `__enter__`/`__aenter__` now call `open()` instead of clearing the flag inline. Reopening an already-open container is a no-op. - -## Why - -`__enter__`/`__aenter__` already reopened containers on re-entry, but only through the context-manager protocol. Callback-style framework lifecycles can't wrap a long-lived root container in a `with` block — e.g. FastStream exposes `on_startup`/`after_shutdown` hooks, not a context manager. Without a public reopen entry point, those integrations closed the root at shutdown and never reopened it, so a second startup cycle (broker restart, repeated test lifespans) raised `ContainerClosedError` on the first resolve. - -`open()` gives such integrations a clean, public reopen: - -```python -app.on_startup(container.open) -app.after_shutdown(container.close_async) -``` - -## Downstream - -Unblocks the closed-state reopen fixes in the FastAPI, Litestar, and FastStream integrations, all of which bump their floor to `modern-di>=2.19.0`. - -## Internals - -- 100% line coverage maintained across Python 3.10–3.14. diff --git a/planning/releases/2.19.1.md b/planning/releases/2.19.1.md deleted file mode 100644 index e4b903c2..00000000 --- a/planning/releases/2.19.1.md +++ /dev/null @@ -1,21 +0,0 @@ -# modern-di 2.19.1 — Internal deepening, no behavior change - -A maintenance release: three behavior-preserving refactors that improve testability and locality, plus richer PyPI metadata. No public API or behavior changes — every error message and resolution result is identical to 2.19.0. - -## Internal refactors - -- **`WiringPlan` extracted from `Factory`.** The kwarg-wiring decision — match each creator parameter to a provider, else omit / inject `None` / raise — moved out of four separate methods (with the absent-value rule hand-copied three times) into one pure module, `modern_di/wiring.py`. It is now unit-testable without a `Container`, and `CacheItem` holds a single `WiringPlan` instead of four compiled-kwargs fields. -- **Error messages inlined; `errors.py` removed.** The 1:1 message-template indirection is gone — each error message is now an f-string in the exception class that raises it, so the message lives with its raise. Messages are byte-for-byte unchanged (verified by a full before/after dump across every exception). -- **New `suggester` module.** The shared `difflib` "did you mean?" fuzzy match — registry type suggestions and factory unknown-kwarg typos — is now one directly-tested `close_matches` primitive, with the similarity cutoff living in a single place. - -## Packaging - -- Richer PyPI metadata: keywords, trove classifiers (including Python 3.14), and project URLs. - -## Downstream - -No action needed. There is no API change, so the FastAPI, Litestar, FastStream, Typer, and `modern-di-pytest` integrations do **not** need to bump their `modern-di` floor. - -## Internals - -- 100% line coverage maintained across Python 3.10–3.14; `ruff` and `ty` clean. diff --git a/planning/releases/2.19.2.md b/planning/releases/2.19.2.md deleted file mode 100644 index 0a3426b8..00000000 --- a/planning/releases/2.19.2.md +++ /dev/null @@ -1,20 +0,0 @@ -# modern-di 2.19.2 — Docs and tag-driven releases, no code change - -Maintenance release. **No API or behavior changes** — the installed package is byte-for-byte identical to 2.19.1. It ships new documentation and moves the release process to a tag-driven workflow. - -## Documentation - -- **Exploratory roadmap** (`ROADMAP.md`) sketching possible future directions. -- **Comparison pages** — a feature comparison and a "that-depends or modern-di?" guide to help choose between the two. - -## Release tooling - -- **Releases are now tag-driven.** Pushing a bare semver tag (e.g. `2.19.2`) publishes to PyPI and creates the matching GitHub Release in one workflow, replacing the previous "publish a GitHub Release to trigger PyPI" flow. Pre-release tags use the PEP 440 form (`2.0.0rc1`). No change for installers. - -## Downstream - -No action needed. There is no API change, so the FastAPI, Litestar, FastStream, Typer, and `modern-di-pytest` integrations do **not** need to bump their `modern-di` floor. - -## Internals - -- 100% line coverage maintained across Python 3.10–3.14; `ruff` and `ty` clean. diff --git a/planning/releases/2.20.0.md b/planning/releases/2.20.0.md deleted file mode 100644 index 25f07b62..00000000 --- a/planning/releases/2.20.0.md +++ /dev/null @@ -1,27 +0,0 @@ -# modern-di 2.20.0 — `Group.get_named_providers()` - -Purely additive. One new public method; the contract of existing code is unchanged. - -## Feature - -- **`Group.get_named_providers() -> dict[str, AbstractProvider]`** — an MRO-walking accessor that maps each declared attribute name to its provider. `Group.get_providers()` is now `list(cls.get_named_providers().values())`, so the traversal and dedup/masking logic lives in one place. - -The new method preserves the exact semantics of the old `get_providers()` traversal: - -- MRO order (most-derived first) -- first-seen name wins (diamond inheritance returns each provider once) -- a non-provider override masks the parent provider of the same name - -`get_providers()`'s contract (return type, order, dedup, masking) is unchanged. - -## Why - -`get_providers()` discarded the attribute name each provider was declared under. Downstream integrations that need names (notably `modern-di-litestar`'s autowiring) reconstructed them with a fragile `id()`-keyed reverse lookup over `group.__dict__`. That lookup only sees the subclass `__dict__` while `get_providers()` walks the full MRO, so autowiring a `Group` that **inherits** a provider raised `KeyError`. Exposing names at the source — where `Group` owns provider declaration and traversal — fixes the bug for every consumer. - -## Downstream - -Unblocks the `modern-di-litestar` autowiring fix, which consumes `get_named_providers()` and bumps its floor to `modern-di>=2.20.0`. The FastAPI, FastStream, Typer, and `modern-di-pytest` integrations do **not** need to bump. - -## Internals - -- 100% line coverage maintained across Python 3.10–3.14; `ruff` and `ty` clean. diff --git a/planning/releases/2.21.0.md b/planning/releases/2.21.0.md deleted file mode 100644 index 29514da7..00000000 --- a/planning/releases/2.21.0.md +++ /dev/null @@ -1,21 +0,0 @@ -# modern-di 2.21.0 — `ContextProvider.context_type` - -Purely additive. One newly-public attribute; the contract of existing code is unchanged. - -## Feature - -- **`ContextProvider.context_type`** is now a public attribute — the type the provider supplies and the key its value is set under in `context`. It was stored privately (`_context_type`) with no accessor. - -Implemented as a plain public slot, matching its sibling `bound_type` (both derive from the same constructor argument) and the base provider's public `scope` / `bound_type`. Properties in the providers are reserved for *derived* values (`display_name`); `context_type` is stored config, so it is a plain attribute. - -## Why - -Framework integrations build a connection → scope → context-key mapping. Without a public `context_type` they re-state, in an `isinstance` ladder, the very type they already passed into the provider — so the connection-kind knowledge is split across two places. Exposing `context_type` lets an integration drive that dispatch off the provider objects themselves, single-sourcing the mapping. - -## Downstream - -No action required. Integrations that want to single-source their connection-kind dispatch (the `modern-di-fastapi` refactor, and the same pattern in `modern-di-litestar` / `modern-di-faststream` / `modern-di-typer`) can read `provider.context_type` and bump their floor to `modern-di>=2.21.0`. Nothing breaks for consumers that don't. - -## Internals - -- 100% line coverage maintained across Python 3.10–3.14; `ruff` and `ty` clean. diff --git a/planning/releases/2.21.1.md b/planning/releases/2.21.1.md deleted file mode 100644 index a8f796a2..00000000 --- a/planning/releases/2.21.1.md +++ /dev/null @@ -1,11 +0,0 @@ -# modern-di 2.21.1 — release pipeline on PyPI Trusted Publishing - -No library changes. The package is identical to 2.21.0; this release exercises the new publish path end-to-end. - -## CI - -- Releases now authenticate to PyPI via **Trusted Publishing (OIDC)** instead of a long-lived `PYPI_TOKEN` secret. `uv publish` auto-detects the GitHub Actions id-token; the release job runs under a `pypi` environment that scopes the trusted publisher (#251). - -## Downstream - -No action required. Nothing about the installed package changes. diff --git a/planning/releases/2.22.0.md b/planning/releases/2.22.0.md deleted file mode 100644 index fadbedf6..00000000 --- a/planning/releases/2.22.0.md +++ /dev/null @@ -1,66 +0,0 @@ -# modern-di 2.22.0 — ergonomic `Factory(cache=…)`; closed-container reuse softened to a deprecation - -Two user-facing changes. `Factory` gains a `cache=` toggle so the common "just -cache it" case is `cache=True` instead of `cache_settings=CacheSettings()`, and -`cache_settings=` becomes a deprecated alias. Separately, reusing a closed -container no longer raises — it emits a `ContainerClosedWarning` and self-reopens, -restoring pre-2.16 behavior; the hard `ContainerClosedError` returns in **3.0**. - -## Feature - -- **`Factory(cache=…)` — one axis for the whole caching spectrum** (#259). - Enabling a cached singleton used to require constructing an empty settings - object: `Factory(scope=Scope.APP, creator=Database, cache_settings=CacheSettings())`. - Now: - - `cache=True` — cached with defaults (the common case). - - `cache=CacheSettings(...)` — cached and tuned (finalizer, `clear_cache`). - - `cache=False` / `cache=None` / omitted — not cached. - - `CacheSettings` and all resolution/finalizer/lifecycle behavior are unchanged; - the sugar lives entirely in `Factory.__init__`. No `Singleton` class was added. - -## Fix - -- **Closed-container reuse is transitional again** (#261). The - `ContainerClosedError` introduced in 2.16.0 (#202) was a breaking change for - lifecycles that close then keep resolving. `resolve` / `build_child_container` - (and nested providers resolving at a closed ancestor scope) now warn - (`exceptions.ContainerClosedWarning`, a `DeprecationWarning`) and self-reopen - instead of raising. The warning fires once per container per closed→reopened - transition — a resolve that crosses several distinct closed containers emits - one warning per container. -- **Concurrency note.** Under concurrent close + reuse without external - synchronization, a resolving thread may now self-reopen and rebuild after - another thread's finalizers already ran, matching pre-2.16 behavior instead - of the 2.16–2.21 raise. This is user-managed synchronization territory, not - a new bug. - -## Deprecations - -- **`cache_settings=` on `Factory`** (#259) — use `cache=` instead - (`cache=True` for defaults, `cache=CacheSettings(...)` to tune). The old - kwarg still works but emits a `DeprecationWarning` and will be removed in a - future release. Passing both `cache=` and `cache_settings=` is a `TypeError`. -- **Reusing a closed container** (#261) — deprecated; it will raise - `ContainerClosedError` in modern-di 3.0. Wrap the container in `with` / - `async with`, or call `open()`, before reuse. To fail fast today: - `warnings.filterwarnings("error", category=exceptions.ContainerClosedWarning)`. - -## Docs - -- New integration usage guides for **aiohttp** (#257, #258) and **Starlette** - (#254, #255), a **"Writing an integration"** guide (#253), and an - accuracy/integration-parity pass across the docs set (#260). The retired, - separately-maintained `skills/modern-di/` agent-skill set was removed (#256). - -## Downstream - -No action required to keep working. Migrating off the deprecations is -mechanical: rename `cache_settings=X` to `cache=X` (or `cache=True` where you -passed an empty `CacheSettings()`), and reopen a container (`with` / `open()`) -before reusing it after close. Integrations that reopen the root container on -startup are already on the recommended path. - -## Internals - -- 100% line coverage across supported Python versions retained. diff --git a/planning/releases/2.23.0.md b/planning/releases/2.23.0.md deleted file mode 100644 index 8dadfa87..00000000 --- a/planning/releases/2.23.0.md +++ /dev/null @@ -1,19 +0,0 @@ -# modern-di 2.23.0 — private `Container` internals - -Back-compatible. Two `Container` attributes become private, with deprecated aliases; one member is reclassified as a supported extension point. - -## Deprecation - -- **`Container.scope_map` and `Container.lock` are now private** (`_scope_map` / `_lock`). Reading the old names still works but emits `DeprecationWarning` and will be removed in a future release. These attributes were already documented "internal, no stability guarantee"; the thread-safety knob `use_lock=` and the `Container(...)` constructor are unchanged. - -## Clarification - -- **`find_container(scope)` is a supported extension point.** It is the primitive a custom `AbstractProvider.resolve` calls to locate the container at its scope, and is now documented as such (moved out of the internals section). - -## Downstream - -No action required. Nothing in the official integrations reads `scope_map` or `lock`. Advanced users who inspected them should switch to `_scope_map` / `_lock` (or, for scope lookup, `find_container`). - -## Internals - -- 100% line coverage maintained across Python 3.10–3.14; `ruff` and `ty` clean. diff --git a/planning/releases/2.25.0.md b/planning/releases/2.25.0.md deleted file mode 100644 index 3647774e..00000000 --- a/planning/releases/2.25.0.md +++ /dev/null @@ -1,117 +0,0 @@ -# modern-di 2.25.0 — error diagnostics, transitional warnings, and blessed integration seams - -Back-compatible: no behavior flips. `Container` gains the two methods framework -integrations have been reaching into internals for — a post-construction -registration verb and a provider-or-type resolve dispatch — plus a correctness -fix that makes registration safe in any order relative to resolution. - -This release also carries the error-diagnostics and 3.0-transition work that was -prepared as 2.24.0. That version was never published: the changes shipped here, -so 2.24.0 does not exist on PyPI and has no GitHub Release. - -## Feature - -- **`Container.add_providers(*providers)`.** The blessed way for integrations - (and applications) to register providers after the container is built — - previously only possible by reaching into `container.providers_registry`. - Root-only: called on a child container it raises the new - `ChildContainerRegistrationError` (inspect `.scope`), because registries are - shared tree-wide. On a container that has validated (constructed with - `validate=True`, or after a manual `validate()`), the batch is re-validated - immediately, so wiring errors surface at the registration site — and the - call is **atomic**: if re-validation fails for any reason, the whole batch - is rolled back and the container is exactly as it was. -- **`Container.resolve_dependency(dep)`.** One entry point that accepts a - provider *or* a type and dispatches to `resolve_provider`/`resolve` — - the `isinstance` dance every integration's marker handling re-implements - today, now in core. Overrides, caching, scope checks, and "did you mean" - suggestions behave exactly as on the underlying paths. -- **Runtime cycle guard.** An unvalidated circular graph no longer dies with a raw `RecursionError` - on first resolve: `resolve()` now raises `CircularDependencyError` with the full cycle path (the - original `RecursionError` is kept as `__cause__`). A creator that merely recurses on its own — with - no cycle in the provider graph — still raises the original `RecursionError` unchanged. Zero cost on - the happy path; `validate()` remains the way to see all graph errors before first resolve. -- **Scope errors carry dependency chains.** `ScopeNotInitializedError` and `ScopeSkippedError` now - render the same breadcrumb chain as resolution errors when they propagate through factories, naming - both ends of a captive dependency (previously: two scope names, no provider names). Raised directly, - their messages are byte-identical to before; both remain `ContainerError` subclasses. -- **Grouped validation reports.** `ValidationFailedError` now groups its errors by kind with per-group - counts and indents multi-line sub-errors (so "Did you mean" suggestion blocks render intact), and - `CircularDependencyError` draws the cycle as a multi-line arrow chain instead of an inline - `A -> B -> A` string. Attributes (`.errors`, `.cycle_path`) and exception types are unchanged — - only the rendered message. **Note for test suites:** `str()` shapes of these two exceptions changed; - `pytest.raises(match=...)` against the old one-line texts must be updated (substring assertions on - the summary header still hold). -- **[Migration guide to 3.x](https://modern-di.modern-python.org/migration/to-3.x/).** All five 3.0 - switches with before/after code, plus a readiness recipe: escalate `DeprecationWarning` and - `FutureWarning` to errors and a green 2.x suite guarantees a clean 3.0 upgrade. - -## Deprecation - -- **`UnvalidatedContainerWarning` (a `FutureWarning`).** `Container(validate=)` is now tri-state - (`bool | None = None`). Building a **root** container without an explicit `validate` argument warns: - modern-di 3.0 runs `validate()` at root construction by default. Pass `validate=True` to adopt the - 3.0 behavior now, or `validate=False` to keep validation off — an explicit `False` stays valid and - silent, also after 3.0. Child containers never warn. -- **`ContextValueNoneWarning` (a `DeprecationWarning`).** Directly resolving an unset - `ContextProvider` still returns `None` but now warns: modern-di 3.0 raises the new - `ContextValueNotSetError` there instead (the class ships in this release, unraised, so you can - target it in `except` clauses today). Dependent-parameter behavior is unchanged — parameters with a - default or `| None` annotation are satisfied exactly as before, without warnings. - -## Fix - -- **Wiring plans rebuild after registration.** A provider's memoized wiring - plan is now stamped with the providers-registry version and rebuilt when - registration has changed the registry. Previously, a provider resolved - *before* a later registration kept its stale plan silently — an optional - dependency stayed `None`, a missing required one kept raising - `ArgumentResolutionError` even after its provider was registered. This - affected the old `providers_registry` reach-in path too. - -## Why - -INT-1 and INT-2 from the 2026-07-05 3.0 UX research: all seven official -integrations reach two attributes deep to register context providers, and all -seven copy-paste the same marker dispatch. Blessing both as `Container` -methods gives the 3.0 surface a stable integration contract (and, per .NET / -dishka precedent, one place for its semantics). Registration on a validated -container re-validating by default keeps the seam correct under 3.0's -validate-at-construction. - -On the transition side: 3.0 flips five switches, all now warned about on 2.x — the three previously -announced (`ContainerClosedError`, `Alias(scope=)` removal, `Factory(cache_settings=)` removal) plus -the two added here (default-on validation, raise on unset context value), as ruled in the same -research. The house pattern is to warn one full cycle ahead so `filterwarnings("error")` makes the -upgrade mechanical — the migration guide documents the exact recipe. - -## Downstream - -No floor bump required — the `providers_registry` reach-in keeps working in -2.x. Integrations **should migrate** to `add_providers` / -`resolve_dependency` (and bump their floor to `modern-di>=2.25` when they -do); the reach-in path is slated for privatization after the org-wide -migration. The -[writing-integrations spec](https://modern-di.modern-python.org/integrations/writing-integrations/) -already documents the new contract points. - -No integration floor bump is required for the new warnings either: none of the official integrations -constructs a root container internally (audited — the root always comes from your application code, so -the new `FutureWarning` always points at a line you own). The integrations' own test suites and READMEs -already pass explicit `validate=` org-wide. Applications: add `validate=True` (recommended) or -`validate=False` to root `Container(...)` constructions to silence the warning. - -## Internals - -- Docs site deduplicated: every core concept now has one canonical page with - links elsewhere; the historical 0.x→1.x and 1.x→2.x guides are condensed. - Two stub pages merged away (`testing/fixtures`, - `introduction/that-depends-or-modern-di` — their URLs now 404; content - lives in `integrations/pytest` and `introduction/comparison`). -- Docstring policy pass over the package: internal helpers carry one-line - contracts; two stale references to long-removed functions fixed. No - behavior change. -- Every docs sample now constructs root containers with explicit `validate=` — copy-pasted samples - never trigger the new warning. -- 100% line coverage maintained across Python 3.10–3.14; `ruff` and `ty` - clean; docs links and anchors validated under `mkdocs --strict` in CI. diff --git a/planning/releases/2.26.0.md b/planning/releases/2.26.0.md deleted file mode 100644 index 50c551a4..00000000 --- a/planning/releases/2.26.0.md +++ /dev/null @@ -1,50 +0,0 @@ -# modern-di 2.26.0 — every exception links its troubleshooting page - -Back-compatible: no behavior flips, no new warnings, no attribute or -hierarchy changes. The only change is message text — every exception now -ends with a stable docs URL, and the docs grew a page for each one. - -## Feature - -- **Stable per-exception docs URLs (ERR-4).** Every concrete - `ModernDIError` subclass carries a class-level `docs_slug`, and its - message now ends with a uniform final line: - `See: https://modern-di.modern-python.org/troubleshooting//`. - The trailer composes with every message shape — plain errors, - dependency-path breadcrumbs, and `ValidationFailedError`'s grouped - report alike (path first, trailer always last). - `DuplicateProviderTypeError`'s hand-rolled inline URL is replaced by - the same mechanism (URL unchanged). -- **Complete troubleshooting registry (DOC-6).** 16 new pages under - `/troubleshooting/` — one per concrete exception, 21 in total — each - with Symptom, Cause, Fix, and escape hatches where real. The - [exception catalog](https://modern-di.modern-python.org/providers/errors-and-exceptions/) - links each entry to its page. -- **Warnings link their exact migration section.** The three transitional - warnings (`UnvalidatedContainerWarning`, `ContainerClosedWarning`, - `ContextValueNoneWarning`) now link the specific - [to-3.x guide](https://modern-di.modern-python.org/migration/to-3.x/) - section anchor for their switch instead of the guide's landing page. - -## Why - -ERR-4 and DOC-6 from the 2026-07-05 3.0 UX research: a user holding a -production traceback had no stable link to follow — of ~20 exception -classes, only one carried a URL. Field precedent: Angular's NGxxxx -registry, Spring's Description/Action reports, wireup's remedy+URL -messages. A census test now walks `modern_di.exceptions` and asserts -every concrete class has a unique slug and an existing page — new -exceptions cannot ship without one. - -## Downstream - -Tests asserting **exact** exception messages will break on the new -trailer line. As with 2.25.0: prefer `pytest.raises(..., match=...)` -with a substring — the trailer is a separate final line, so substring -matches survive. No floor bumps required. - -## Internals - -- 100% line coverage maintained across Python 3.10–3.14; `ruff` and - `ty` clean; docs built under `mkdocs --strict` in CI (which validates - every slug URL has a page). diff --git a/planning/releases/2.27.0.md b/planning/releases/2.27.0.md deleted file mode 100644 index 289b6827..00000000 --- a/planning/releases/2.27.0.md +++ /dev/null @@ -1,75 +0,0 @@ -# modern-di 2.27.0 — shorter registrations, self-resetting overrides, anchored errors - -Back-compatible: no behavior flips, no new warnings. Four features from the -3.0 UX research land together — the registration line gets shorter twice, -test overrides clean up after themselves, and error messages now point at -the source line that declared the failing provider. - -## Feature - -- **Positional subject arguments (API-5).** `Factory`, `ContextProvider`, - and `Alias` accept their subject as the first positional argument — - `providers.Factory(UserRepository)`, - `providers.ContextProvider(HttpRequest)`, `providers.Alias(Concrete)`. - The keyword spellings (`creator=`, `context_type=`, `source_type=`) - remain first-class; nothing is deprecated. Docs teach the positional - form throughout. -- **Group-level default scope (API-4).** A `Group` subclass may declare its - members' default scope: - - ```python - class RequestGroup(Group, scope=Scope.REQUEST): - repo = providers.Factory(UserRepository) # takes REQUEST - audit = providers.Factory(AuditLog, scope=Scope.APP) # explicit wins - ``` - - Priority: explicit `scope=` > the group's kwarg (inherited via MRO; - a subclass's kwarg applies to providers declared in its own body) > - `Scope.APP`. `Alias` never participates (its scope derives from its - source). A scope-defaulted provider shared by two groups with different - defaults raises the new `GroupScopeConflictError` at class creation — - import order never decides a provider's scope. -- **Context-manager override (INT-4).** `container.override(provider, obj)` - still applies immediately and now returns an `OverrideHandle`: - - ```python - with container.override(MyGroup.api_client, mock_client) as client: - ... # resolution returns mock_client - # prior override state restored here — even on exception - ``` - - Exit restores the snapshot taken at the call (a previously stacked - override, or none); nested overrides of the same provider unwind in - order. Imperative `override()`/`reset_override()` callers are unaffected. -- **Definition-site anchors on error paths (ERR-6).** Breadcrumb lines and - cycle hops now end with the creator's declaration site: - - ``` - Cannot resolve dependency chain: - REQUEST UserService (app.services:17) - APP └─> SessionFactory (app.db:42) - ``` - - Captured lazily on the error path only (zero import/resolution cost), - memoized, with silent fallback for callables without source. Cycle - errors expose the parallel `.cycle_locations`; `.cycle_path` stays bare - type names. - -## Downstream - -- **Tests asserting exact error messages** will break on the new trailing - `(module:line)` anchors in breadcrumb and cycle lines. As with 2.25.0 - and 2.26.0: prefer `pytest.raises(..., match=...)` with a substring — - anchors are trailing additions, so substring matches survive. -- `Container.override` now returns an `OverrideHandle` instead of `None` — - invisible to callers that ignore the return value. -- New exception: `GroupScopeConflictError` (a `RegistrationError`), with - its [troubleshooting page](https://modern-di.modern-python.org/troubleshooting/group-scope-conflict-error/). -- No floor bumps required. - -## Internals - -- The eager warm-up capability (API-8/INT-3, `init_cache()`-style) is - explicitly deferred with a revisit trigger recorded in planning. -- 100% line coverage maintained across Python 3.10–3.14; `ruff` and `ty` - clean; docs built under `mkdocs --strict` in CI. diff --git a/planning/releases/2.28.0.md b/planning/releases/2.28.0.md deleted file mode 100644 index e21bc11a..00000000 --- a/planning/releases/2.28.0.md +++ /dev/null @@ -1,65 +0,0 @@ -# modern-di 2.28.0 — the integration kit - -Back-compatible: no behavior flips, no new warnings, no changes to any -existing public symbol. This release adds one new module, -`modern_di.integrations`, for framework-integration authors — nothing else -changes. - -## Feature - -- **`modern_di.integrations` — shared primitives for building an - integration.** Every framework adapter (FastAPI, Starlette, gRPC, ...) has - hand-rolled the same framework-agnostic skeleton: deriving a child - container's scope/context from a connection, and scanning a handler's - `Annotated` hints for DI markers. This module extracts that skeleton so - adapters can compose it instead of duplicating it. - - Connection derivation — never wraps `Container.build_child_container` - itself, only computes what to pass it: - - ```python - from modern_di import integrations - - match = integrations.classify_connection(connection, (request_provider, websocket_provider)) - child = root.build_child_container( - scope=match.scope if match else None, - context=match.context if match else None, - ) - ``` - - For a single connection kind with no dispatch, `integrations.bind(provider, connection)` - skips straight to the derivation. - - The `Annotated`-marker injector: - - ```python - from modern_di import integrations - - service: typing.Annotated[Service, integrations.from_di(Deps.service)] - - markers = integrations.parse_markers(handler) # decoration time - resolved = integrations.resolve_markers(child, markers) # call time - ``` - - `integrations.is_injected`/`mark_injected` guard against double-wrapping a - handler an auto-inject sweep visits more than once. - - See [architecture/integration-kit.md](https://github.com/modern-python/modern-di/blob/2.28.0/architecture/integration-kit.md) - for the full design, and the updated - [writing-integrations guide](https://modern-di.modern-python.org/integrations/writing-integrations/) - for how an adapter composes these. - -## Downstream - -- **No action needed for existing integrations** — this release changes - nothing they already depend on. -- **Integration authors adopting the kit** (starlette is next, one adapter - per PR per the rollout plan): bump the floor to `modern-di>=2.28,<3` to - import `modern_di.integrations`. - -## Internals - -- 100% line coverage maintained across Python 3.10–3.14; `ruff` and `ty` - clean; docs built under `mkdocs --strict` in CI. -- Built via `subagent-driven-development`: 9 planned tasks, each with an - independent spec+quality review; a whole-branch review before merge. diff --git a/planning/releases/2.29.0.md b/planning/releases/2.29.0.md deleted file mode 100644 index 145836b4..00000000 --- a/planning/releases/2.29.0.md +++ /dev/null @@ -1,95 +0,0 @@ -# modern-di 2.29.0 — the compiled resolver - -Resolution was rebuilt onto a single-path compiled-closure resolver: measurably -faster on every hot-path shape, with two correctness fixes to how creator -defaults and nullable parameters are wired. **One breaking change** — the -provider-type set is now closed; subclassing `AbstractProvider` (or `Factory`) -to add a provider type is no longer supported. See below. - -## Performance - -- **Single-path compiled resolver (#334).** The interpreted resolve recursion - is replaced by one per-provider compiled closure, memoized on - `ProvidersRegistry` alongside the wiring plan. Each closure front-guards its - own override, navigates to its target scope once (same-scope dependencies skip - via an int compare), and inlines the kwargs build and creator call. The shipped - tree has exactly one resolve path. - - Measured against the prior path (guard-tier medians): transient resolve - **-37%**, warm-cached singleton **-31%**, deep dependency chain **-61%**, wide - fan-out **-62%**; lifecycle -6.5%, `build_child_container` ~flat (no - regression). modern-di stays the only zero-dependency pure-Python framework in - its comparative tier holding its own against the codegen frameworks. - -- **Per-node hot-path tax trimmed (A1–A4).** Redundant per-node framework - overhead removed from the resolve path, feeding the numbers above. - -- **Wiring plans memoized on the registry (#326).** A creator's parameter - partition is computed once and shared tree-wide, so child containers reuse the - parent's plan instead of re-parsing signatures per resolve. - -## Fixes - -- **A `ContextProvider` passed via `kwargs={...}` now honors the creator's - default (#340).** When a context value was unset, a provider wired explicitly - through `kwargs=` bypassed the creator's default and fell through to the direct - `ContextProvider.resolve` path — which returned `None` (with a - `ContextValueNoneWarning`) instead of omitting the parameter so its default - applied. It now routes through the same dependent-parameter path as by-type - wiring: an unset context value with a creator default omits the parameter, a - nullable annotation injects `None`, and a present value is injected. This is - the warning users saw on optional-`Request` creators like - `def choose_engine(*, ..., request: fastapi.Request | None = None)`. - -- **`NoneType` parameters keep their default and nullability (#321).** A - parameter annotated `None`/`NoneType` no longer loses its creator default or - its nullable disposition during signature parsing. - -- **`validate()` traverses providers supplied via `kwargs=` (#320).** Providers - declared through `kwargs={...}` are now part of the graph `validate()` walks, - so cycle and transitive-scope checks cover them like type-wired dependencies. - -## Breaking change - -- **The provider-type set is closed; custom providers are no longer supported.** - `Factory`, `Alias`, `ContextProvider`, and the pre-built `container_provider` - are the only provider types. The compiled resolver (#334) dispatches by exact - type identity, so a subclass of `AbstractProvider` — **or of `Factory`** — - raises `TypeError` at its first resolve. Note the failure mode: `validate()` - walks the dependency graph but compilation is lazy, so a container built with - `validate=True` reports clean and then raises `TypeError` on the first resolve - under traffic. - - Custom provider support was never a designed capability — it was an emergent - property of the old polymorphic `provider.resolve(self)` dispatch, and a - now-removed docs section described that accident. If you relied on it, move the - behavior into a creator function, or use `Alias`, instead of introducing a - provider type. Rationale and the rejected alternatives (a 2.x deprecation ramp, - holding for 3.0) are recorded in - [`docs/adr/0013-custom-providers-retracted.md`](https://github.com/modern-python/modern-di/blob/main/docs/adr/0013-custom-providers-retracted.md). - -## Docs & internals - -- The "Subclassing `AbstractProvider`" extension point is retracted from the - advanced-API docs, replaced by a note stating the closed set and the failure - mode above (#341, #338). -- Curated core-terms glossary (#329); corrected `architecture/resolution.md` - after the edge-set unification (#322); `CLAUDE.md` message-ownership claim - corrected (#323). -- Internal refactors with no behavior change: exceptions own every rendering - glyph, raise sites carry only facts (#324); `Scope` gets its own algebra - (#325); `CacheItem` owns the singleton get-or-create invariant (#314); the - override short-circuit folds into `OverridesRegistry` (#315); the suggester - owns finding a suggestion, the registry is storage (#327); - `ProvidersRegistry` owns its validation-freshness check (#339). -- 100% line coverage maintained across Python 3.10–3.14; `ruff` and `ty` clean; - docs built under `mkdocs --strict` in CI. - -## Downstream - -- **Existing integrations:** no action needed — none of the sibling - `modern-di-*` adapters subclass a provider type; the breaking change does not - reach them. -- **If you subclass `AbstractProvider` or `Factory`** in your own code: this - release breaks that on first resolve. Replace the custom provider with a - creator function or an `Alias` before upgrading. diff --git a/planning/releases/2.30.0.md b/planning/releases/2.30.0.md deleted file mode 100644 index ca54674c..00000000 --- a/planning/releases/2.30.0.md +++ /dev/null @@ -1,65 +0,0 @@ -# modern-di 2.30.0 — free-threaded (Beta), and a concurrency fix - -modern-di now declares free-threaded CPython (PEP 703) support at the Beta trove -level — and, found while verifying it, this release fixes a real concurrency bug -that affects **any** supported Python: two threads first-resolving the same -provider concurrently could raise a spurious `RecursionError`. The fix leads. - -## Fixes - -- **Concurrent first-resolution of the same provider no longer false-cycles - (#344).** `ProvidersRegistry`'s cycle-guard set (`_building`) was shared across - threads, so when two threads resolved the same provider for the first time at - once, the second could mistake the first's in-flight compile for a dependency - cycle, take the runtime back-edge, and recurse to `RecursionError` — on a graph - with no cycle at all. The guard is now **thread-local**: it tracks - compilation per call stack, so a genuine same-thread `A → B → A` cycle is still - detected via the back-edge, while a concurrent first-resolve simply compiles - independently (an idempotent duplicate, last-writer-wins on the shared cache). - This reproduces **under the GIL too** — it is not specific to free-threaded - builds, so any multithreaded resolve on any supported Python benefits. A - deterministic regression test (a compile-stall gate, not timing) guards it on - every interpreter, so a revert fails CI on all cells. - -## Free-threaded (PEP 703) support — Beta - -- **`Programming Language :: Python :: Free Threading :: 2 - Beta` (#344).** - Being zero-dependency pure Python, modern-di's wheel already imported on - free-threaded builds; what is new is that its concurrency is now **verified**. - CI runs the full gated suite (100% coverage) on a free-threaded `3.14t` - interpreter, with a hard `sys._is_gil_enabled()` assertion, a 32-thread - concurrent-resolution stress test, and `-W error::RuntimeWarning` so a silent - GIL re-enable fails the run instead of passing trivially. Singleton creation is - the only locked path (a double-checked per-container `RLock`); the registry - memoization is lock-free and idempotent. - -- **Why Beta and not Stable.** CPython publishes no formal memory model, so - object-publication ordering rests on the current implementation's behavior, not - a spec guarantee — the same reliance every lock-free Python singleton makes. - That gap is the reason for the level, and it is documented rather than hidden. - -- **One caveat worth repeating:** apply `override()` / `reset_override()` and - `set_context` during single-threaded setup, *before* resolving concurrently — - mutating a shared registry mid-resolution is inherently unordered (it always - was, GIL or not). The full contract lives in - [`architecture/concurrency.md`](https://github.com/modern-python/modern-di/blob/2.30.0/architecture/concurrency.md). - -## Docs & internals - -- New [`architecture/concurrency.md`](https://github.com/modern-python/modern-di/blob/2.30.0/architecture/concurrency.md) - capability page — the standing thread-safety contract (the locked - singleton path, the idempotent lock-free memoization, the thread-local cycle - guard, the Beta stance and its publication-ordering caveat). -- Two GIL-caveat code comments now point at the capability page; a stale - free-threading item was removed from the deferred queue as actioned. -- 100% line coverage maintained across Python 3.10–3.14 **and** the new - free-threaded `3.14t` cell; `ruff` and `ty` clean. - -## Downstream - -- **No action needed.** The concurrency fix is a pure correctness improvement - with no API change — anyone resolving from multiple threads, on **any** Python - version, gets it for free. The Free Threading classifier is advisory; nothing - in the public API changed. -- **Existing integrations:** unaffected — no sibling `modern-di-*` adapter is - touched by either change. diff --git a/planning/releases/2.31.0.md b/planning/releases/2.31.0.md deleted file mode 100644 index 19e294d4..00000000 --- a/planning/releases/2.31.0.md +++ /dev/null @@ -1,48 +0,0 @@ -# modern-di 2.31.0 — faster cold starts - -A performance release: three optimizations on the resolve and child-build hot -paths, all **backward-compatible with no API or behavior change**. The headline -is a 2.4x faster first resolve, which matters most for short-lived processes — -serverless cold starts, CLIs, and test suites that build many fresh containers. - -## Performance - -- **First resolve is ~2.4x faster (#349).** The positional fast-path predicate - re-ran a full `inspect.signature(creator)` at compile time — on every provider, - on every fresh container — solely to detect positional-only parameters. That - information is already computed when the `Factory` is defined, so the compiler - now reads a recorded flag instead of re-introspecting. `inspect.signature` was - ~56% of cold first-resolve; removing it cut a depth-6 chain's cold build+resolve - from ~100 µs to ~41 µs (**−59%**). Compile-time only — the steady-state resolve - path is unchanged, and there is no new per-resolve cost. - -- **`build_child_container()` is ~40% faster on the default path (#348).** - Building a child without an explicit scope re-sorted the scope enum on every - call to find the next-deeper scope (~44% of the cost). That step is a constant - function of an immutable enum member, now memoized — cutting the default - child-build from ~2983 ns to ~1763 ns. - -- **A small, uniform win on every resolve (#347).** The registry's resolver and - plan memos dropped their per-lookup version stamp in favor of clearing on - mutation (a single-threaded configure-phase operation), removing work from the - dispatch floor every resolve pays: guard-tier cached-singleton resolve −9/−12%, - comparative transient −7.6%, singleton −4.8%, deep chain −3.3%. - -modern-di remains the only zero-dependency, pure-Python framework in its -comparative tier holding its own against the codegen frameworks. - -## Internals - -- The public benchmark suite gained scenarios for cold first-resolve, context - injection, `validate()`, override-active resolve, teardown at scale, and - concurrent throughput — the guard tier now covers every hot path. -- Concurrent resolution is thread-safe but does not gain throughput from more - cores on free-threaded builds; this is CPython reference-counting of shared - objects (not modern-di), and is now documented in - `architecture/concurrency.md`. It will improve as CPython's deferred reference - counting expands. -- 100% line coverage maintained across Python 3.10–3.14; `ruff` and `ty` clean. - -## Downstream - -No action needed — no API surface changed. Upgrade for the speedups. diff --git a/planning/releases/2.31.1.md b/planning/releases/2.31.1.md deleted file mode 100644 index 9bfa436b..00000000 --- a/planning/releases/2.31.1.md +++ /dev/null @@ -1,63 +0,0 @@ -# modern-di 2.31.1 — containers stop being garbage the refcounter can't free - -A backport of the 3.1.1 fix to the 2.x line. Every container stored itself inside -its own scope map, which made it a reference cycle: no container — root or child — -could be freed by reference counting, so each one waited for a generational GC -pass. An application that builds a container per request was handing the collector -work at its request rate. - -**No API changes.** No integration needs updating. - -## Fix - -- **Containers are no longer reference cycles.** `Container.__init__` seeded - `_scope_map` with `{scope: self}`, so the container referenced a dict that - referenced the container. The map is now seeded from the parent instead — it - holds ancestors only. `find_container` already returned `self` for its own - scope *before* consulting the map, so the self-entry was never read and - resolution is unaffected. - - For the measured effect, see the - [3.1.1 release notes](https://github.com/modern-python/modern-di/releases/tag/3.1.1). - Those figures were taken on the 3.x tree after its benchmark suite was rebuilt - for measurement fidelity, which this line predates; they are not restated here - as 2.x measurements. - -## Behavior change - -- **`Container.scope_map` no longer contains the container itself.** A root's map - is now empty and a child's holds only its ancestors. The property is documented - as an internal surface with no stability guarantee and already emits a - `DeprecationWarning`; a survey of all 12 sibling `modern-di-*` integration - repositories found no references to it. `find_container` is unaffected on both - its own-scope and ancestor paths. - -## Scope of this release - -2.31.1 is a **one-off backport**, not the start of a maintained 2.x line. Active -development continues on 3.x, and upgrading remains the supported path — see the -[3.x migration guide](https://modern-di.modern-python.org/migration/to-3.x/). - -## Packaging - -- The `lint` dependency group pins `ruff<0.16` on this branch, freezing the - toolchain alongside the code. Development-only: not a runtime dependency, and - no effect on installers. - -## Downstream - -No action needed to adopt this release: no API changed, and no integration reads the -affected property. - -One caveat if you use `modern-di-aiohttp` or `modern-di-starlette`. Those integrations -carried a second, independent per-request reference cycle in their own middleware, fixed -only in their 3.1.0 releases — and those require `modern-di>=3.1.1,<4`, so they cannot be -used on this line. The newest 2.x-compatible release of either integration (2.2.0, pinning -`modern-di>=2.28.0,<3`) still contains it. On 2.31.1 those two therefore still leave a -per-request container that only the collector can free; only the 3.x line clears both -cycles. - -## Internals - -- 433 tests, 100% line coverage, Python 3.10–3.14 including the free-threaded - 3.14t build. diff --git a/planning/releases/3.0.0.md b/planning/releases/3.0.0.md deleted file mode 100644 index bf10b632..00000000 --- a/planning/releases/3.0.0.md +++ /dev/null @@ -1,98 +0,0 @@ -# modern-di 3.0.0 — an explicit container lifecycle - -3.0 is a **breaking release**. It makes the container lifecycle explicit and -turns six long-standing "warn-then-continue" behaviors into hard rules: a -container must be opened before use, and validation runs by default. Five of the -six changes have been warned about since 2.x — if your 2.x suite is green under -the [warnings-as-errors readiness recipe](https://modern-di.modern-python.org/migration/to-3.x/#readiness-recipe-escalating-warnings-to-errors-with-filterwarnings), -those five are a no-op. The sixth — the mandatory-open lifecycle — is a hard -break with no 2.x signal; see [Migrating](#migrating). - -Full guide: **[Migration to 3.x](https://modern-di.modern-python.org/migration/to-3.x/)**. - -## Breaking changes - -### A container must be opened before use (#365) - -The headline change. A freshly constructed container starts **unopened**; -`resolve` and `build_child_container` raise `ContainerClosedError` until you -enter it via `with` / `async with` / `open()`. Child containers require opening -too. - -```python -# Before (2.x) -container = Container(scope=Scope.APP, groups=[MyGroup]) -service = container.resolve(MyService) # worked - -# After (3.0) -with Container(scope=Scope.APP, groups=[MyGroup]) as container: - service = container.resolve(MyService) # open, then resolve -# or: container = Container(...); container.open(); container.resolve(...) -``` - -This makes the lifecycle explicit and guarantees validation cannot be silently -skipped (opening is what validates). It has **no 2.x deprecation warning** — 2.x -containers were usable on construction, so there was no "unopened" state to warn -on. - -### Validation runs by default, at container entry (#364) - -`validate` now defaults to on and is a plain `bool` (`validate: bool = True`; the -old `None` was dropped). Validation runs **once, when you open the container** — -not at construction, and not on first resolve. `validate=False` disables it. For -a construction-time check, call `container.validate()` explicitly. - -```python -with Container(groups=[MyGroup]) as container: # validates here (cycles + scope order) - ... -``` - -Because integrations register their connection providers *after* you build the -container, deferring validation to entry is what lets a `Factory` depend on a -framework's request/message type without a spurious startup error. - -### Reusing a closed container raises (#362) - -Resolving from — or building a child of — a **closed** container now raises -`ContainerClosedError` instead of silently reopening (self-healing). Re-enter -with `with` / `open()` before reuse. (This and the mandatory-open rule above are -the same error: a container must be open to use.) - -### Direct resolve of an unset `ContextProvider` raises (#363) - -`container.resolve(SomeContextType)` with no value set now raises -`ContextValueNotSetError` instead of returning `None`. A `Factory` parameter -backed by the same provider is unchanged — it still follows its -default/nullable/required disposition. - -### `Alias(scope=)` removed (#359) - -The `scope` argument never affected an alias's resolution (its scope is derived -from its source). It's gone; passing it raises `TypeError`. - -### `Factory(cache_settings=)` removed (#360) - -The pre-`cache=` spelling is gone; use `cache=` (`cache=True` for defaults, or -`cache=CacheSettings(...)` to tune). - -## Migrating - -1. **The five signalled switches** (closed-raises, `Alias(scope=)`, - `Factory(cache_settings=)`, validate-by-default, unset-context-raises) each - warned in 2.x. Run your 2.x suite under the - [readiness recipe](https://modern-di.modern-python.org/migration/to-3.x/#readiness-recipe-escalating-warnings-to-errors-with-filterwarnings) - (warnings as errors); if it stays green, these are a no-op for you. - -2. **The mandatory-open lifecycle** (and validation now running at entry) cannot - be caught by the recipe — it has no 2.x signal. Wrap your containers in - `with` / `async with`, or call `open()` before use. This is the one change - that may need code edits even if your suite was warning-clean. - -3. **Framework integrations.** The `modern-di-*` integration packages that build - or hand you a container are updated to open it for you; upgrade them alongside - `modern-di` 3.0. If you build request-scoped child containers yourself, open - them (or use `with`). - -The retained warning classes (`ContainerClosedWarning`, -`UnvalidatedContainerWarning`, `ContextValueNoneWarning`) still import for -back-compat but are no longer emitted. diff --git a/planning/releases/3.1.0.md b/planning/releases/3.1.0.md deleted file mode 100644 index 9f6e5a58..00000000 --- a/planning/releases/3.1.0.md +++ /dev/null @@ -1,165 +0,0 @@ -# modern-di 3.1.0 — open() becomes optional, validation becomes explicit-only - -3.1 relaxes two things 3.0 tightened. A container no longer has to be opened -before use — every 3.0 pattern (`with`, `async with`, `open()`) keeps working -exactly as documented, and no integration needs a code change. Graph -validation is relaxed further than that: it becomes **explicit-only**. -`container.validate()` is now the *only* thing that walks the provider graph -— not construction, not `open()`, not `add_providers`, not `resolve()`. Most -users need no change here either, but read the callout below: this is a real -drop in the default safety net, not a pure relaxation like the lifecycle -change. - -## Lifecycle: open() becomes optional - -- **A constructed container is usable immediately.** `Container(...)` is open - (`closed = False`) the moment it returns — no `open()` step is required - before the first `resolve` / `resolve_provider`. `open()` and `with` / - `async with` are unchanged in spelling and stay the recommended form for - running finalizers on the way out and for putting a reopen at a well-defined - point instead of the first request. -- **Reusing a closed container warns instead of raising.** Resolving from a - container after an explicit `close_sync`/`close_async` — directly, or because - a descendant's resolve navigates back into its scope — emits - `ContainerClosedWarning` and reopens it, restoring the 2.x self-healing - behavior that 3.0 turned into a hard `ContainerClosedError`. An explicit - `open()` after a close stays silent — that's how you tell modern-di the - reuse is deliberate. `ContainerClosedWarning` is a `RuntimeWarning`, not a - `DeprecationWarning` as it was pre-3.0: CPython hides deprecation warnings - outside `__main__`, which would hide this diagnostic from exactly the - long-running services that need to see it. -- **`build_child_container` requires nothing of its parent.** It reads the - parent's scope map and its two shared registries, resolves nothing, and - touches no cache — so building off a closed parent is not itself a reuse and - never warns. The warning fires only if and when a resolve actually reaches - the closed ancestor. -- **`ContainerClosedError` is inert.** Nothing raises it anymore; it stays - importable through the 3.x series and is removed in 4.0. If you catch it - explicitly, that code path is now dead — safe to leave in place or remove. - -**Want 3.0's reuse-raises behavior back?** Escalate the warning to an error in -code, at startup, to turn a closed-container reuse back into a hard failure: - -```python -import warnings - -from modern_di import exceptions - -warnings.filterwarnings("error", category=exceptions.ContainerClosedWarning) -``` - -Under pytest, the config form does the same for a test run: - -```toml -[tool.pytest.ini_options] -filterwarnings = ["error::modern_di.exceptions.ContainerClosedWarning"] -``` - -Note that the interpreter-level spellings — `-W` and `PYTHONWARNINGS` — do -**not** work for this category: CPython parses them during interpreter init, -before `modern_di` is importable, so the filter is silently dropped -(`Invalid -W option ignored: invalid module name: 'modern_di.exceptions'`) and -the warning is never promoted. The `warnings.filterwarnings` call and the -pytest `filterwarnings` config key above are the two forms that actually work. - -## Validation becomes explicit-only - -- **`container.validate()` is the only trigger.** It still walks the whole - provider graph once — cycles, transitive scope ordering, missing - dependencies — and raises one aggregated `ValidationFailedError` covering - every issue found, exactly as before. What changes is that nothing else - calls it: not `Container.__init__`, not `open()`, not `add_providers`, not - `resolve()`. An earlier, unreleased revision of this design tried to keep - part of that implicit guarantee by splitting validation into a monotone half - (cycles, inverted scopes) checked at construction and a completeness half - held for first use. It worked, but the machinery it needed — a two-flag - container lifecycle, a monotone/completeness classification threaded through - the graph walk, an `add_providers` rollback path — was out of proportion to - what it bought. 3.1 ships the simpler rule instead: one explicit call, one - aggregated report, nothing to infer about when it runs. -- **`add_providers` registers and nothing more.** It does not validate, and - there is no rollback on a bad batch — a cycle or inverted scope introduced by - the new providers is only reported the next time something calls - `validate()`. -- **`Container(validate=...)` is a deprecated no-op.** Passing `True` or - `False` changes nothing about the container built and emits - `exceptions.ValidateArgumentWarning` (a `DeprecationWarning`); omitting the - argument (the default) is silent either way. Removed in 4.0. - -### The default safety net is thinner now — read this before upgrading - -Through 3.0, a broken graph raised at `open()` whether you asked for it or -not. As of 3.1, **nothing validates unless you call `validate()`.** A missing -dependency now surfaces only at the resolve that actually hits it, as a bare -`ArgumentResolutionError` — not aggregated with any other issue in the graph, -and not necessarily on your first request. A cycle or an inverted-scope -dependency you never trigger stays silently wired wrong. - -The replacement idiom is one line, called once at startup: - -```python -container = Container(scope=Scope.APP, groups=[MyGroup]) -container.validate() # raises ValidationFailedError with every issue found, or nothing -``` - -If a framework integration registers its own providers after construction -(`setup_di`, `add_providers`), call `validate()` *after* that call, so the -complete graph — including the integration's providers — is what gets -checked: - -```python -container = Container(scope=Scope.APP, groups=[MyGroup]) -setup_di(container) # registers the integration's providers via add_providers -container.validate() -``` - -**Want 3.0's fail-fast-by-default back?** There is no automatic equivalent — -`validate()` has no warning class to escalate, because nothing warns when you -skip it. Call `container.validate()` explicitly at every construction site (or -right after an integration's setup call, as above); that is the whole recipe. - -## Why - -Six days after 3.0 shipped, field evidence pointed at one root cause behind -six separate production defects: an integration's root container is opened in -one place, but some execution contexts (a non-prefork worker pool, a mounted -sub-app, a disabled lifespan hook) never reach that code path, so the very -first unit of work in that context raised. Binding validation to `open()` also -created an authoring trap — a caller had to open the root *after* registering -its providers, or a by-type dependency on a not-yet-registered connection -failed validation early. Both problems are structural consequences of making -`open()` load-bearing, not bugs in any particular integration. 3.1 removes the -load-bearing requirement from `open()` entirely, and rather than re-attach -validation to some other implicit point, makes it a single explicit call — -predictable at the cost of no longer being automatic. - -## Downstream - -No action needed for `modern-di-*` integrations (aiohttp, FastAPI, FastStream, -Litestar, Starlette, Typer, Flask, gRPC, Celery, arq, taskiq, aiogram) or -`modern-di-pytest` — every 3.0-era `open()`/`with` call site keeps its exact -meaning. Upgrading is a drop-in floor bump. One thing to check: if you relied -on an integration's startup hook calling `open()` to fail fast on a broken -graph (true on 3.0, since `open()` validated by default), that fail-fast is -gone on 3.1 — none of these integrations call `validate()` for you. Add an -explicit `container.validate()` call after setup if you want that check back. -Also check your own call sites: if you pass `validate=` to `Container(...)` -anywhere, drop it — it is now a deprecated no-op that emits a -`DeprecationWarning` on every construction, which a suite configured with -`error::DeprecationWarning` (per the [readiness -recipe](https://modern-di.modern-python.org/migration/to-3.x/#readiness-recipe-escalating-warnings-to-errors-with-filterwarnings)) -will fail on. - -## Internals - -- 100% line coverage maintained across Python 3.10–3.14; `ruff` and `ty` - clean; docs built under `mkdocs --strict`. -- **The resolve tier is unchanged.** Compiled resolvers keep the identical - single `if target.closed:` test they emitted in 3.0 — only what runs behind - it changed (self-heal vs. raise). Guard-tier resolve numbers (cached, - transient, deep-chain) match 3.0 within ordinary run-to-run noise. -- **Construction is cheaper than 3.0.** A `Container(...)` followed by - `open()`, both with default arguments, no longer pays for a graph walk at - `open()` — 3.0 did that by default. Measured on a depth-6 provider chain, - construct-then-open dropped from roughly 15 µs (3.0, default `validate=True` - running at `open()`) to roughly 2.6 µs (3.1, nothing validates implicitly). diff --git a/planning/releases/3.1.1.md b/planning/releases/3.1.1.md deleted file mode 100644 index 7509862b..00000000 --- a/planning/releases/3.1.1.md +++ /dev/null @@ -1,71 +0,0 @@ -# modern-di 3.1.1 — containers stop being garbage the refcounter can't free - -A single-line fix in `Container.__init__` with a disproportionate effect. Every -container stored itself inside its own scope map, which made it a reference -cycle: no container — root or child — could be freed by reference counting, so -each one waited for a generational GC pass. An application that builds a -container per request was handing the collector work at its request rate. - -No API changes. No integration needs updating. - -## Fix - -- **Containers are no longer reference cycles.** `Container.__init__` seeded - `_scope_map` with `{scope: self}`, so the container referenced a dict that - referenced the container. The map is now seeded from the parent instead — it - holds ancestors only. `find_container` already returned `self` for its own - scope *before* consulting the map, so the self-entry was never read and - resolution is unaffected. - - Measured on an Apple M4 / CPython 3.14.6: - - | | before | after | - |---|---|---| - | 100 closed REQUEST children | 792 objects reclaimable only by the GC | 0 | - | 100 closed root containers | 2300 | 0 | - | 100 full APP→SESSION→REQUEST→ACTION→STEP chains | 5500 | 0 | - | build a child and drop it, GC enabled | ~849 ns | ~542 ns | - - Isolated construction is unchanged within noise (~500 vs ~506 ns with the GC - off); the gain is the collector no longer reclaiming what reference counting - now frees. On the comparative request-lifecycle benchmark the median fell from - 232.7 µs to 194.8 µs per 100-request batch, and the standard deviation from - 123.0 µs to 7.9 µs — the tail that scenario carried was GC, and it is gone. - -## Behavior change - -- **`Container.scope_map` no longer contains the container itself.** A root's - map is now empty and a child's holds only its ancestors. The property is - documented as an internal surface with no stability guarantee and already - emits a `DeprecationWarning`; a survey of all 12 sibling `modern-di-*` - integration repositories found no references to it. `find_container` is - unaffected on both its own-scope and ancestor paths. - -## Internal - -- **The benchmark suite was rebuilt for measurement fidelity.** Nine defects - found by audit, none of them affecting library code. The largest: the - request-lifecycle scenarios entered the asyncio event loop once per timed - iteration, and that entry costs ~27 µs regardless of the body — so ~93% of - every published figure was a shared constant that compressed the real - differences between frameworks toward parity. Scenarios are now batched, the - per-framework setup asymmetries are levelled, `iterations` is pinned so no - cell sits on the platform timer's ~42 ns grid, and the published comparison - table is generated by `just bench-report` rather than hand-assembled. - - This changed a published claim: modern-di is **not** level with dishka on the - request lifecycle, as the old numbers implied. See - [Performance](https://modern-di.modern-python.org/introduction/performance/). - -- The reference-cycle fix above was found while investigating why modern-di's - request-lifecycle measurement was the least stable cell on that page. - -## Downstream - -No action needed. No API changed, and no integration reads the affected -property. - -## Internals - -- 450 tests, 100% line coverage, Python 3.10–3.14 including the free-threaded - 3.14t build. diff --git a/planning/releases/3.1.2.md b/planning/releases/3.1.2.md deleted file mode 100644 index 0f619f50..00000000 --- a/planning/releases/3.1.2.md +++ /dev/null @@ -1,64 +0,0 @@ -# modern-di 3.1.2 — a warm singleton hit loses two method frames - -A cached resolve reached its compiled resolver through one method call and its -`CacheItem` through another. Both methods open with a dict lookup that hits and -returns, so on the warm path both frames were pure indirection. They are now -inlined at the call site, with the method called only on a miss. - -**No API changes. No behaviour changes.** Purely a hot-path trim. - -## Performance - -- **A warm singleton hit is ~25% faster.** `ProvidersRegistry.resolver_for` and - `CacheRegistry.fetch_cache_item` are inlined on their hit paths. Measured on - an Apple M4 / CPython 3.14.6, 300 000 iterations × 11 rounds: - - | | 3.1.1 | 3.1.2 | - |---|---|---| - | warm singleton, by reference | 170 ns | **128 ns** | - | warm singleton, by type | 208 ns | **163 ns** | - | transient resolve, by reference | 304 ns | **276 ns** | - - The first of the two inlines sits in `resolve_provider`, so **every top-level - resolve** benefits, not only cached ones — which is why the transient case - moves too. - - On the published comparative table the warm-singleton (C2) ratios move - 3.88 → **2.94** against dependency-injector, 2.80 → **2.13** against - that-depends, 1.32 → **1.06** against dishka, and 3.02 → **2.40** against - wireup. Both rivals whose implied absolutes that page prints are unchanged - (~48 ns and ~67 ns), which is the check that the movement is modern-di's and - not measurement drift. On the by-reference basis modern-di's warm hit is now - the faster of modern-di and dishka. - - The bound stated when this was planned holds: it trims the cell, it does not - close it. dependency-injector's ~48 ns hit is a C-level slot read on a Cython - core, which pure Python does not reach. - -## What did not change - -The miss paths are untouched. `resolver_for` still owns the cycle-safe -compilation thunk and the memo write; `fetch_cache_item` still uses `setdefault`, -so concurrent first-resolvers share one `CacheItem` through a single atomic op — -the property the singleton cache and its double-checked lock depend on. -Registry mutation still clears the memo and forces a recompile, and a -`RecursionError` raised while compiling is still converted to -`CircularDependencyError`. - -A further step is **deliberately not taken**: an APP-scoped resolver could close -over its `CacheItem` and reach ~16 ns, but that requires the providers registry -to reference its root container — reintroducing the reference cycle removed in -3.1.1. It needs a weakref and a proof, for ~30 ns. - -## Downstream - -No action needed. No API changed; integrations pick the improvement up on -upgrade. - -## Internals - -- 452 tests, 100% line coverage, Python 3.10–3.14 including free-threaded 3.14t; - `ruff`, `ty` clean. -- Guard tier: `g2_cached_resolve` −22%, `g17_resolve_by_type_large_registry` - −20%, `g14_concurrent_cached_hit` −24% (8 000 warm reads per batch). No - scenario regressed. diff --git a/planning/releases/3.2.0.md b/planning/releases/3.2.0.md deleted file mode 100644 index 952b22a5..00000000 --- a/planning/releases/3.2.0.md +++ /dev/null @@ -1,120 +0,0 @@ -# modern-di 3.2.0 — two silent-wrong-answer bugs closed, and a resolve path four steps lighter - -Two defects in this release could each make a container answer *incorrectly* -rather than fail: a group could restamp the scope of an already-compiled -provider, and a registration racing a compile could be silently and permanently -lost. Both are fixed, and one of them introduces a new exception — the reason -this is a minor rather than a patch release. - -Alongside them, four independent trims to the resolve path: the alias hop, the -context-kwarg path, the cached resolver's warm hit, and container reopen. - -## Fix - -- **A registration racing a compile could be lost, permanently.** - `ProvidersRegistry.resolver_for` and `plan_for` both built their memo entry - outside `_lock` and published it after. A `register()` / `add_providers()` - landing in that window calls `_invalidate()`, clearing a memo the entry has not - been written to yet — the entry then lands and is never dropped, because the - invalidation meant to remove it already happened. The provider resolves with - its dependency absent even though that dependency *is* registered. Both now - read `_generation` before building and publish under `_lock` only if it is - unchanged; a build that loses the race is still returned to its caller, just - not memoized. - - The window spans the whole nested compile, so it widens with graph depth — - without instrumentation, 60/100 at depth 10 and 99/100 at depth 40. After the - fix, 0/150 at depths 10 and 60, under both the GIL and free-threaded 3.14t. - Both windows have a regression test driven by an event rather than by racing - threads. - -- **A `Group` could restamp the scope of an already-registered provider.** A - group declared without `scope=` stamps nothing, so a provider listed only there - kept the `Scope.APP` default and stayed unclaimed — leaving a later scoped - group free to restamp it. That is unsound once the provider is registered: a - compiled resolver captures `scope` in its closure, and the stamp touches no - registry, so nothing invalidates the memo. The same provider then answered - differently depending on whether a resolver happened to be compiled before the - restamp — resolving fine from `APP` through the stale resolver, while a fresh - container raised `ScopeNotInitializedError`. - -## Behaviour changes - -- **`ProviderScopeFrozenError` (new, a `RegistrationError`).** Raised when a - group's scope stamp would *change* the scope of an already-registered provider. - A same-scope stamp still returns early, so sharing one provider instance across - groups at the same scope — documented in `docs/providers/scopes.md` — is - unaffected, and a rejected `add_providers` does not freeze anything. - `GroupScopeConflictError` is unchanged: it still covers two groups that both - declare a scope and disagree, registered or not. - -- **Reopening a closed container warns at least once, not exactly once.** The - reopen path is no longer serialized under the container lock. Threads racing - one closed container may each warn; every one of them writes the same - `closed = False`. Only the warning count is affected. - -## Performance - -Measured on an Apple M4, CPython 3.14.6, each against its own immediate baseline -rather than cumulatively. - -| Path | Before | After | | -|---|---|---|---| -| alias resolve | ~322 ns | **~252 ns** | −22% | -| warm cached hit | 162.5 ns | **144.2 ns** | −11.3% | -| warm cross-scope | 217.2 ns | **199.2 ns** | −8.3% | -| override hit | 153.4 ns | **142.0 ns** | −7.4% | -| context kwarg, no overrides | 686.6 ns | **645.5 ns** | −6.0% | - -- **An alias hop costs one Python frame instead of four.** `Alias` was the only - compiled closure that did not reach its dependency's resolver by direct - reference — it went through `Alias._find_source` → `find_provider`, then - re-entered `Container.resolve_provider`. It now inlines both lookups and calls - the source's compiled resolver directly. It caches nothing and captures - nothing, so a source registered later is still picked up on the next resolve; - the binding variants that measured faster are declined with reasoning in - [`docs/adr/0025-alias-binds-nothing.md`](https://github.com/modern-python/modern-di/blob/main/docs/adr/0025-alias-binds-nothing.md). - -- **The cached resolver's warm path lost its `MAKE_CELL`.** The cold-miss thunk - is built with `functools.partial(build_cold, target)` rather than a lambda - closing over `target`; a closure promotes `target` to a cell for the *whole* - resolver, so `MAKE_CELL` ran in the prologue on every call — including the warm - hit that returns two lines later and the override hit that never reaches - `target` at all. - -- **The context-kwarg path front-guards its override lookup** on - `has_overrides`, and `ContextRegistry.find_context` dropped a `typing.cast` and - a dead `None` check. - -## What did not change - -Resolution stays sync-only. Overrides, caching, scope transparency, -`redirect_target`, and validation behave exactly as before — including through an -alias, where overriding either the alias or its source still works unchanged and -a dangling source still raises `AliasSourceNotRegisteredError` carrying the -alias's own resolution step. The memo miss paths still own the cycle-safe -compile, the memo write, and the `setdefault` that makes concurrent -first-resolvers share one `CacheItem`. - -## Downstream - -**No action needed for integrations** (FastAPI, Litestar, FastStream, Typer, -`modern-di-pytest`, and the rest) — no API they call has changed, and the -performance work is picked up on upgrade. - -One case warrants a look before upgrading: a suite that relies on a second -`Group` silently changing a registered provider's scope now raises -`ProviderScopeFrozenError`. That pattern was already producing -resolver-dependent answers, so the raise is surfacing an existing bug rather -than creating one. - -## Internals - -- 466 tests, 100% line coverage, Python 3.10–3.14 including free-threaded 3.14t; - `ruff`, `ty` clean. -- The per-node frame budget is now enforced rather than only documented: - `tests/test_resolver_compiler.py` holds a chain node at one resolver frame and - an alias hop at one, and pins that no compiled resolver's closure captures its - registry. -- The comparative table in `docs/introduction/performance.md` still reports 3.1.2 - and does not include this release's trims. diff --git a/planning/releases/3.3.0.md b/planning/releases/3.3.0.md deleted file mode 100644 index 681060d0..00000000 --- a/planning/releases/3.3.0.md +++ /dev/null @@ -1,99 +0,0 @@ -# modern-di 3.3.0 — four frames off the resolve path - -Every entry point into resolution got cheaper: a by-type resolve, a context-backed -parameter, a hop through an `Alias`, and the creator call itself. No API changed. -Two documented contracts narrowed, both stated rather than enforced — see -**Behaviour contracts** before upgrading if you subclass `Container`. - -## Performance - -Measured on an Apple M4 / CPython 3.14.6 with the repo's A/B/A harness, each change -against its own immediate baseline rather than cumulatively. - -| Path | Before | After | | -|---|---|---|---| -| transient resolve | 353.6 ns | **236.9 ns** | −33.0% | -| deep chain (depth 6) | 940.6 ns | **676.7 ns** | −28.1% | -| by-type `resolve(T)` | 193 ns | **155 ns** | −19.7% | -| wide fan-out (10 deps) | 1661 ns | **1321 ns** | −20.5% | -| context kwarg | ~707 ns | **~665 ns** | −6.1% | - -- **The positional creator call is arity-specialised.** A factory with 0 or 1 - provider dependencies now compiles to a closure that names its argument and calls - the creator directly — no intermediate list, no `CALL_FUNCTION_EX` unpack, and - below 3.12 no comprehension frame either. Arity 2+ keeps the generic star-call. - The ladder stops at 1 on evidence: a 0–3 version was built and measured first and - bought nothing beyond arity 1, because leaves are arity 0 and chain nodes are - arity 1. Wide graphs improve anyway — their leaves take the ladder even when the - root does not. - -- **`Container.resolve(SomeType)` no longer delegates to `resolve_provider`.** It - carries its own copy of that body, so the by-type path — what every `@inject` - marker and framework integration uses — pays one frame fewer. - -- **A context-backed parameter costs three Python frames instead of five.** The - binding (`provider_id`, scope, `context_type`, absent-disposition) is folded into - the compiled closure, and the scope hop is an int compare when the resolving - container is already at the provider's scope. The *value* is still read live on - every resolve; only the binding is fixed. - -- **An `Alias` hop costs one Python frame instead of four** (3.2.0 shipped this; - it now has a guard scenario, G18, at ~225 ns against G2's ~155 ns). - -## Behaviour contracts - -Neither is enforced in code. Both are stated so that a future optimisation is a -performance change rather than a breaking one. - -- **`resolve_provider` is not an interception seam.** A `Container` subclass that - overrides it is no longer consulted for by-type resolves. In practice this - narrows nothing that worked: since the compiled resolvers landed in 2.29.0 an - override has only ever seen *top-level* calls — measured at 1 call while - resolving a 4-node chain — because resolvers call each other directly. An audit - of all 13 sibling integration wheels found zero `Container` subclasses and zero - overrides. Full reasoning in - [`docs/adr/0026-resolve-provider-not-a-seam.md`](https://github.com/modern-python/modern-di/blob/main/docs/adr/0026-resolve-provider-not-a-seam.md). - `find_container` is unaffected and remains a blessed extension point. - -- **A `ContextProvider`'s identity is fixed once something resolves through it.** - Its `scope` and `context_type` are read when a consumer's resolver compiles and - folded into that closure. `ProviderScopeFrozenError` enforces the scope half for - *registered* providers only; `context_type` is contract-only, as is `scope` for a - provider passed solely via `Factory(kwargs={...})`. Rebinding either on a provider - already in use is unsupported — construct a second provider instead. - -- **An exception raised through `resolve()` carries one traceback frame fewer** - (`resolve_provider` no longer appears). - -## What did not change - -Resolution stays sync-only. Overrides, caching, scope transparency, validation, and -every error type and breadcrumb are untouched — the compiled closures were verified -byte-identical against the paths they replaced across 110 differential cases (22 -creator kinds × 5 scenarios × 2 interpreters). Teardown order of transient -dependencies is unchanged on every supported interpreter, verified main-vs-release -at arities 1–3 on 3.10 and 3.14. - -## Internals - -- 502 tests, 100% line coverage, Python 3.10–3.14 including free-threaded 3.14t; - `ruff`, `ty` clean. -- **The guard tier is pinned.** The 11 scenarios under ~2 µs now run at fixed - `rounds × iterations`, so their medians no longer sit on the platform timer's - ~41 ns grid (which was 23% of the smallest scenario). The CI alert threshold drops - 150% → 120%, and the stored baseline was reset because the reported statistic - changed to a median of per-round means. Expect one-off apparent gains across that - boundary: the by-type scenario moved 250 → 168 ns purely by coming off the grid, - before any of this release's work. -- Two guard scenarios added: **G8b**, a `cache=True` sibling of G8 that gives the - cached cold-miss builders their own signal (28.0 µs against G8's 21.9 µs), and - **G18** for the alias hop. -- `docs/introduction/performance.md`'s comparative table still reports 3.2.0 and - predates this release's work. - -## Downstream - -**No action needed.** No API changed, and integrations pick the improvements up on -upgrade. The two contracts above are worth a glance only if you subclass `Container` -or mutate a provider after registering it — neither is a pattern any sibling -integration uses. diff --git a/planning/releases/3.4.0.md b/planning/releases/3.4.0.md deleted file mode 100644 index ab8a460a..00000000 --- a/planning/releases/3.4.0.md +++ /dev/null @@ -1,117 +0,0 @@ -# modern-di 3.4.0 — one field per question on the scope path - -A provider's scope is now derived from what was actually declared instead of being -collapsed at construction and reconstructed afterwards. That is an internal change with -no effect on which scope any provider resolves at, but two small pieces of surface go -with it — see **Breaking changes** before upgrading if you touch `ContextProvider` -internals or assign to `provider.scope`. Resolving a `ContextProvider` directly also got -16% faster. - -## Internal refactors - -- **`AbstractProvider` derives its scope rather than storing it.** The constructor used - to collapse an omitted `scope=` to `Scope.APP` on its first line, which erased *why* a - provider was APP-scoped — precisely the fact the precedence rule needs (explicit - `scope=` beats a `Group` default beats `Scope.APP`). A companion flag existed only to - carry that erased bit back, and had to stay in sync with the field recording which - group had stamped the provider. - - The sources are kept instead: `_explicit_scope` holds what `scope=` gave (`None` when - omitted) and `_group_claim` holds `(scope, group name)` once a `Group` stamps it. - `scope` is a property whose body is the documented precedence list line for line. Each - field answers exactly one question, and the effective scope cannot drift from its - provenance because it is computed from it. Slot count is unchanged. - - Every group-scope precedence, conflict and freeze test passes untouched; - `GroupScopeConflictError` and `ProviderScopeFrozenError` fire on exactly the same - inputs as before. - -- **`DependencyPathMixin` drops its empty `__slots__`.** The line asserted the class had - no instance attributes while its own `__init__` set two, which `ty` 0.0.74 began - reporting. Removing it lets the mixing-in classes' declarations stand on their own and - leaves `__dict__` empty after `__init__`, at the cost of 16 bytes per instance on the - nine mixin users, on the error path only. No behavior change. - -## Performance - -Measured on an Apple M2 / CPython 3.14.7, `min` of 11 timing repeats, averaged across -three separate processes. - -| Path | 3.3.0 | 3.4.0 | | -|---|---|---|---| -| direct `ContextProvider` resolve | 194.3 ns | **161.6 ns** | −16.8% | - -- **The compiled `ContextProvider` resolver inlines its lookup.** It used to delegate to - `ContextProvider.resolve`, which called `fetch_context_value`, which read - `provider.scope` on every resolve. The compiled closure now performs the override - guard, scope hop, reopen check and context read itself, with the scope read once at - compile time — the same shape the folded context kwargs have used since 3.3.0, and - consistent with the contract that a registered `ContextProvider`'s scope and - `context_type` are fixed. Two delegated frames go with it, which is where most of the - gain comes from. - - This path is what the `Annotated` marker injectors hit when a handler asks for a - context type directly, once per marker per request. - -- **The guard tier is unchanged.** All 25 scenarios sit inside run-to-run noise; the - control scenario moved further than any real one. No guard scenario covers a direct - `ContextProvider` resolve, which is why the number above is measured separately. - -## Breaking changes - -Both are removals of machinery that was never a designed extension point, in the same -class as the provider-type closure shipped in 2.29.0. - -- **`ContextProvider.resolve(container)` is removed.** Its only caller was the compiled - resolver branch above, and the polymorphic `provider.resolve(self)` dispatch it - belonged to was retired in 2.29.0 - ([decision](https://github.com/modern-python/modern-di/blob/main/docs/adr/0013-custom-providers-retracted.md)). - Nothing in the docs referenced it. Resolve through the container - (`container.resolve(SomeType)` or `container.resolve_provider(provider)`), which is - unchanged and raises the same `ContextValueNotSetError` with the same message. - - **`ContextProvider.fetch_context_value(container)` is unaffected.** Public since - 2.18.0, it remains the way to read a context value without raising, returning `UNSET` - when nothing is set. It now has direct tests covering the same-scope read, the - cross-scope hop, the closed-owner reopen, and the absent value. - -- **`provider.scope` is read-only.** It is now a property, so assigning to it raises - `AttributeError` where it previously silently succeeded. Reading is unchanged - everywhere, including before any container exists (`MyGroup.svc.scope` still reflects - a group default at class-creation time). Mutating a registered provider's scope was - already unsupported: the group path has raised `ProviderScopeFrozenError` since 2.x, - and 3.3.0 stated the contract that a `ContextProvider`'s identity is fixed once - something resolves through it. Set the scope at construction (`scope=`) or via the - `Group` default; to change it, construct a second provider. - -## What did not change - -Which scope any provider resolves at, the precedence rule, the freeze, every error type, -message and breadcrumb, and the `Alias` / `container_provider` opt-out from group -stamping. Resolution stays sync-only. No public name other than the two above moved, and -no documented statement in `docs/providers/scopes.md` needed editing. - -## Docs - -- `docs/architecture/` is gone. The living truth about behaviour is the code and its - `INVARIANT:`-marked tests, and a behaviour change is reviewed with the diff rather than - promoted to a prose page; the invariant census test now enforces that every citation - pointing at a test resolves to a real one. -- `docs/introduction/performance.md`'s comparative table was republished at 3.3.0, which - it had lagged. It does not yet include this release, whose only measured change is on a - path that table does not cover. - -## Internals - -- 514 tests, 100% line coverage, Python 3.10–3.14 including free-threaded 3.14t; `ruff` - and `ty` clean. -- One invariant test added for the compile-time scope capture, carrying a positive - control so it cannot pass vacuously, plus two for the group-default opt-outs on `Alias` - and `container_provider` — both written and confirmed passing before the refactor, so - they characterise existing behaviour rather than the new code. - -## Downstream - -**No action needed** for any integration that resolves through a `Container`. Check only -if you call `ContextProvider.resolve` directly or assign to `provider.scope`; neither -appears in any sibling integration. diff --git a/planning/scripts/bug-hunt-audit.workflow.mjs b/planning/scripts/bug-hunt-audit.workflow.mjs deleted file mode 100644 index b022d480..00000000 --- a/planning/scripts/bug-hunt-audit.workflow.mjs +++ /dev/null @@ -1,407 +0,0 @@ -export const meta = { - name: 'bug-hunt-audit', - description: 'Four-dimension (UX/security/tests/logic) bug-hunt audit of modern-di with adversarial verify and triaged report.', - whenToUse: 'Run when you want a fresh triaged backlog of bugs and quality risks across the modern-di repo.', - // The report is transient scratch: a sweep's durable output is a PR plus a GitHub issue or an ADR. - phases: [ - { title: 'Discover', detail: 'map files, extract behavior claims' }, - { title: 'Find', detail: 'four parallel dimension finders' }, - { title: 'Verify', detail: 'three lenses per finding, majority vote' }, - { title: 'Synthesize', detail: 'dedup, triage, write report' }, - ], -} - -const CONTEXT_BLOB_SCHEMA = { - type: 'object', - additionalProperties: false, - required: ['file_map', 'behavior_claims', 'recent_commits'], - properties: { - file_map: { - type: 'array', - items: { - type: 'object', - additionalProperties: false, - required: ['path', 'lines', 'role'], - properties: { - path: { type: 'string' }, - lines: { type: 'integer' }, - role: { type: 'string', description: 'one-line summary of what this file is responsible for' }, - }, - }, - }, - behavior_claims: { - type: 'array', - description: 'Claims made in CLAUDE.md / README.md / docstrings about how the library behaves, with source.', - items: { - type: 'object', - additionalProperties: false, - required: ['claim', 'source'], - properties: { - claim: { type: 'string' }, - source: { type: 'string', description: 'e.g. "CLAUDE.md: Scope hierarchy" or "modern_di/container.py docstring"' }, - }, - }, - }, - recent_commits: { - type: 'array', - description: 'Recent commit subjects from git log, useful as priors for where churn is.', - items: { type: 'string' }, - }, - }, -} - -const RAW_FINDING_SCHEMA = { - type: 'object', - additionalProperties: false, - required: ['dimension', 'title', 'file', 'line', 'description', 'evidence', 'repro', 'suggested_fix', 'severity', 'confidence_finder'], - properties: { - dimension: { enum: ['ux', 'security', 'tests', 'logic'] }, - title: { type: 'string', description: 'short noun phrase' }, - file: { type: 'string', description: 'relative path' }, - line: { type: 'string', description: 'integer or "start-end" range, as string' }, - description: { type: 'string', description: '1-3 sentences on what is wrong' }, - evidence: { type: 'string', description: 'exact code snippet or doc quote indicted' }, - repro: { type: 'string', description: 'minimal scenario; code for code-bugs, prose for docs/UX' }, - suggested_fix: { type: 'string', description: 'one-line direction, not a patch' }, - severity: { enum: ['high', 'medium', 'low'] }, - confidence_finder:{ enum: ['high', 'medium', 'low'] }, - }, -} - -const FINDER_RESULT_SCHEMA = { - type: 'object', - additionalProperties: false, - required: ['dimension', 'findings'], - properties: { - dimension: { enum: ['ux', 'security', 'tests', 'logic'] }, - findings: { type: 'array', items: RAW_FINDING_SCHEMA }, - }, -} - -const VERDICT_SCHEMA = { - type: 'object', - additionalProperties: false, - required: ['lens', 'confirmed', 'reasoning', 'reclassification'], - properties: { - lens: { enum: ['reproduce', 'read-real-code', 'spec-vs-behavior'] }, - confirmed: { type: 'boolean', description: 'true if the lens confirms the finding; default to false when uncertain' }, - reasoning: { type: 'string', description: '1-3 sentences. For reproduce: the constructed repro or why it could not be. For read-real-code: what the cited code actually does. For spec-vs-behavior: how the docs and code line up.' }, - reclassification: { enum: ['bug-in-code', 'bug-in-spec', 'intended-behavior', 'unknown'], description: 'only spec-vs-behavior lens uses non-unknown values; other lenses set "unknown"' }, - }, -} - -const SYNTH_SUMMARY_SCHEMA = { - type: 'object', - additionalProperties: false, - required: ['report_path', 'counts'], - properties: { - report_path: { type: 'string', description: 'absolute or repo-relative path to the written report' }, - counts: { - type: 'object', - additionalProperties: false, - required: ['must_fix_now', 'should_fix_soon', 'nice_to_have', 'spec_fix', 'wont_fix'], - properties: { - must_fix_now: { type: 'integer' }, - should_fix_soon: { type: 'integer' }, - nice_to_have: { type: 'integer' }, - spec_fix: { type: 'integer' }, - wont_fix: { type: 'integer' }, - }, - }, - }, -} - -const DISCOVER_PROMPT = `You are the discover agent for a bug-hunt audit of the modern-di repository (a zero-dependency Python DI library). Produce a single structured context blob that downstream finder agents will use to ground their work. - -Do exactly the following, then return the structured output: - -1. Walk the repo from its root. Build a file_map covering: - - every file under modern_di/ (source) - - every file under tests/ (test suite) - - every file under benchmarks/ - - top-level docs: README.md, CLAUDE.md - - planning/specs/2026-06-05-bug-hunt-audit-design.md (the audit spec — relevant context) - Skip .venv, .pytest_cache, .ruff_cache, .git, .benchmarks, .idea, __pycache__. - For each file: relative path, line count, and a one-line role describing its responsibility. - -2. Extract behavior_claims from README.md and CLAUDE.md and the docstrings of the main public modules (modern_di/__init__.py, modern_di/container.py, modern_di/scope.py, modern_di/group.py). A claim is a concrete statement about how the library behaves — e.g. "Scope is an IntEnum with five levels APP < SESSION < REQUEST < ACTION < STEP" or "ContextProvider is for runtime values injected at container creation". For each claim record the exact source (file + section/identifier). 20-50 claims is the right order of magnitude — be selective; only claims a finder could compare against code. - -3. Grab the last 20 commits from \`git log --oneline -20\` and put the subject lines in recent_commits. - -Do not analyze, do not look for bugs, do not opine. Just produce the context blob. Other agents do the analysis.` - -function finderPrompt(dimension, heuristics, context) { - return `You are the ${dimension}-dimension finder for a bug-hunt audit of the modern-di repository (zero-dependency Python DI library). - -CONTEXT BLOB (file map and behavior claims, provided by the discover agent): -${JSON.stringify(context, null, 2)} - -YOUR JOB: -Find concrete, defensible findings in the ${dimension} dimension. For each finding: -- name the exact file and line (or line range) — required, no exceptions -- quote the exact code or doc text as "evidence" — required -- describe what is wrong in 1-3 sentences -- give a minimal reproduction (code for code-bugs, prose for docs/UX issues) -- propose a one-line fix direction (not a patch) -- assign severity (high/medium/low) and your own confidence (high/medium/low) - -RULES: -- No speculation. If you cannot quote a specific file:line that demonstrates the issue, do not include it. -- Read the actual file before claiming. The context blob is a map, not the source. Use your file-reading tools to open the cited file before writing a finding. -- Do not flag style or lint issues. ruff and ty already enforce those in CI. -- Do not propose performance improvements unless they cross into correctness (unbounded growth, hangs, pathological complexity that becomes DoS). -- Findings about sibling repos (modern-di-pytest, FastAPI / FastStream / Litestar / Typer integrations) are OUT OF SCOPE. Stay in this repo only. - -DIMENSION-SPECIFIC HEURISTICS: -${heuristics} - -OUTPUT: -Return the structured object. Aim for 5-20 findings; quality over quantity. Returning an empty findings list is acceptable if the dimension truly has nothing actionable.` -} - -const UX_HEURISTICS = `Audit developer experience (not end-user UI — this is a library). -- Error message quality. Open modern_di/errors.py and modern_di/exceptions.py. Cross-reference every error template against its raise sites. Does each error name the offending type, provider, and scope, or just describe a category? -- API friction. Required-but-unintuitive kwargs. Footguns: skip_creator_parsing semantics, validate=True cost claims, cache_settings=CacheSettings() as the singleton-by-other-name pattern, kwargs={} bypassing type-based resolution. -- Surprising defaults. What happens with no groups=, no context=, default scope, default cache_settings? -- Doc-vs-behavior divergence. README and CLAUDE.md claims (see behavior_claims in the context blob) vs. real signatures, exports, scope rules. A doc that says "X" while the code does "Y" is a UX finding.` - -const SECURITY_HEURISTICS = `Audit security. Small surface for a zero-dep DI library, but real. -- types_parser evaluation paths. Open modern_di/types_parser.py. Are there any eval, exec, __import__ calls when resolving forward refs or string annotations? What happens with adversarial annotation strings (e.g. Annotated[str, "malicious_payload"])? -- Override registry escape hatches. Can container.override() bypass scope guards? Can a child container's override leak to siblings via the shared registry? -- Unbounded recursion / cache growth. Cycle-detection guarantees in container.validate() and resolution paths. Child-container leak on long-lived parents (does parent hold refs to children?). ContextRegistry / CacheRegistry size bounds. -- Unsafe __reduce__ / pickling paths on providers, registries, or Container. Look for any __reduce__, __getstate__, __setstate__ implementations.` - -const TESTS_HEURISTICS = `Audit the test suite as a target — these are bugs IN tests, not bugs FOUND BY tests. -- Assertions that do not test the claim. e.g. assert result is not None when the docstring or test name promises structure or value verification. -- Branches with coverage but no behavioral assertion. The recent "full cov require" / "Use typing.List[int] in non-class bound_type test for 3.10 coverage" commits suggest coverage was tightened — easy to game by adding code execution without assertions. Look for tests that call into code but only assert "no exception raised" when a return value or side effect is the real contract. -- Missing edges. Scope mismatch errors. Cycle detection. Async finalizer in sync close. Override reset semantics. Deep child-container chains. PEP 604 unions in types_parser. -- Fixtures that paper over bugs. e.g. function-scoped fresh container hides cross-test cache bleed that would otherwise surface. -- Undocumented xfail / skip / flake markers — anything without a clear "Why:" comment is a finding.` - -const LOGIC_HEURISTICS = `Audit correctness. -- Scope rules. CLAUDE.md says: a provider can only be resolved from a container of the same or deeper scope. Verify the guard fires on every resolution path (Factory, ContextProvider, container_provider, Alias). Trace find_container(scope) on the parent chain. -- Cache lifecycle. Child cache vs parent cache isolation. Behavior on close() mid-resolution. Finalizer order (LIFO vs insertion order). Sync close() with an async finalizer — the recent "Raise on async finalizer in close_sync" commit suggests this path now raises; verify it actually does on every code path that calls close_sync. -- Override propagation. Shared overrides_registry semantics across the container tree. reset behavior. Interaction with caching (cached value present before override registered — does override take effect?). -- types_parser correctness. PEP 604 unions (X | Y), Optional, Annotated, TYPE_CHECKING strings, forward refs, generics, *args / **kwargs, default-valued params, Self. -- Container tree edges. build_child_container called on a closed parent? on the same parent concurrently? with a shallower scope than parent? With validate=True after cycles introduced post-creation?` - -function verifierPrompt(lens, finding) { - const findingJson = JSON.stringify(finding, null, 2) - - if (lens === 'reproduce') { - return `You are the REPRODUCE verifier for a bug-hunt audit finding. - -Your job: try to construct the minimum scenario that would actually trigger the claimed bug. If you can build a repro, the finding is confirmed. If you cannot — for any reason: the claim is too vague, the cited code does not exhibit the described behavior, the repro requires unrealistic conditions, the code path is unreachable — the finding is REFUTED. - -IMPORTANT: Default to "confirmed=false" when uncertain. False positives waste user attention more than false negatives. - -FINDING: -${findingJson} - -DO: -1. Open the cited file and read the surrounding context. -2. Construct (in your head or on paper) the smallest scenario that would trigger the bug: setup code, the trigger call, the observable wrong behavior. -3. If the scenario holds together, set confirmed=true and put the repro sketch in reasoning. -4. If it does not hold together, set confirmed=false and explain why in reasoning. - -Set lens="reproduce" and reclassification="unknown" (this lens does not reclassify). - -Return the structured verdict.` - } - - if (lens === 'read-real-code') { - return `You are the READ-REAL-CODE verifier for a bug-hunt audit finding. - -Your job: open the cited file at the cited line and confirm the code actually does what the finding claims. Many finder agents hallucinate; this lens catches that. - -IMPORTANT: Default to "confirmed=false" when uncertain. False positives waste user attention more than false negatives. - -FINDING: -${findingJson} - -DO: -1. Open the file at the cited path. Read the cited line plus enough surrounding context (10-30 lines) to understand control flow. -2. Compare what the code actually does to what the finding's description and evidence claim. -3. If the code matches the claim (the bug really is in this code at this location), set confirmed=true. -4. If the code does not match — the cited line is something else, the claim describes behavior the code does not exhibit, the evidence quote is fabricated or paraphrased badly — set confirmed=false. Quote what the code actually does in reasoning. - -Set lens="read-real-code" and reclassification="unknown" (this lens does not reclassify). - -Return the structured verdict.` - } - - // spec-vs-behavior - return `You are the SPEC-VS-BEHAVIOR verifier for a bug-hunt audit finding. - -Your job: cross-check the finding against the project's specifications (CLAUDE.md, README.md, relevant docstrings). Decide whether this is a bug in the code, a bug in the spec, or actually intended behavior that the finder misjudged. - -IMPORTANT: Default to "confirmed=false" when uncertain. False positives waste user attention more than false negatives. - -FINDING: -${findingJson} - -DO: -1. Re-read the relevant section of CLAUDE.md (e.g. "Scope hierarchy", "Resolution flow", "Registries"). -2. Re-read the relevant docstrings. -3. Decide: - - If the code violates a documented spec OR violates an obvious correctness contract not contradicted by spec: confirmed=true, reclassification="bug-in-code". - - If the spec says X but code does Y and the code's Y is the obviously correct behavior: confirmed=true, reclassification="bug-in-spec". - - If the spec explicitly endorses the behavior the finder flagged (e.g. "resolution is sync-only", "conservative feature set"): confirmed=false, reclassification="intended-behavior". Quote the spec line in reasoning. - - If uncertain: confirmed=false, reclassification="unknown". - -Set lens="spec-vs-behavior". - -Return the structured verdict.` -} - -function synthPrompt(survivors) { - return `You are the synthesizer for a bug-hunt audit of the modern-di repository. You receive the surviving findings (each adversarially verified by 3 lenses, majority-confirmed). Your job: - -1. DEDUPLICATE across dimensions. A "weak assertion" finding from tests and a "missed edge" from logic are often the same root issue. Merge them: keep the more specific title, union the evidence, list both source dimensions. - -2. TRIAGE every surviving finding into exactly one bucket: - - must-fix-now — correctness (logic dimension) or security, severity "high", all 3 verifier votes confirmed (verifier_votes.filter(v => v.confirmed).length === 3). - - should-fix-soon — severity "high" with 2/3 verifier confirmation, OR severity "medium" with 3/3 confirmation AND dimension is logic or security. - - nice-to-have — UX rough edges, low-severity logic findings, test weaknesses not currently masking known bugs. - - spec-fix — reclassification is "bug-in-spec" (regardless of severity). Code is correct; docs are wrong. - - wont-fix — reclassification is "intended-behavior". Record so they don't resurface next audit. - Findings outside these definitions: drop to nice-to-have. - -3. WRITE the report to .superpowers/audits/bug-hunt-report.md (git-ignored scratch) using your Write tool. Use this exact structure: - -\`\`\`markdown -# Bug-Hunt Audit Report — 2026-06-05 - -**Spec:** planning/specs/2026-06-05-bug-hunt-audit-design.md -**Plan:** planning/plans/2026-06-05-bug-hunt-audit-plan.md -**Survivors:** N findings post-verify, M after dedup - -## Summary - -| Bucket | Count | -|---|---| -| must-fix-now | … | -| should-fix-soon | … | -| nice-to-have | … | -| spec-fix | … | -| wont-fix | … | - -## must-fix-now - -### -- Dimension(s): logic -- File: modern_di/container.py:120-128 -- Severity: high -- Verifier confirmations: 3/3 - -**Description.** … - -**Evidence.** -\\\`\\\`\\\`python -… -\\\`\\\`\\\` - -**Reproduction.** -\\\`\\\`\\\`python -… -\\\`\\\`\\\` - -**Suggested fix.** … - -(repeat per finding) - -## should-fix-soon -(same structure) - -## nice-to-have -(same structure) - -## spec-fix -(same structure, but "Suggested fix" describes the doc/spec edit) - -## wont-fix -(same structure, plus a final line "Rationale:" quoting the spec/CLAUDE.md line that endorses this behavior) -\`\`\` - -4. After writing the report, return the structured summary (report_path = ".superpowers/audits/bug-hunt-report.md", counts per bucket). - -SURVIVORS (already verified): -${JSON.stringify(survivors, null, 2)} - -If survivors is empty, still write the report file with each bucket marked "(no findings)" and return zero counts.` -} - -// --- script body --- - -phase('Discover') -const context = await agent(DISCOVER_PROMPT, { - label: 'discover', - schema: CONTEXT_BLOB_SCHEMA, -}) -log(`discover: ${context.file_map.length} files mapped, ${context.behavior_claims.length} claims extracted`) - -phase('Find') -const findersConfig = [ - { dim: 'ux', heur: UX_HEURISTICS }, - { dim: 'security', heur: SECURITY_HEURISTICS }, - { dim: 'tests', heur: TESTS_HEURISTICS }, - { dim: 'logic', heur: LOGIC_HEURISTICS }, -] - -// Pipeline: each dimension flows through Find (1 finder) → Verify (3 verifiers per finding, majority vote). -// No barrier between Find and Verify — dimension A's findings start verifying while dimension B is still finding. -const perDimensionVerified = await pipeline( - findersConfig, - // Stage 1: Find - ({ dim, heur }) => - agent(finderPrompt(dim, heur, context), { - label: `find:${dim}`, - phase: 'Find', - schema: FINDER_RESULT_SCHEMA, - }), - // Stage 2: Verify every finding in this dimension (3 lenses each, majority vote) - (finderResult, original) => { - const findings = finderResult?.findings ?? [] - log(`verify:${original.dim}: ${findings.length} findings entering verify`) - return parallel(findings.map((f, i) => () => - parallel(['reproduce', 'read-real-code', 'spec-vs-behavior'].map(lens => () => - agent(verifierPrompt(lens, f), { - label: `verify:${original.dim}#${i}:${lens}`, - phase: 'Verify', - schema: VERDICT_SCHEMA, - }) - )) - .then(votes => { - const valid = votes.filter(Boolean) - const confirms = valid.filter(v => v.confirmed).length - const survives = confirms >= 2 - // Use spec-vs-behavior verdict if present, else "unknown" - const specVote = valid.find(v => v.lens === 'spec-vs-behavior') - const reclassification = specVote?.reclassification ?? 'unknown' - return { - ...f, - verifier_votes: valid, - survives, - reclassification, - } - }) - )) - } -) - -const verifiedFlat = perDimensionVerified.filter(Boolean).flat().filter(Boolean) -const survivors = verifiedFlat.filter(v => v.survives) -log(`verify: ${verifiedFlat.length} verified, ${survivors.length} survived majority vote`) - -phase('Synthesize') -const summary = await agent(synthPrompt(survivors), { - label: 'synth', - phase: 'Synthesize', - schema: SYNTH_SUMMARY_SCHEMA, - agentType: 'general-purpose', -}) - -log(`synth: report written to ${summary.report_path}`) -log(`synth: must=${summary.counts.must_fix_now} should=${summary.counts.should_fix_soon} nice=${summary.counts.nice_to_have} spec=${summary.counts.spec_fix} wont=${summary.counts.wont_fix}`) - -return summary diff --git a/planning/scripts/perf-readability-audit.workflow.mjs b/planning/scripts/perf-readability-audit.workflow.mjs deleted file mode 100644 index ce374f34..00000000 --- a/planning/scripts/perf-readability-audit.workflow.mjs +++ /dev/null @@ -1,381 +0,0 @@ -export const meta = { - name: 'perf-readability-audit', - description: 'Two-lens (performance + readability) decision-grade audit of modern_di, gated against known ground (settled ADRs, already-recorded open issues), producing a leverage-vs-risk report.', - whenToUse: 'Run for a fresh both-axes refactor survey that separates new perf hypotheses from already-settled ground and finds off-hot-path readability seams.', - // The report is transient scratch: a sweep's durable output is a PR plus a GitHub issue or an ADR. - phases: [ - { title: 'Discover', detail: 'file map + known ground (ADRs, open issues, guard scenarios)' }, - { title: 'Find', detail: 'two parallel lens finders: performance, readability' }, - { title: 'Verify', detail: 'three lenses per finding, majority vote' }, - { title: 'Synthesize', detail: 'dedup, leverage-vs-risk triage, write report' }, - ], -} - -// ---------- schemas ---------- - -const CONTEXT_BLOB_SCHEMA = { - type: 'object', - additionalProperties: false, - required: ['baseline_commit', 'file_map', 'decisions', 'open_issues', 'guard_scenarios', 'competitive_note', 'recent_commits'], - properties: { - baseline_commit: { type: 'string', description: 'short HEAD sha from `git rev-parse --short HEAD`' }, - file_map: { - type: 'array', - items: { - type: 'object', - additionalProperties: false, - required: ['path', 'lines', 'role'], - properties: { - path: { type: 'string' }, - lines: { type: 'integer' }, - role: { type: 'string', description: 'one-line responsibility' }, - }, - }, - }, - decisions: { - type: 'array', - description: 'Every docs/adr/*.md ruling: slug + one-line holding (especially what was rejected).', - items: { - type: 'object', - additionalProperties: false, - required: ['slug', 'holding'], - properties: { - slug: { type: 'string' }, - holding: { type: 'string' }, - }, - }, - }, - open_issues: { - type: 'array', - description: 'Every open GitHub issue: number, short title, one-line gist.', - items: { - type: 'object', - additionalProperties: false, - required: ['number', 'title', 'gist'], - properties: { - number: { type: 'integer' }, - title: { type: 'string' }, - gist: { type: 'string' }, - }, - }, - }, - guard_scenarios: { - type: 'array', - description: 'The G1-G15 catalog from benchmarks/README.md: id + what it isolates.', - items: { - type: 'object', - additionalProperties: false, - required: ['id', 'isolates'], - properties: { - id: { type: 'string' }, - isolates: { type: 'string' }, - }, - }, - }, - competitive_note: { type: 'string', description: 'Where modern-di sits vs rivals (docs/introduction/performance.md) and the accepted floor (the no-exec stance).' }, - recent_commits: { type: 'array', items: { type: 'string' } }, - }, -} - -const RAW_FINDING_SCHEMA = { - type: 'object', - additionalProperties: false, - required: ['lens', 'title', 'file', 'line', 'description', 'evidence', 'suggested_direction', 'leverage', 'risk', 'confidence', 'guard_scenario', 'hot_path', 'invariant_at_risk', 'settled_ref'], - properties: { - lens: { enum: ['performance', 'readability'] }, - title: { type: 'string', description: 'short noun phrase' }, - file: { type: 'string', description: 'relative path' }, - line: { type: 'string', description: 'integer or "start-end" as string' }, - description: { type: 'string', description: '1-3 sentences on what could be better' }, - evidence: { type: 'string', description: 'exact code snippet indicted' }, - suggested_direction: { type: 'string', description: 'one-line direction, not a patch' }, - leverage: { enum: ['high', 'medium', 'low'], description: 'estimated payoff (perf: expected speedup magnitude / breadth; readability: how much clarity it buys)' }, - risk: { enum: ['high', 'medium', 'low'], description: 'risk to an invariant or a settled stance' }, - confidence: { enum: ['high', 'medium', 'low'] }, - guard_scenario: { type: 'string', description: 'PERF: the G-id (G1-G15) that would confirm it, plus expected-leverage note. READABILITY: "n/a".' }, - hot_path: { type: 'boolean', description: 'true if the code is on the resolve hot path' }, - invariant_at_risk: { type: 'string', description: 'READABILITY: the invariant the change must not break (frame count / 100% cov / zero-dep / behavior), or "none". PERF: "n/a".' }, - settled_ref: { type: 'string', description: 'if you suspect this matches an ADR or an open issue, name it here; else "".' }, - }, -} - -const FINDER_RESULT_SCHEMA = { - type: 'object', - additionalProperties: false, - required: ['lens', 'findings'], - properties: { - lens: { enum: ['performance', 'readability'] }, - findings: { type: 'array', items: RAW_FINDING_SCHEMA }, - }, -} - -const VERDICT_SCHEMA = { - type: 'object', - additionalProperties: false, - required: ['lens', 'confirmed', 'reasoning', 'settled_match'], - properties: { - lens: { enum: ['read-real-code', 'decision-conflict', 'invariant-safety'] }, - confirmed: { type: 'boolean', description: 'read-real-code: code matches claim. decision-conflict: finding is FRESH (not already settled). invariant-safety: change is safe & leverage-honest. Default false when uncertain.' }, - reasoning: { type: 'string', description: '1-3 sentences citing what the code/decision actually is.' }, - settled_match: { type: 'string', description: 'decision-conflict lens only: the ADR slug / issue number this duplicates, or "" if genuinely fresh. Other lenses set "".' }, - }, -} - -const SYNTH_SUMMARY_SCHEMA = { - type: 'object', - additionalProperties: false, - required: ['report_path', 'counts'], - properties: { - report_path: { type: 'string' }, - counts: { - type: 'object', - additionalProperties: false, - required: ['do_first', 'needs_decision', 'cleanup', 'skip', 'already_settled'], - properties: { - do_first: { type: 'integer' }, - needs_decision: { type: 'integer' }, - cleanup: { type: 'integer' }, - skip: { type: 'integer' }, - already_settled: { type: 'integer' }, - }, - }, - }, -} - -// ---------- prompts ---------- - -const DISCOVER_PROMPT = `You are the discover agent for a two-lens refactor audit (performance + readability) of the modern-di repository (a zero-dependency Python DI library). Produce a single structured context blob that grounds the downstream finders and, crucially, the "already-settled" guardrail. Do NOT analyze or opine. - -Do exactly this: - -1. baseline_commit: run \`git rev-parse --short HEAD\`. - -2. file_map: every file under modern_di/ (source) — relative path, line count, one-line role. Skip __pycache__. - -3. decisions: read every file under docs/adr/. For each, record its slug (the filename without number/extension is fine) and a one-line holding — WHAT WAS DECIDED, especially what was rejected (e.g. "declined folding ContextRegistry into Container", "no exec codegen"). These are the settled-ground guardrail. - -4. open_issues: run \`gh issue list --state open --limit 100 --json number,title,body\`. For each, record its number, a short title, and a one-line gist. Capture the perf items faithfully (warm-singleton headroom, free-threaded non-scaling) — these are already known and must not be re-proposed as fresh. - -5. guard_scenarios: read benchmarks/README.md and record the G1-G15 catalog: each id and what it isolates. - -6. competitive_note: one paragraph summarizing where modern-di currently sits vs rivals and what the accepted floor is — the standing from docs/introduction/performance.md, the no-exec stance from docs/adr/0017-exec-hot-path-declined.md. - -7. recent_commits: subject lines from \`git log --oneline -20\`. - -Return the structured blob only.` - -const PERF_FINDER_PROMPT = (ctx) => `You are the PERFORMANCE-lens finder for a refactor audit of modern-di (zero-dependency Python DI library). The resolve hot path is already at a documented floor — your job is NOT to find easy wins, it is to surface genuinely-new, defensible perf HYPOTHESES and be honest about what is already settled. - -CONTEXT BLOB (file map + the settled corpus you must respect): -${JSON.stringify(ctx, null, 2)} - -RULES: -- Read the ACTUAL source before any finding (open modern_di/resolver_compiler.py, container.py, registries/*.py, providers/*.py, wiring.py, dependency_graph.py). The blob is a map, not the code. -- Every finding is a HYPOTHESIS, not a claim. Set guard_scenario to the G-id (G1-G15) that would confirm it plus a one-line expected-leverage note. No prototyping, no bench runs — you are proposing what to measure, not measuring. -- KNOWN GROUND IS OFF LIMITS as an "open" proposal. If your idea matches an ADR ruling or an open issue (warm-singleton memo-swap, child lazy-alloc, exec/codegen, free-threaded immortalization, per-provider compile seam, folding ContextRegistry), you may only raise it if you bring GENUINELY NEW evidence — and you MUST name the settled item in settled_ref. When in doubt, set settled_ref and let the verifier judge. -- Respect the stances as settled: no exec/codegen (zero-dep), conservative feature set, sync-only resolution. Do not propose them. -- No lint/style. ruff and ty own those. -- Set hot_path (is this on the resolve path?), leverage, risk, confidence. Set invariant_at_risk to "n/a" for perf. - -Aim for 4-12 findings; quality over quantity. An empty list is acceptable if there is nothing new. Return the structured object with lens="performance".` - -const READABILITY_FINDER_PROMPT = (ctx) => `You are the READABILITY/STRUCTURE-lens finder for a refactor audit of modern-di (zero-dependency Python DI library). Find concrete file:line seams and simplifications that make the code clearer WITHOUT regressing performance or breaking invariants. - -CONTEXT BLOB (file map + settled corpus): -${JSON.stringify(ctx, null, 2)} - -WEIGHTING — spend your depth where complexity lives: -- FULL-DEPTH read: exceptions.py (663 lines), container.py, resolver_compiler.py, factory.py, dependency_graph.py, wiring.py, types_parser.py. -- LIGHT confirmation pass: the small stable files (scope.py, group.py, alias.py, context_provider.py, container_provider.py, abstract.py, types.py, suggester.py, integrations.py, registries/*). - -RULES: -- Read the ACTUAL file before any finding. Cite exact file:line and quote the evidence. -- HOT-PATH TENSION: resolver_compiler.py deliberately inlines the override-guard/navigate/closed-check preamble across all six compiled closures to hold the per-node frame at 1. Do NOT propose extracting shared hot-path helpers unless you can argue it is frame-count-neutral — set hot_path=true and name the invariant. Off-hot-path clarity is the open ground. -- For every finding set invariant_at_risk to the thing the change must not break (frame count / 100% coverage / zero-dep / behavior), or "none". -- No lint/style (ruff/ty own it). No behavior changes dressed as readability — if it changes semantics, it is out of scope. -- If a structural idea matches a settled decision (e.g. "fold ContextRegistry into Container", "provider-facing seam"), name it in settled_ref. -- Set guard_scenario="n/a", leverage (clarity bought), risk, confidence. - -Aim for 6-16 findings. Return the structured object with lens="readability".` - -const verifierPrompt = (lens, finding, ctx) => { - const f = JSON.stringify(finding, null, 2) - if (lens === 'read-real-code') { - return `You are the READ-REAL-CODE verifier for a refactor-audit finding. Open the cited file at the cited line and confirm the code ACTUALLY does what the finding claims. Finders hallucinate; catch it. - -Default confirmed=false when uncertain. False positives waste attention. - -FINDING: -${f} - -DO: open the file, read the cited line + 10-30 lines of context. If the code matches the claim (the seam / cost really is there), confirmed=true. If the cited line is something else or the evidence is fabricated/misquoted, confirmed=false and quote what the code actually does. Set lens="read-real-code", settled_match="". Return the verdict.` - } - if (lens === 'decision-conflict') { - return `You are the DECISION-CONFLICT verifier — the mature-repo guardrail. Decide whether this finding is GENUINELY FRESH or already settled. - -KNOWN GROUND — an ADR is a settled refusal, an open issue is work already recorded: -decisions: ${JSON.stringify(ctx.decisions, null, 2)} -open_issues: ${JSON.stringify(ctx.open_issues, null, 2)} - -FINDING: -${f} - -DO: -1. Check the finding against every ADR holding and open issue. -2. If it duplicates an ADR ruling or an already-open issue WITHOUT genuinely new evidence: confirmed=false, and set settled_match to that ADR slug / issue number. Quote the holding — or the issue's gist — in reasoning. -3. If it is genuinely fresh (or brings new evidence a skeptic would accept): confirmed=true, settled_match="". -Default confirmed=false when the overlap is real and the "new evidence" is thin. Set lens="decision-conflict". Return the verdict.` - } - // invariant-safety / leverage-realism - return `You are the INVARIANT-SAFETY / LEVERAGE-REALISM verifier. Judge whether the finding is safe and honest, not whether it is nice. - -FINDING: -${f} - -DO: -- READABILITY finding (guard_scenario="n/a"): would the suggested direction preserve behavior, 100% coverage, zero-dependency, AND (if hot_path) the per-node frame count? If it silently changes semantics or would regress the hot path, confirmed=false and say why. Else confirmed=true. -- PERFORMANCE finding: is guard_scenario a REAL G-id that actually isolates the claimed cost, and is the expected-leverage estimate defensible rather than hand-waved (given the documented floor)? If the scenario mapping is wrong or the leverage is fantasy, confirmed=false. Else confirmed=true. -Default confirmed=false when uncertain. Set lens="invariant-safety", settled_match="". Return the verdict.` -} - -const synthPrompt = (survivors, ctx) => `You are the synthesizer for a two-lens (performance + readability) refactor audit of modern-di. You receive findings each adversarially verified by 3 lenses (read-real-code, decision-conflict, invariant-safety). Write ONE decision-grade report. - -BASELINE COMMIT: ${ctx.baseline_commit} - -TRIAGE — each finding carries verifier_votes (3), plus derived flags. Assign exactly one bucket: -- already-settled — the decision-conflict verdict has a NON-EMPTY settled_match. Route here regardless of other votes; record the citation. (These exist so the next audit doesn't re-raise them.) -- Otherwise a finding must SURVIVE (read-real-code confirmed AND invariant-safety confirmed — i.e. it is real and safe/honest) to be actionable. Drop findings that fail read-real-code (hallucination) entirely. -- Among survivors, bucket by the finder's leverage/risk: - - do-first — leverage high, risk low. - - needs-decision — leverage high AND risk high (a maintainer must rule: an invariant or stance is in play). Also route here any survivor whose invariant_at_risk is non-trivial even at medium leverage. - - cleanup — leverage medium/low, risk low. - - skip — leverage low, risk high (record briefly so it's not re-found). - -DEDUPLICATE first: a perf and a readability finding on the same file:line region are often one root; merge, keep the sharper title, union evidence, list both lenses. - -WRITE the report to .superpowers/audits/perf-readability-report.md (git-ignored scratch) with your Write tool, EXACTLY this structure: - -# Perf & Readability Refactor Audit Report — 2026-07-19 - -**Spec:** the PR body for this sweep. -**Baseline:** ${ctx.baseline_commit} -**Method:** Two-lens multi-agent workflow (perf + readability finders; 3-lens adversarial verify: read-real-code, decision-conflict, invariant-safety; majority survive). No code changes; perf findings are bench-mapped hypotheses. - -## Summary - -| Bucket | Count | -|---|---| -| do-first | … | -| needs-decision | … | -| cleanup | … | -| skip | … | -| already-settled | … | - -One paragraph: the dominant themes and the single most important takeaway (e.g. "the hot path is at floor; the readable gains are off-path in exceptions.py / container.py"). - -## do-first -### -- Lens(es): performance | readability -- File: modern_di/x.py:NN -- Leverage / Risk: high / low · Confidence: high · Hot path: no -- (perf only) Confirming scenario: G2 — expected leverage: … -- (readability only) Invariant guarded: none | frame-count | coverage - -**What.** … - -**Evidence.** -\`\`\`python -… -\`\`\` - -**Direction.** … (one line, not a patch) - -(repeat per finding) - -## needs-decision -(same structure; add a **Decision.** line naming the invariant/stance the maintainer must weigh) - -## cleanup -(same structure) - -## skip -(same structure; one-line **Why skip.**) - -## already-settled -### <title> -- Matches: <ADR slug / issue number> -- Lens(es): … - -**Why settled.** Quote the ADR ruling or the issue. (These are recorded, not actioned.) - -After writing, return the structured summary (report_path + counts per bucket). If survivors is empty, still write the report with each bucket "(no findings)" and return zero counts. - -SURVIVORS (verified): -${JSON.stringify(survivors, null, 2)}` - -// ---------- script body ---------- - -phase('Discover') -const context = await agent(DISCOVER_PROMPT, { - label: 'discover', - schema: CONTEXT_BLOB_SCHEMA, - model: 'haiku', -}) -log(`discover: ${context.file_map.length} files, ${context.decisions.length} decisions, ${context.open_issues.length} open issues, ${context.guard_scenarios.length} guard scenarios @ ${context.baseline_commit}`) - -phase('Find') -const lenses = [ - { lens: 'performance', prompt: PERF_FINDER_PROMPT(context) }, - { lens: 'readability', prompt: READABILITY_FINDER_PROMPT(context) }, -] - -// Pipeline: each lens flows Find -> Verify with no barrier between them. -const perLensVerified = await pipeline( - lenses, - ({ lens, prompt }) => - agent(prompt, { label: `find:${lens}`, phase: 'Find', schema: FINDER_RESULT_SCHEMA, model: 'haiku' }), - (finderResult, original) => { - const findings = finderResult?.findings ?? [] - log(`verify:${original.lens}: ${findings.length} findings entering verify`) - return parallel(findings.map((f, i) => () => - parallel(['read-real-code', 'decision-conflict', 'invariant-safety'].map(vl => () => - agent(verifierPrompt(vl, f, context), { - label: `verify:${original.lens}#${i}:${vl}`, - phase: 'Verify', - schema: VERDICT_SCHEMA, - model: 'haiku', - }) - )).then(votes => { - const valid = votes.filter(Boolean) - const decisionVote = valid.find(v => v.lens === 'decision-conflict') - const codeVote = valid.find(v => v.lens === 'read-real-code') - const safeVote = valid.find(v => v.lens === 'invariant-safety') - return { - ...f, - verifier_votes: valid, - settled_match: decisionVote?.settled_match ?? '', - real: codeVote?.confirmed ?? false, - safe_or_honest: safeVote?.confirmed ?? false, - } - }) - )) - } -) - -const verifiedFlat = perLensVerified.filter(Boolean).flat().filter(Boolean) -// Keep: anything matched-as-settled (for the record) OR a real+safe survivor. -const survivors = verifiedFlat.filter(v => v.settled_match || (v.real && v.safe_or_honest)) -log(`verify: ${verifiedFlat.length} verified, ${survivors.length} kept (survivors + already-settled)`) - -phase('Synthesize') -const summary = await agent(synthPrompt(survivors, context), { - label: 'synth', - phase: 'Synthesize', - schema: SYNTH_SUMMARY_SCHEMA, - agentType: 'general-purpose', -}) - -log(`synth: report at ${summary.report_path}`) -log(`synth: do-first=${summary.counts.do_first} needs-decision=${summary.counts.needs_decision} cleanup=${summary.counts.cleanup} skip=${summary.counts.skip} already-settled=${summary.counts.already_settled}`) - -return summary