diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3a817fc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,27 @@ +# CLAUDE.md + +Guidance for agents working in `modern-di-fastapi` — the +[modern-di](https://modern-di.modern-python.org) integration for FastAPI. + +## Workflow + +Before making a change, follow the **Quick path** in +[`planning/README.md`](planning/README.md) — the authoritative planning +convention. Pick a lane (Full / Lightweight / Tiny), create the change bundle +under `planning/changes/` when the lane calls for one, and run +`just check-planning` before pushing. + +## Architecture + +[`architecture/`](architecture/) holds the living truth about what the system +does **now** — one file per capability, plus `glossary.md`. **When a change +alters a capability's behavior, update the matching +`architecture/.md` in the same PR**, alongside the code; the *why* +stays in the change bundle under `planning/changes/`. + +## Build & checks + +- `just lint` / `just lint-ci` — format, ruff, `ty`; `lint-ci` also runs + `check-planning`. +- `just test` — pytest (100% coverage required via `just test-ci`). +- `just index` — print the generated planning index. diff --git a/Justfile b/Justfile index 8ccbb3b..498be36 100644 --- a/Justfile +++ b/Justfile @@ -15,6 +15,13 @@ lint-ci: uv run ruff format --check uv run ruff check --no-fix uv run ty check + uv run python planning/index.py --check + +index: + uv run python planning/index.py + +check-planning: + uv run python planning/index.py --check test *args: uv run --no-sync pytest {{ args }} diff --git a/architecture/README.md b/architecture/README.md new file mode 100644 index 0000000..ca31218 --- /dev/null +++ b/architecture/README.md @@ -0,0 +1,14 @@ +# Architecture + +The living truth about what `modern-di-fastapi` does **now**. One file per +capability, plus a single [`glossary.md`](glossary.md) (the ubiquitous +language) — living prose, no frontmatter, dated by git. + +**Promotion rule:** when a change alters a capability's behavior, the +implementing PR hand-edits the matching `architecture/.md` in the +same diff, alongside the code. That hand-edit is what keeps this directory true; +the *why* lives in the change bundle under +[`planning/changes/`](../planning/changes/). + +Capability files and `glossary.md` are authored lazily — each appears when the +first capability or term is worth pinning down. diff --git a/planning/.convention-version b/planning/.convention-version new file mode 100644 index 0000000..9084fa2 --- /dev/null +++ b/planning/.convention-version @@ -0,0 +1 @@ +1.1.0 diff --git a/planning/README.md b/planning/README.md new file mode 100644 index 0000000..f6cb97b --- /dev/null +++ b/planning/README.md @@ -0,0 +1,152 @@ +# Planning + +How change is proposed, designed, and recorded in `modern-di-fastapi`. Start +with the [Quick path](#quick-path-start-here); reach for +[Conventions](#conventions) when it isn't enough. + +The portable two-axis planning convention: [`architecture/`](../architecture/) +(repo root) holds the living truth about what the system does **now**; +`planning/changes/` records how it got there. The convention prose below is +sourced from the canonical repo +[`lesnik512/planning-convention`](https://github.com/lesnik512/planning-convention); +the applied version is in [`.convention-version`](.convention-version). To +update it, run that repo's `APPLY.md` flow. + +## Quick path (start here) + +> The fast lane for making a change. The full reference is in +> [Conventions](#conventions) below — read it only when this isn't enough. + +**1. Choose a lane — first matching rule wins:** + +1. Any of: needs design judgment · new file/module · public-API change · + cross-cutting or multi-file · non-trivial test design → **Full** + (`design.md` + `plan.md`) +2. Purely mechanical: typo · dep bump · linter/formatter/CI tweak · + mechanical rename · single-line config → **Tiny** (no bundle, conventional + commit) +3. Small-but-real, none of the above: ≲30 LOC net · ≤2 files · no new file · + no public-API change · one straightforward test → **Lightweight** + (`change.md`) + +Ambiguous between two? Take the heavier. A `change.md` that outgrows its lane +splits into `design.md` + `plan.md`. + +**2. Create the bundle** (Full / Lightweight only): +`planning/changes/YYYY-MM-DD.NN-/`, where `.NN` is a zero-padded +intra-day counter. Copy the matching template from +[`_templates/`](_templates/). + +**3. Ship in the implementing PR:** hand-edit the affected +`architecture/.md`, finalize the bundle's `summary:` to the +realized result, and run `just check-planning` before pushing. + +## Conventions + +> This is the portable convention, sourced from the canonical repo +> [`lesnik512/planning-convention`](https://github.com/lesnik512/planning-convention) +> (applied version in [`.convention-version`](.convention-version)). To update +> it, run that repo's `APPLY.md` flow. The generated change index (`just index`) +> and the `## Other` pointers below are repo-local. + +### Two axes, never mixed + +- **`architecture/` (repo root) — the present.** One file per capability, plus + a single `glossary.md` (the ubiquitous language); living prose, updated in the + same PR that ships the change. The truth home. +- **`planning/changes/` — the past-and-pending.** One folder per change, + kept in place after ship. + +A change **promotes** its conclusions into the affected +`architecture/.md` by hand **in the implementing PR, alongside the +code** — the edit rides in the same diff and is reviewed with it, never applied +as a separate post-merge step. That hand-edit is what keeps `architecture/` +true; the bundle stays in `changes/` as the *why*. + +### Glossary + +`architecture/glossary.md` is the project's **ubiquitous language** — one page +defining the domain terms that code, specs, and capability pages all share. Like +the capability files beside it, it is living prose with **no frontmatter**, dated +by git, and authored lazily: it appears when the first term is worth pinning down. + +Each entry is a term, a one-or-two-sentence definition of what it *is* (not what +it does), and an optional `_Avoid_:` line naming the synonyms to reject: + +```md +**Timer**: +A scheduled future delivery, identified by a timer id. +_Avoid_: job, task, alarm +``` + +Keep it a glossary, not a spec — no implementation detail. A change that +introduces or sharpens a term updates `glossary.md` in the same PR, the same way +a behavior change promotes into a capability file. + +### Change bundles + +A change is a folder `changes/YYYY-MM-DD.NN-/`: + +- `YYYY-MM-DD` — proposal date; `.NN` — zero-padded intra-day counter + (`.01`, `.02`, …) that breaks same-date ties so the timeline sorts stably. +- `` — kebab-case description, not a story ID. + +`summary` is written when the change is created (the intent one-liner) and +**finalized at ship** to state the realized result — set in the implementing +PR, alongside the code and the `architecture/` promotion. No post-merge +bookkeeping, no folder move. `date` and `slug` are never written — they are +read from the bundle's directory name. + +### Three lanes + +| Lane | Artifacts | Use when | +|------|-----------|----------| +| **Full** | `design.md` + `plan.md` | design judgment; new file/module; public-API change; cross-cutting/multi-file; non-trivial test design | +| **Lightweight** | `change.md` | small-but-real: ≲30 LOC net, ≤2 files, no new file, no public-API change, single straightforward test | +| **Tiny** | none — conventional commit | typo, dep bump, linter/formatter/CI tweak, mechanical rename, single-line config | + +Heavier lane wins on ambiguity. A `change.md` that outgrows its lane splits +into `design.md` + `plan.md`. + +### Artifacts at a glance + +- **`design.md`** — the spec: the *thinking* (why, design, trade-offs, scope). +- **`plan.md`** — the plan: the *sequencing* (the executor's task checklist). +- **`change.md`** — both, condensed, for the lightweight lane. +- **`releases/.md`** — per-release user-facing notes. +- **`audits/-.md`** — findings from a code/docs/bug-hunt sweep; + spawns fix changes. +- **`retros/-.md`** — what we learned after a body of work. +- **`deferred.md`** — real-but-unscheduled items, each with a revisit trigger. +- **`decisions/-.md`** — one file per design decision taken + (especially options *rejected*), each with a revisit trigger; listed by + `just index`. + +Templates live in [`_templates/`](_templates/). + +### Frontmatter + +`date` and `slug` are **derived from the directory / file name** — never +repeated in frontmatter. So: + +- `design.md` / `change.md`: `summary` (single line) only. +- `plan.md`: **no frontmatter** — its identity is the bundle directory. +- `decisions/*.md`: `status` (accepted|superseded), `summary`, and optional + `supersedes` / `superseded_by`. +- Files in `architecture/` carry **no** frontmatter — living prose, dated by git. + +**`summary`** is one line: written at creation as the intent, then **finalized +at ship** to state the realized result — what shipped and its effect. It is the +only field the index renders. + +## Index + +Run `just index` to print the generated change-and-decision index +(newest-first) to stdout. It is a query over the bundle / decision files, never +a committed artifact. + +## Other + +- Canonical convention: [`lesnik512/planning-convention`](https://github.com/lesnik512/planning-convention) +- Applied version: [`.convention-version`](.convention-version) +- Truth home: [`architecture/`](../architecture/) diff --git a/planning/_templates/change.md b/planning/_templates/change.md new file mode 100644 index 0000000..d4c8962 --- /dev/null +++ b/planning/_templates/change.md @@ -0,0 +1,32 @@ +--- +summary: One line — shown in the generated index. Written at creation; finalize at ship to state the realized result. +--- + +# Change: One-line capitalized title + +**Lane:** lightweight — ≲30 LOC net, ≤2 files, no new file, no public-API +change, a single straightforward test. If it outgrows this, split into +`design.md` + `plan.md`. + +## Goal + +One or two sentences: what changes and why. + +## Approach + +The shape of the change in brief — enough that a reviewer sees the design +without a full spec. Link the truth home (`architecture/.md`) if a +capability contract moves. + +## Files + +- `path/to/file.py` — what changes +- `tests/test_x.py` — test added / updated + +## Verification + +- [ ] Failing test first — command + expected error. +- [ ] Apply the change. +- [ ] Test passes — command. +- [ ] `just test` — full suite green. +- [ ] `just lint` — clean. diff --git a/planning/_templates/decision.md b/planning/_templates/decision.md new file mode 100644 index 0000000..45ccaf0 --- /dev/null +++ b/planning/_templates/decision.md @@ -0,0 +1,23 @@ +--- +status: accepted # accepted | superseded +summary: One line — shown in `just index`. +supersedes: null +superseded_by: null +--- + +# One-line capitalized title + +**Decision:** What was decided, in a sentence. + +## Context + +Why this came up; the options that were on the table. + +## Decision & rationale + +The call and why — including why the alternatives were rejected. Enough that a +future explorer doesn't re-litigate it. + +## Revisit trigger + +The concrete signal that should reopen this decision. diff --git a/planning/_templates/design.md b/planning/_templates/design.md new file mode 100644 index 0000000..d63e22d --- /dev/null +++ b/planning/_templates/design.md @@ -0,0 +1,49 @@ +--- +summary: One line — shown in the generated index. Written at creation; finalize at ship to state the realized result. +--- + +# Design: One-line capitalized title + +## Summary + +One paragraph. What changes, at the level a reader needs to decide if this +spec is worth reading in full. + +## Motivation + +Why now. What is broken or missing. Concrete observations / numbers, not +abstract complaints. Link to memory entries or earlier specs when relevant. + +## Non-goals + +What is deliberately out of scope and (when nontrivial) why. Each item is +a sentence; one line each. + +## Design + +### 1. + +What changes, in enough detail that a reader who has not seen the codebase +can follow. Code samples / diagrams welcome. + +### 2. + +... + +## Operations + +Out-of-repo steps (DNS, infra, external account changes). Omit if none. + +## Out of scope + +Already covered above under Non-goals if appropriate. Repeat-list of +explicitly-excluded follow-ups belongs here when the list is long. + +## Testing + +How we know it landed correctly. New pytest? Smoke check on live URL? +Lint pass? Be specific. + +## Risk + +What could go wrong, ranked by likelihood × impact. Mitigations. diff --git a/planning/_templates/glossary.md b/planning/_templates/glossary.md new file mode 100644 index 0000000..82385c3 --- /dev/null +++ b/planning/_templates/glossary.md @@ -0,0 +1,15 @@ +# Glossary + +The project's ubiquitous language — the domain terms that code, specs, and +capability pages share. Living prose, no frontmatter, dated by git. Each entry is +a term, what it *is* (not what it does), and the synonyms to avoid. No +implementation detail; this is a glossary, not a spec. + +**Term**: +A one-or-two-sentence definition of what it is. +_Avoid_: rejected-synonym, another-one + +**Another term**: +Define what it is, tightly. Group related terms under `##` subheadings when +natural clusters emerge; a flat list is fine when they don't. +_Avoid_: … diff --git a/planning/_templates/plan.md b/planning/_templates/plan.md new file mode 100644 index 0000000..132d720 --- /dev/null +++ b/planning/_templates/plan.md @@ -0,0 +1,46 @@ +# — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps +> use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** One sentence — what shipping this plan achieves. No design +rationale; link to the spec for that. + +**Spec:** [`design.md`](./design.md) + +**Branch:** `feat/my-change` (or `fix/`, `chore/`, etc.) + +**Commit strategy:** Per-task commits / single commit / squash on merge. +Whichever fits. + +--- + +### Task 1: + +**Files:** +- Modify: `path/to/file.py` +- Create: `path/to/new.py` + +One sentence on what this task accomplishes. No deeper reasoning — that's +in the spec. + +- [ ] **Step 1: ** + + Run / edit / verify command. Expected output. + +- [ ] **Step 2: ** + + ... + +- [ ] **Step 3: Commit** + + ```bash + git add path/to/file.py + git commit -m ": " + ``` + +--- + +### Task 2: ... diff --git a/planning/_templates/release.md b/planning/_templates/release.md new file mode 100644 index 0000000..5081187 --- /dev/null +++ b/planning/_templates/release.md @@ -0,0 +1,38 @@ +# + + + + + +## 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 dependents must do — e.g. bump their version floor — or "No action +needed" when there is no API change. Omit if the project has no downstreams. + +## Internals + +- Coverage / tooling notes. diff --git a/planning/changes/.gitkeep b/planning/changes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/planning/decisions/.gitkeep b/planning/decisions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/planning/deferred.md b/planning/deferred.md new file mode 100644 index 0000000..a282e77 --- /dev/null +++ b/planning/deferred.md @@ -0,0 +1,3 @@ +# Deferred + +Real-but-unscheduled items, each with a concrete revisit trigger. (Empty for now.) diff --git a/planning/index.py b/planning/index.py new file mode 100644 index 0000000..a1632e1 --- /dev/null +++ b/planning/index.py @@ -0,0 +1,193 @@ +# ruff: noqa: INP001, D212 # planning/ is not a Python package; D212/D213 conflict differs from faststream-outbox +""" +Generate the planning index from frontmatter. + +Run via ``just index``. Globs ``planning/changes/*/`` (each bundle's +``design.md``, falling back to ``change.md``) and ``planning/decisions/*.md``, +reads their frontmatter, and prints a Markdown listing to stdout — changes +then decisions, newest-first. Never writes a file: +the listing is a query over the files, not a committed artifact. + +``date`` and ``slug`` are derived from the directory / file name, not +frontmatter — the name is the single source of truth for both. +""" + +import pathlib +import re +import sys + + +CHANGES_DIR = pathlib.Path(__file__).parent / "changes" +DECISIONS_DIR = pathlib.Path(__file__).parent / "decisions" +VALID_DECISION_STATUS = {"accepted", "superseded"} +BUNDLE_RE = re.compile(r"^(?P\d{4}-\d{2}-\d{2})\.\d{2}-(?P.+)$") +DECISION_RE = re.compile(r"^(?P\d{4}-\d{2}-\d{2})-(?P.+)$") +ALLOWED_BUNDLE_FILES = {"design.md", "plan.md", "change.md"} +SPEC_REQUIRED = ("summary",) +DECISION_REQUIRED = ("status", "summary") + + +def parse_frontmatter(text: str) -> dict[str, str]: + """Parse a single-line-scalar YAML frontmatter block into a dict.""" + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return {} + fields: dict[str, str] = {} + for line in lines[1:]: + if line.strip() == "---": + break + if line[:1] in (" ", "\t"): + continue + key, sep, value = line.partition(": ") + if not sep: + continue + cleaned = value.strip().strip('"').strip("'") + fields[key.strip()] = "" if cleaned == "null" else cleaned + return fields + + +def _named(fields: dict[str, str], name: str, pattern: re.Pattern[str]) -> dict[str, str]: + """Inject ``date``/``slug`` derived from a dir/file name into ``fields``.""" + match = pattern.match(name) + if match: + fields["date"] = match.group("date") + fields["slug"] = match.group("slug") + return fields + + +def load_bundles() -> list[dict[str, str]]: + """Read each bundle's summary; derive date/slug from the directory name.""" + bundles: list[dict[str, str]] = [] + if not CHANGES_DIR.is_dir(): + return bundles + for bundle in sorted(CHANGES_DIR.iterdir()): + if not bundle.is_dir(): + continue + spec = bundle / "design.md" + if not spec.exists(): + spec = bundle / "change.md" + if not spec.exists(): + continue + fields = _named(parse_frontmatter(spec.read_text(encoding="utf-8")), bundle.name, BUNDLE_RE) + fields["path"] = f"changes/{bundle.name}/{spec.name}" + fields["name"] = bundle.name + bundles.append(fields) + return bundles + + +def load_decisions() -> list[dict[str, str]]: + """Read each decision's frontmatter; derive date/slug from the file name.""" + decisions: list[dict[str, str]] = [] + if not DECISIONS_DIR.is_dir(): + return decisions + for path in sorted(DECISIONS_DIR.glob("*.md")): + if path.name == "README.md" or path.name.startswith("_"): + continue + fields = _named(parse_frontmatter(path.read_text(encoding="utf-8")), path.stem, DECISION_RE) + fields["path"] = f"decisions/{path.name}" + fields["name"] = path.stem + decisions.append(fields) + return decisions + + +def format_row(bundle: dict[str, str]) -> str: + """Render one bundle as a Markdown list item.""" + slug = bundle.get("slug", "?") + path = bundle.get("path", "") + date = bundle.get("date", "") + summary = bundle.get("summary") or "(no summary)" + line = f"- **[{slug}]({path})** ({date}) — {summary}" + if bundle.get("supersedes"): + line += f" _(supersedes {bundle['supersedes']})_" + if bundle.get("superseded_by"): + line += f" _(superseded by {bundle['superseded_by']})_" + return line + + +def render(bundles: list[dict[str, str]], decisions: list[dict[str, str]]) -> str: + """Render the full Markdown listing: changes then decisions, newest-first.""" + out = ["# Planning index", "", "_Generated by `just index` — do not edit._", "", "## Changes", ""] + change_rows = sorted(bundles, key=lambda b: b.get("name", ""), reverse=True) + out += [format_row(b) for b in change_rows] if change_rows else ["_None._"] + out += ["", "## Decisions", ""] + decision_rows = sorted(decisions, key=lambda d: d.get("name", ""), reverse=True) + out += [format_row(d) for d in decision_rows] if decision_rows else ["_None._"] + out.append("") + return "\n".join(out).rstrip() + "\n" + + +def _require(fields: dict[str, str], keys: tuple[str, ...], rel: str, violations: list[str]) -> None: + """Append a violation for each required key that is absent or empty.""" + violations.extend(f"{rel}: missing or empty frontmatter key '{key}'" for key in keys if not fields.get(key)) + + +def _check_spec_file(path: pathlib.Path, rel: str, violations: list[str]) -> None: + """Validate a design.md / change.md spec file (requires `summary`).""" + fields = parse_frontmatter(path.read_text(encoding="utf-8")) + _require(fields, SPEC_REQUIRED, rel, violations) + + +def _check_bundle(bundle: pathlib.Path, violations: list[str]) -> None: + """Validate one change bundle directory.""" + rel = f"changes/{bundle.name}" + if BUNDLE_RE.match(bundle.name) is None: + violations.append(f"{rel}: directory name is not 'YYYY-MM-DD.NN-slug'") + violations.extend( + f"{rel}/{child.name}: unexpected file in bundle (allowed: {', '.join(sorted(ALLOWED_BUNDLE_FILES))})" + for child in sorted(bundle.iterdir()) + if child.name not in ALLOWED_BUNDLE_FILES + ) + design = bundle / "design.md" + change = bundle / "change.md" + if not design.exists() and not change.exists(): + violations.append(f"{rel}: bundle has neither design.md nor change.md") + for spec_file in (design, change): + if spec_file.exists(): + _check_spec_file(spec_file, f"{rel}/{spec_file.name}", violations) + # plan.md carries no frontmatter — its identity comes from the bundle dir. + + +def _check_decision(path: pathlib.Path, violations: list[str]) -> None: + """Validate one decision file (requires `status` + `summary`).""" + rel = f"decisions/{path.name}" + if DECISION_RE.match(path.stem) is None: + violations.append(f"{rel}: file name is not 'YYYY-MM-DD-slug.md'") + fields = parse_frontmatter(path.read_text(encoding="utf-8")) + _require(fields, DECISION_REQUIRED, rel, violations) + status = fields.get("status", "") + if status and status not in VALID_DECISION_STATUS: + violations.append(f"{rel}: invalid status '{status}' (allowed: {', '.join(sorted(VALID_DECISION_STATUS))})") + + +def check() -> list[str]: + """Validate every bundle and decision; return the list of violation strings.""" + violations: list[str] = [] + if CHANGES_DIR.is_dir(): + for bundle in sorted(CHANGES_DIR.iterdir()): + if bundle.is_dir(): + _check_bundle(bundle, violations) + if DECISIONS_DIR.is_dir(): + for path in sorted(DECISIONS_DIR.glob("*.md")): + if path.name == "README.md" or path.name.startswith("_"): + continue + _check_decision(path, violations) + return violations + + +def main() -> int: + """Print the listing to stdout, or validate bundles with --check.""" + if "--check" in sys.argv[1:]: + violations = check() + if violations: + sys.stderr.write(f"planning: {len(violations)} violation(s)\n") + for violation in violations: + sys.stderr.write(f" - {violation}\n") + return 1 + sys.stdout.write("planning: OK\n") + return 0 + sys.stdout.write(render(load_bundles(), load_decisions())) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/planning/releases/.gitkeep b/planning/releases/.gitkeep new file mode 100644 index 0000000..e69de29